298 lines
12 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|
|
}
|