Files
kandev/apps/backend/internal/agentctl/server/api/ownership_test.go
T
2026-09-24 07:37:17 +01:00

298 lines
12 KiB
Go

package api
import (
"errors"
"net/http/httptest"
"testing"
"time"
agentctl "github.com/kandev/kandev/internal/agent/runtime/agentctl"
"github.com/kandev/kandev/internal/agentctl/server/config"
"github.com/kandev/kandev/internal/agentctl/server/instance"
"github.com/kandev/kandev/internal/common/logger"
)
// TestOwnershipStateRenewUpdatesLastRenewal pins that Renew advances the
// tracked instant, which the unowned-period reaper (Layer 5.5) will measure
// elapsed time against.
func TestOwnershipStateRenewUpdatesLastRenewal(t *testing.T) {
o := newOwnershipState()
time.Sleep(2 * time.Millisecond)
before := o.UnownedFor()
if !o.Renew() {
t.Fatal("Renew() = false, want true")
}
after := o.UnownedFor()
if after >= before {
t.Fatalf("UnownedFor after Renew (%v) >= before (%v), want renewal to reset the elapsed duration", after, before)
}
}
// TestOwnershipStateNewStartsFromProcessLaunch pins design 01's "A server
// spawned by a backend that then dies before completing the handshake has
// no successful renewal at all, so its period runs from process start and it
// reaps itself without special handling" -- the very first UnownedFor call,
// before any Renew, must already report a small nonzero-or-zero duration
// measured from construction, not an unset/zero-time sentinel that would
// read as "just now" forever or as an enormous elapsed duration.
func TestOwnershipStateNewStartsFromProcessLaunch(t *testing.T) {
o := newOwnershipState()
time.Sleep(2 * time.Millisecond)
elapsed := o.UnownedFor()
if elapsed <= 0 {
t.Fatalf("UnownedFor() = %v, want a small positive duration measured from construction", elapsed)
}
if elapsed > time.Second {
t.Fatalf("UnownedFor() = %v, want a small duration (construction just happened), not a large one implying a zero-time baseline", elapsed)
}
}
// TestOwnershipStateBeginShutdownIsOneWay pins the one-way door: once a
// shutdown has begun, Renew must refuse (never resurrect a decided
// shutdown), and BeginShutdown itself is idempotent (a second call reports
// it was already begun rather than restarting the decision).
func TestOwnershipStateBeginShutdownIsOneWay(t *testing.T) {
o := newOwnershipState()
if !o.BeginShutdown() {
t.Fatal("first BeginShutdown() = false, want true")
}
if o.BeginShutdown() {
t.Fatal("second BeginShutdown() = true, want false (idempotent one-way latch)")
}
if !o.IsShuttingDown() {
t.Fatal("IsShuttingDown() = false after BeginShutdown, want true")
}
if o.Renew() {
t.Fatal("Renew() = true after shutdown began, want false (a decided shutdown must never be reversed)")
}
}
// TestOwnershipStateTryBeginShutdownIfUnownedForRefusesAfterRecentRenewal
// pins the atomic check-and-latch's core correctness: a renewal that landed
// before the call must be observed by the SAME call that would otherwise
// latch shutdown, closing the TOCTOU window a separate UnownedFor()-then-
// BeginShutdown() sequence leaves open (Review round 1, finding 5).
func TestOwnershipStateTryBeginShutdownIfUnownedForRefusesAfterRecentRenewal(t *testing.T) {
o := newOwnershipState()
if !o.Renew() {
t.Fatal("Renew() = false, want true")
}
if o.TryBeginShutdownIfUnownedFor(time.Hour) {
t.Fatal("TryBeginShutdownIfUnownedFor() = true immediately after a renewal, want false")
}
if o.IsShuttingDown() {
t.Fatal("IsShuttingDown() = true after a refused attempt, want false")
}
}
// TestOwnershipStateTryBeginShutdownIfUnownedForLatchesOnceElapsed pins the
// success path: once the period has genuinely elapsed, the call both
// reports true and latches the one-way shutdown door.
func TestOwnershipStateTryBeginShutdownIfUnownedForLatchesOnceElapsed(t *testing.T) {
o := newOwnershipState()
o.mu.Lock()
o.lastRenewal = time.Now().Add(-time.Hour)
o.mu.Unlock()
if !o.TryBeginShutdownIfUnownedFor(time.Minute) {
t.Fatal("TryBeginShutdownIfUnownedFor() = false once the period elapsed, want true")
}
if !o.IsShuttingDown() {
t.Fatal("IsShuttingDown() = false after a successful latch, want true")
}
}
// TestOwnershipStateTryBeginShutdownIfUnownedForIsOneWay pins idempotency:
// a second call after the door is already latched must not report success
// again, matching BeginShutdown's existing one-way-door contract.
func TestOwnershipStateTryBeginShutdownIfUnownedForIsOneWay(t *testing.T) {
o := newOwnershipState()
o.mu.Lock()
o.lastRenewal = time.Now().Add(-time.Hour)
o.mu.Unlock()
if !o.TryBeginShutdownIfUnownedFor(time.Minute) {
t.Fatal("first TryBeginShutdownIfUnownedFor() = false, want true")
}
if o.TryBeginShutdownIfUnownedFor(time.Minute) {
t.Fatal("second TryBeginShutdownIfUnownedFor() = true, want false (one-way latch)")
}
}
// TestHandleOwnershipClaimRenewsOwnership pins that a successful claim
// renews the tracked ownership instant. The claim operation carries no
// instance identity -- a server with zero instances is still owned.
func TestHandleOwnershipClaimRenewsOwnership(t *testing.T) {
cfg := &config.Config{AuthToken: "test-token"}
log := logger.Default()
cs := NewControlServer(cfg, &instance.Manager{}, log)
server := httptest.NewServer(cs.Router())
defer server.Close()
host, port := parseHostPort(t, server.URL)
client := agentctl.NewControlClient(host, port, log, agentctl.WithControlAuthToken("test-token"))
backdateOwnership(cs, time.Hour)
if err := client.ClaimOwnership(t.Context()); err != nil {
t.Fatalf("ClaimOwnership: %v", err)
}
if elapsed := cs.ownership.UnownedFor(); elapsed >= time.Minute {
t.Fatalf("UnownedFor after claim = %v, want well under the backdated hour (claim should have renewed)", elapsed)
}
}
// backdateOwnership sets a ControlServer's tracked last-renewal instant into
// the past, so a test can assert that a subsequent operation renews it
// without racing HTTP round-trip latency against a tiny real elapsed
// duration.
func backdateOwnership(cs *ControlServer, age time.Duration) {
cs.ownership.mu.Lock()
defer cs.ownership.mu.Unlock()
cs.ownership.lastRenewal = time.Now().Add(-age)
}
// TestHandleOwnershipClaimRequiresAuth pins that the claim operation sits
// ABOVE identity/capability negotiation in design 01's gate ordering: unlike
// /identity, it is a normal authenticated endpoint.
func TestHandleOwnershipClaimRequiresAuth(t *testing.T) {
cfg := &config.Config{AuthToken: "test-token"}
log := logger.Default()
cs := NewControlServer(cfg, &instance.Manager{}, log)
server := httptest.NewServer(cs.Router())
defer server.Close()
host, port := parseHostPort(t, server.URL)
client := agentctl.NewControlClient(host, port, log) // no auth token
if err := client.ClaimOwnership(t.Context()); err == nil {
t.Fatal("ClaimOwnership without auth = nil error, want a rejection")
}
}
// TestHandleOwnershipClaimRefusedAfterShutdownBegun pins the one-way-door
// refusal at the HTTP layer: a claim arriving after an unowned shutdown has
// begun must be refused with a shutting-down outcome rather than reviving
// ownership underneath a teardown in progress (design 02's failure table).
func TestHandleOwnershipClaimRefusedAfterShutdownBegun(t *testing.T) {
cfg := &config.Config{AuthToken: "test-token"}
log := logger.Default()
cs := NewControlServer(cfg, &instance.Manager{}, log)
cs.ownership.BeginShutdown()
server := httptest.NewServer(cs.Router())
defer server.Close()
host, port := parseHostPort(t, server.URL)
client := agentctl.NewControlClient(host, port, log, agentctl.WithControlAuthToken("test-token"))
if err := client.ClaimOwnership(t.Context()); err == nil {
t.Fatal("ClaimOwnership after shutdown began = nil error, want a rejection")
}
}
// TestHandleHandshakeRenewsOwnership pins design 01's "two other operations
// renew it: the bootstrap handshake and a successful credential rotation" --
// a fresh server's first renewal comes from the handshake that mints its
// credential, giving the unowned-period timer a definite start without
// requiring a separate claim call immediately after.
func TestHandleHandshakeRenewsOwnership(t *testing.T) {
cfg := &config.Config{
AuthToken: "test-generated-token",
BootstrapNonce: "test-nonce",
}
log := logger.Default()
cs := NewControlServer(cfg, &instance.Manager{}, log)
server := httptest.NewServer(cs.Router())
defer server.Close()
host, port := parseHostPort(t, server.URL)
client := agentctl.NewControlClient(host, port, log)
backdateOwnership(cs, time.Hour)
if _, err := client.Handshake(t.Context(), "test-nonce"); err != nil {
t.Fatalf("Handshake: %v", err)
}
if elapsed := cs.ownership.UnownedFor(); elapsed >= time.Minute {
t.Fatalf("UnownedFor after handshake = %v, want well under the backdated hour (handshake should have renewed)", elapsed)
}
}
// TestNonRenewingControlOperationsDoNotRenewOwnership pins the negative
// clause of AC-EXECUTORS-CONTROL-OWNERSHIP-003.8: beyond the claim, the
// bootstrap handshake and a successful credential rotation, "no operation
// shall renew ownership: neither an instance operation, nor an open stream,
// nor enumeration". Only the three renewing operations are pinned
// positively elsewhere, which leaves the clause that actually carries the
// guarantee unguarded -- a renewal added to the shared credential
// middleware, or to any instance route, would make a backend that is busy
// but has stopped renewing look present forever and defeat the unowned
// shutdown entirely. Every case drives real HTTP through the router, so the
// middleware is on the path under test rather than bypassed.
//
// The clause's "open stream" arm is structural rather than testable here:
// the streaming routes live on the per-instance Server (server.go), which
// holds no ownership state at all, so no stream can reach this clock.
func TestNonRenewingControlOperationsDoNotRenewOwnership(t *testing.T) {
const backdate = time.Hour
cfg := &config.Config{AuthToken: "test-token"}
log := logger.Default()
cs := NewControlServer(cfg, &instance.Manager{}, log)
server := httptest.NewServer(cs.Router())
defer server.Close()
host, port := parseHostPort(t, server.URL)
client := agentctl.NewControlClient(host, port, log, agentctl.WithControlAuthToken("test-token"))
// Each call asserts its own outcome before ownership is examined. A
// request that never reached the handler would also leave ownership
// unrenewed, so without these the test would pass vacuously against a
// server that was not listening at all.
cases := []struct {
name string
call func(t *testing.T)
}{
{"enumeration", func(t *testing.T) {
if _, err := client.ListInstances(t.Context()); err != nil {
t.Fatalf("ListInstances: %v", err)
}
}},
{"instance operation", func(t *testing.T) {
_, err := client.GetInstance(t.Context(), "no-such-instance")
// Only the handler's 404 maps to this sentinel; a transport failure
// must not make the ownership assertion pass vacuously.
if !errors.Is(err, agentctl.ErrInstanceNotFound) {
t.Fatalf("GetInstance error = %v, want the handler's not-found response", err)
}
}},
{"ownership details read", func(t *testing.T) {
if _, err := client.GetServerDetails(t.Context()); err != nil {
t.Fatalf("GetServerDetails: %v", err)
}
}},
{"health", func(t *testing.T) {
if err := client.Health(t.Context()); err != nil {
t.Fatalf("Health: %v", err)
}
}},
{"identity", func(t *testing.T) {
if _, err := client.GetIdentity(t.Context()); err != nil {
t.Fatalf("GetIdentity: %v", err)
}
}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
backdateOwnership(cs, backdate)
tc.call(t)
if elapsed := cs.ownership.UnownedFor(); elapsed < backdate {
t.Fatalf("UnownedFor() after %s = %v, want >= %v (this operation must not renew ownership)", tc.name, elapsed, backdate)
}
})
}
}