carlos / platform Public

console: TestMoveCapableGateAtTheRoutes: plan 1's admission first, then the 404 that costs nothing

In unknown and draining every caller meets plan 1's 503 at the three
mutations. With lifetimes on (and again before the cutover), a plain
instance token, an admin token, another app's move-capable token and a
member's token get the family's 404 with no bucket call and only binding
SQL (cli_tokens, accounts, apps), against absent, eligible, ineligible and
unreadable records, with a fresh limiter, no bucket and an exhausted
limiter. The owner's move-capable token passes and meets the 429 and the
no-bucket 503; a malformed hostname is the 404 before the rails; a revoked
token is today's 401; outside self-serve mode a plain token reaches the
legacy body as main. Counting bucket and SQL wrappers live in the test.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 46863d17b3cf2e1e484334ba7ef974fd531b8d5b parent 35d1bdc
1 file changed, +549 −0
  • internal/console/selfserve_gate_test.go +549 −0
diff --git a/internal/console/selfserve_gate_test.go b/internal/console/selfserve_gate_test.go
new file mode 100644
index 000000000..86c52b633
--- /dev/null
+++ b/internal/console/selfserve_gate_test.go
@@ -0,0 +1,549 @@
+package console
+
+import (
+ "context"
+ "database/sql"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "regexp"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/release"
+ "github.com/carlosframework/platform/internal/svcrole"
+)
+
+// ---- counting wrappers ------------------------------------------------------
+
+// countingRelStore counts every call into the deployment bucket. It embeds
+// the concrete DirStore and overrides every method DirStore has, so no
+// optional interface is hidden and no call goes uncounted.
+type countingRelStore struct {
+ *release.DirStore
+ n atomic.Int64
+}
+
+func (c *countingRelStore) Get(ctx context.Context, key string) ([]byte, string, error) {
+ c.n.Add(1)
+ return c.DirStore.Get(ctx, key)
+}
+
+func (c *countingRelStore) Open(ctx context.Context, key string) (io.ReadCloser, string, error) {
+ c.n.Add(1)
+ return c.DirStore.Open(ctx, key)
+}
+
+func (c *countingRelStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) {
+ c.n.Add(1)
+ return c.DirStore.Put(ctx, key, body, ifMatch)
+}
+
+func (c *countingRelStore) PutFrom(ctx context.Context, key string, body io.ReadSeeker, size int64, ifMatch string) (string, error) {
+ c.n.Add(1)
+ return c.DirStore.PutFrom(ctx, key, body, size, ifMatch)
+}
+
+func (c *countingRelStore) List(ctx context.Context, prefix string) ([]string, error) {
+ c.n.Add(1)
+ return c.DirStore.List(ctx, prefix)
+}
+
+func (c *countingRelStore) ListPage(ctx context.Context, prefix, startAfter string, limit int) ([]string, error) {
+ c.n.Add(1)
+ return c.DirStore.ListPage(ctx, prefix, startAfter, limit)
+}
+
+func (c *countingRelStore) ListMod(ctx context.Context, prefix string) ([]release.KeyMod, error) {
+ c.n.Add(1)
+ return c.DirStore.ListMod(ctx, prefix)
+}
+
+func (c *countingRelStore) ListSizes(ctx context.Context, prefix string) ([]release.KeySize, error) {
+ c.n.Add(1)
+ return c.DirStore.ListSizes(ctx, prefix)
+}
+
+func (c *countingRelStore) ListDirs(ctx context.Context, prefix string) ([]string, error) {
+ c.n.Add(1)
+ return c.DirStore.ListDirs(ctx, prefix)
+}
+
+func (c *countingRelStore) Delete(ctx context.Context, key string) error {
+ c.n.Add(1)
+ return c.DirStore.Delete(ctx, key)
+}
+
+func (c *countingRelStore) DeleteIfMatch(ctx context.Context, key, etag string) error {
+ c.n.Add(1)
+ return c.DirStore.DeleteIfMatch(ctx, key, etag)
+}
+
+// bindingSQL is the SQL the gate may run: authenticating the token
+// (cli_tokens) and resolving the account and the app (accounts, apps).
+// Everything else is evidence.
+var bindingSQL = regexp.MustCompile(`\b(cli_tokens|accounts|apps)\b`)
+
+// countingSQL classifies every statement the console store runs. A
+// transaction is counted as evidence when it begins: its statements do not
+// pass through this handle.
+type countingSQL struct {
+ sqlHandle
+ mu sync.Mutex
+ binding int
+ evidence []string
+}
+
+func (c *countingSQL) note(q string) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ if bindingSQL.MatchString(q) {
+ c.binding++
+ return
+ }
+ c.evidence = append(c.evidence, strings.Join(strings.Fields(q), " "))
+}
+
+func (c *countingSQL) reset() (binding int, evidence []string) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ binding, evidence = c.binding, c.evidence
+ c.binding, c.evidence = 0, nil
+ return
+}
+
+func (c *countingSQL) Exec(q string, args ...any) (sql.Result, error) {
+ c.note(q)
+ return c.sqlHandle.Exec(q, args...)
+}
+
+func (c *countingSQL) ExecContext(ctx context.Context, q string, args ...any) (sql.Result, error) {
+ c.note(q)
+ return c.sqlHandle.ExecContext(ctx, q, args...)
+}
+
+func (c *countingSQL) Query(q string, args ...any) (*sql.Rows, error) {
+ c.note(q)
+ return c.sqlHandle.Query(q, args...)
+}
+
+func (c *countingSQL) QueryContext(ctx context.Context, q string, args ...any) (*sql.Rows, error) {
+ c.note(q)
+ return c.sqlHandle.QueryContext(ctx, q, args...)
+}
+
+func (c *countingSQL) QueryRow(q string, args ...any) *sql.Row {
+ c.note(q)
+ return c.sqlHandle.QueryRow(q, args...)
+}
+
+func (c *countingSQL) QueryRowContext(ctx context.Context, q string, args ...any) *sql.Row {
+ c.note(q)
+ return c.sqlHandle.QueryRowContext(ctx, q, args...)
+}
+
+func (c *countingSQL) Begin() (*sql.Tx, error) {
+ c.note("BEGIN")
+ return c.sqlHandle.Begin()
+}
+
+func (c *countingSQL) BeginTx(ctx context.Context, opts *sql.TxOptions) (*sql.Tx, error) {
+ c.note("BEGIN")
+ return c.sqlHandle.BeginTx(ctx, opts)
+}
+
+func (c *countingSQL) Prepare(q string) (*sql.Stmt, error) {
+ c.note(q)
+ return c.sqlHandle.Prepare(q)
+}
+
+func (c *countingSQL) PrepareContext(ctx context.Context, q string) (*sql.Stmt, error) {
+ c.note(q)
+ return c.sqlHandle.PrepareContext(ctx, q)
+}
+
+// ---- the scene --------------------------------------------------------------
+
+// selfServeDoors are the three mutation doors the self-serve gate fronts.
+var selfServeDoors = []struct{ name, method, suffix, body string }{
+ {"request", http.MethodPost, "/move", `{"region":"uk"}`},
+ {"cut", http.MethodPost, "/move/cut", ""},
+ {"abort", http.MethodDelete, "/move", ""},
+}
+
+// moveRecordStates writes ltOne's record in each state the gate must never
+// read: absent, eligible, ineligible (pooled) and unreadable.
+var moveRecordStates = []string{"absent", "eligible", "ineligible", "unreadable"}
+
+func setMoveRecord(t *testing.T, st release.Store, state string) {
+ t.Helper()
+ ctx := context.Background()
+ key := provision.RecordKey(release.OpsAccount, ltApp, ltOne)
+ if err := st.Delete(ctx, key); err != nil {
+ t.Fatal(err)
+ }
+ rec := provision.Record{Schema: 1, Host: ltOne, Slug: "one", App: ltApp, Account: release.OpsAccount,
+ Channel: "edge", CreatedAt: time.Date(2026, 10, 22, 11, 0, 0, 0, time.UTC)}
+ var body []byte
+ switch state {
+ case "absent":
+ return
+ case "eligible":
+ body, _ = json.Marshal(rec)
+ case "ineligible":
+ rec.Pool = "shared"
+ body, _ = json.Marshal(rec)
+ case "unreadable":
+ body = []byte("{not a record")
+ }
+ if _, err := st.Put(ctx, key, body, ""); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// moveScene is the lifetimes fixture with every caller the gate meets.
+type moveScene struct {
+ l lifetimesFx
+ owner, plain, admin, foreign, revoked string
+ ownerFresh string // a second owner token, for the limiter's own count
+}
+
+func newMoveScene(t *testing.T) moveScene {
+ t.Helper()
+ l := newLifetimesFx(t)
+ st, now := l.fx.st, time.Now()
+ if _, err := st.CreateAppIn(OpsAccountID, "other", "paul@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+ mint := func(app string, moves bool) string {
+ raw, err := st.NewAppToken(OpsAccountID, app, "paul@keymail.dev", "router", moves, now)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return raw
+ }
+ sc := moveScene{l: l, owner: mint(ltApp, true), plain: mint(ltApp, false), foreign: mint("other", true),
+ revoked: mint(ltApp, true), ownerFresh: mint(ltApp, true)}
+ admin, err := st.NewServicePrincipal(OpsAccountID, "ops", ltApp, svcrole.Admin, "paul@keymail.dev", now)
+ if err != nil {
+ t.Fatal(err)
+ }
+ sc.admin = admin
+ if err := st.RevokeCLIToken("paul@keymail.dev", hashToken(sc.revoked)); err != nil {
+ t.Fatal(err)
+ }
+ return sc
+}
+
+func (sc moveScene) url(host, suffix string) string {
+ return instancesURL(sc.l.fx, "bab", ltApp) + "/" + host + suffix
+}
+
+// exhaust spends token's instance budget.
+func (sc moveScene) exhaust(token string) {
+ for sc.l.fx.srv.cli.instances.allow(hashToken(token), time.Now()) {
+ }
+}
+
+// remaining counts what is left of token's instance budget, spending it.
+func (sc moveScene) remaining(token string) int {
+ n := 0
+ for sc.l.fx.srv.cli.instances.allow(hashToken(token), time.Now()) {
+ n++
+ }
+ return n
+}
+
+// costedCall makes one request and reports what it cost: the bucket calls,
+// the binding statements and the evidence statements.
+type costedCall func(method, u, tok, body string) (code int, h http.Header, out string, reads int64, binding int, evidence []string)
+
+// counting installs the counting bucket and the counting SQL handle on the
+// scene's console. The bucket is installed as the console's; a rail may
+// take it away again.
+func (sc moveScene) counting(t *testing.T) (*countingRelStore, costedCall) {
+ t.Helper()
+ fx := sc.l.fx
+ counted := &countingRelStore{DirStore: fx.rel.(*deletableStore).Store.(*release.DirStore)}
+ fx.srv.cfg.Releases = counted
+ cq := &countingSQL{sqlHandle: fx.st.db}
+ fx.st.db = cq
+ return counted, func(method, u, tok, body string) (code int, h http.Header, out string, reads int64, binding int, evidence []string) {
+ counted.n.Store(0)
+ cq.reset()
+ code, h, out = rawReq(t, method, u, tok, body)
+ binding, evidence = cq.reset()
+ return code, h, out, counted.n.Load(), binding, evidence
+ }
+}
+
+// rails are the three conditions an unauthorised caller's answer must not
+// depend on, applied in this order: a fresh limiter with a bucket, no
+// bucket, and every caller's budget spent.
+func (sc moveScene) rails(counted *countingRelStore) []struct {
+ name string
+ apply func()
+} {
+ fx := sc.l.fx
+ return []struct {
+ name string
+ apply func()
+ }{
+ {"fresh", func() { fx.srv.cfg.Releases = counted }},
+ {"no deployment bucket", func() { fx.srv.cfg.Releases = nil }},
+ {"limiter exhausted", func() {
+ fx.srv.cfg.Releases = counted
+ for _, tok := range []string{sc.owner, sc.plain, sc.admin, sc.foreign, sc.l.member} {
+ sc.exhaust(tok)
+ }
+ }},
+ }
+}
+
+// requireUnauthorisedCostNothing: at the three mutations in self-serve
+// mode, a plain instance token, an admin token, another app's move-capable
+// token and a member's token get the family's 404 with no bucket call and
+// no SQL but the binding's, against every record state and every rail.
+func requireUnauthorisedCostNothing(t *testing.T, sc moveScene, counted *countingRelStore, call costedCall) {
+ t.Helper()
+ notFound := canonicalNotFound(t)
+ unauthorised := map[string]string{
+ "plain instance": sc.plain, "admin": sc.admin,
+ "another app's move-capable": sc.foreign, "member": sc.l.member,
+ }
+ for _, rail := range sc.rails(counted) {
+ rail.apply()
+ for _, state := range moveRecordStates {
+ setMoveRecord(t, sc.l.fx.rel, state)
+ for who, tok := range unauthorised {
+ for _, d := range selfServeDoors {
+ what := fmt.Sprintf("%s, %s record, %s: %s", rail.name, state, who, d.name)
+ code, h, body, reads, binding, evidence := call(d.method, sc.url(ltOne, d.suffix), tok, d.body)
+ assertNotFound(t, what, notFound, code, h, body)
+ if reads != 0 || len(evidence) != 0 || binding == 0 {
+ t.Errorf("%s: %d store calls, %d binding statements, evidence %q; want no store call and binding SQL only",
+ what, reads, binding, evidence)
+ }
+ }
+ }
+ }
+ }
+}
+
+// ---- the test ---------------------------------------------------------------
+
+func TestMoveCapableGateAtTheRoutes(t *testing.T) {
+ sc := newMoveScene(t)
+ fx := sc.l.fx
+ fx.srv.cfg.SelfServeMoves = "offline"
+ notFound := canonicalNotFound(t)
+ everyone := map[string]string{
+ "owner's move-capable": sc.owner, "plain instance": sc.plain, "admin": sc.admin,
+ "another app's move-capable": sc.foreign, "member": sc.l.member, "revoked": sc.revoked, "none": "",
+ }
+
+ t.Run("mode", func(t *testing.T) {
+ for _, c := range []struct {
+ sw string
+ hostClaim, phase bool
+ want bool
+ }{
+ {"offline", false, false, true},
+ {"", false, false, false},
+ {"on", false, false, false},
+ {"offline", true, false, false},
+ {"offline", false, true, false},
+ } {
+ s := &Server{cfg: Config{SelfServeMoves: c.sw, HostClaim: c.hostClaim, MovesEnabled: c.phase}}
+ if got := s.selfServeMode(); got != c.want {
+ t.Errorf("selfServeMode(%q, claims %v, phase 1 %v) = %v, want %v", c.sw, c.hostClaim, c.phase, got, c.want)
+ }
+ }
+ })
+
+ // Plan 1's admission wraps every record route first: in unknown every
+ // caller, authorised or not, gets plan 1's 503 before this gate.
+ t.Run("unknown", func(t *testing.T) {
+ rel := &markerFaultStore{Store: fx.rel}
+ rel.fail.Store(true)
+ cfg := fx.srv.cfg
+ cfg.Releases = rel
+ srv, err := NewServer(cfg, fx.st)
+ if err != nil {
+ t.Fatal(err)
+ }
+ ts := httptest.NewServer(srv)
+ t.Cleanup(ts.Close)
+ for who, tok := range everyone {
+ for _, d := range selfServeDoors {
+ u := ts.URL + "/api/cli/apps/bab/" + ltApp + "/instances/" + ltOne + d.suffix
+ code, _, body := rawReq(t, d.method, u, tok, d.body)
+ if code != http.StatusServiceUnavailable || cutoverErrorOf(body) != msgLifetimesStarting {
+ t.Errorf("%s %s while unknown = %d %s, want 503 starting", who, d.name, code, body)
+ }
+ }
+ }
+ })
+
+ if code, _, body := sc.l.cutover(t, sc.l.operator); code != http.StatusOK {
+ t.Fatalf("cutover = %d %s", code, body)
+ }
+ t.Run("draining", func(t *testing.T) {
+ for who, tok := range everyone {
+ for _, d := range selfServeDoors {
+ code, _, body := rawReq(t, d.method, sc.url(ltOne, d.suffix), tok, d.body)
+ if code != http.StatusServiceUnavailable || cutoverErrorOf(body) != msgLifetimesDraining {
+ t.Errorf("%s %s while draining = %d %s, want 503 draining", who, d.name, code, body)
+ }
+ }
+ }
+ })
+
+ sc.l.clock.Advance(lifetimeSettle)
+ fx.srv.lifetimesWatchOnce(context.Background())
+ if !fx.srv.lifetimesOn() {
+ t.Fatal("lifetimes are not on")
+ }
+
+ // From here every bucket call and every statement is counted.
+ counted, call := sc.counting(t)
+ rails := sc.rails(counted)
+ t.Run("unauthorised callers get the 404 and cost nothing", func(t *testing.T) {
+ requireUnauthorisedCostNothing(t, sc, counted, call)
+ })
+ // The same before the cutover, where plan 1 admits the request as a
+ // legacy unit: the gate does not depend on the admission's mode.
+ t.Run("unauthorised callers before the cutover", func(t *testing.T) {
+ before := newMoveScene(t)
+ before.l.fx.srv.cfg.SelfServeMoves = "offline"
+ if got := before.l.fx.srv.cutover.status().State; got != "before" {
+ t.Fatalf("the second console is %s, want before", got)
+ }
+ c, call := before.counting(t)
+ requireUnauthorisedCostNothing(t, before, c, call)
+ })
+
+ // The owner's move-capable token passes the gate, then meets the
+ // instance gate's rails exactly as the instance gate gives them.
+ t.Run("the owner's token meets the rails", func(t *testing.T) {
+ setMoveRecord(t, fx.rel, "eligible")
+ // Past the rails it reaches the self-serve body, which until Task 5
+ // answers the family's 404: the budget shows each request got there.
+ fx.srv.cfg.Releases = counted
+ passed, err := fx.st.NewAppToken(OpsAccountID, ltApp, "paul@keymail.dev", "router", true, time.Now())
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, d := range selfServeDoors {
+ code, h, body, _, _, _ := call(d.method, sc.url(ltOne, d.suffix), passed, d.body)
+ assertNotFound(t, "the owner's "+d.name+" past the rails", notFound, code, h, body)
+ }
+ if left := sc.remaining(passed); left != cliInstanceBurst-len(selfServeDoors) {
+ t.Errorf("the owner's requests left %d of the budget, want %d: each one passed the limiter",
+ left, cliInstanceBurst-len(selfServeDoors))
+ }
+ fx.srv.cfg.Releases = nil
+ for _, d := range selfServeDoors {
+ code, _, body, reads, _, evidence := call(d.method, sc.url(ltOne, d.suffix), sc.ownerFresh, d.body)
+ if code != http.StatusServiceUnavailable || cutoverErrorOf(body) != mainGateCopy().noBucket || reads != 0 || len(evidence) != 0 {
+ t.Errorf("no bucket, %s = %d %s (%d store calls, evidence %q), want 503 %q",
+ d.name, code, body, reads, evidence, mainGateCopy().noBucket)
+ }
+ }
+ fx.srv.cfg.Releases = counted
+ if left := sc.remaining(sc.ownerFresh); left != cliInstanceBurst-len(selfServeDoors) {
+ t.Errorf("the no-bucket answers left %d of the budget, want %d: the limiter runs before the bucket check",
+ left, cliInstanceBurst-len(selfServeDoors))
+ }
+ for _, d := range selfServeDoors {
+ code, _, body, reads, _, evidence := call(d.method, sc.url(ltOne, d.suffix), sc.owner, d.body)
+ want := mainGateCopy().tooMany("instance")
+ if code != http.StatusTooManyRequests || cutoverErrorOf(body) != want || reads != 0 || len(evidence) != 0 {
+ t.Errorf("limiter exhausted, %s = %d %s (%d store calls, evidence %q), want 429 %q",
+ d.name, code, body, reads, evidence, want)
+ }
+ }
+ })
+
+ // The declared exception: a malformed hostname is the 404 before the
+ // rails, for the owner's token too.
+ t.Run("a malformed hostname is the 404 before the rails", func(t *testing.T) {
+ const bad = "Shop_Example"
+ if provision.ValidHostShape(bad) {
+ t.Fatalf("%q is a valid host shape", bad)
+ }
+ for _, rail := range rails {
+ rail.apply()
+ for _, d := range selfServeDoors {
+ what := fmt.Sprintf("%s: owner %s %s", rail.name, d.name, bad)
+ code, h, body, reads, _, evidence := call(d.method, sc.url(bad, d.suffix), sc.owner, d.body)
+ assertNotFound(t, what, notFound, code, h, body)
+ if reads != 0 || len(evidence) != 0 {
+ t.Errorf("%s: %d store calls, evidence %q", what, reads, evidence)
+ }
+ }
+ }
+ })
+
+ t.Run("a revoked token gets today's 401", func(t *testing.T) {
+ fx.srv.cfg.Releases = counted
+ wantCode, _, wantBody := rawReq(t, http.MethodPost, sc.url(ltOne, "/move"), "not-a-token", `{"region":"uk"}`)
+ if wantCode != http.StatusUnauthorized {
+ t.Fatalf("a fabricated token = %d %s, want 401", wantCode, wantBody)
+ }
+ for _, d := range selfServeDoors {
+ code, _, body, reads, _, evidence := call(d.method, sc.url(ltOne, d.suffix), sc.revoked, d.body)
+ if code != wantCode || body != wantBody || reads != 0 || len(evidence) != 0 {
+ t.Errorf("revoked %s = %d %s (%d store calls, evidence %q), want the fabricated token's %d %s",
+ d.name, code, body, reads, evidence, wantCode, wantBody)
+ }
+ }
+ })
+
+ // Outside self-serve mode the plain instance token reaches the legacy
+ // body, which answers as main.
+ t.Run("outside self-serve mode the legacy doors answer", func(t *testing.T) {
+ fx.srv.cfg.SelfServeMoves = ""
+ fx.srv.cfg.Releases = fx.rel
+ setMoveRecord(t, fx.rel, "eligible")
+ plain, err := fx.st.NewAppToken(OpsAccountID, ltApp, "paul@keymail.dev", "router", false, time.Now())
+ if err != nil {
+ t.Fatal(err)
+ }
+ // main's answers: the request's own sentence, and moveTransition's
+ // err.Error() for a record with no move.
+ quiet := func(mut func(*provision.Record) error) string {
+ err := mut(&provision.Record{Schema: 1, Host: ltOne})
+ if err == nil {
+ t.Fatal("a quiet record accepted the transition")
+ }
+ return err.Error()
+ }
+ for _, c := range []struct {
+ door string
+ code int
+ msg string
+ }{
+ {"request", http.StatusBadRequest, "this deployment publishes no armed regions \u2014 nothing to move to"},
+ {"cut", http.StatusConflict, quiet(provision.MoveMarkCutting())},
+ {"abort", http.StatusConflict, quiet(provision.MoveAbort())},
+ } {
+ var d = selfServeDoors[0]
+ for _, x := range selfServeDoors {
+ if x.name == c.door {
+ d = x
+ }
+ }
+ code, _, body := rawReq(t, d.method, sc.url(ltOne, d.suffix), plain, d.body)
+ if code != c.code || cutoverErrorOf(body) != c.msg {
+ t.Errorf("legacy %s = %d %s, want %d %q", c.door, code, body, c.code, c.msg)
+ }
+ }
+ })
+}