package console import ( "strings" "testing" "time" "github.com/carlosframework/platform/internal/svcrole" ) // mustAccount mints a fresh account for a test, using the same constructor // the console itself calls (Store.CreateAccount) and the package's own // testAccountID for a collision-free id. func mustAccount(t *testing.T, s *Store, name string) *Account { t.Helper() id := testAccountID() if err := s.CreateAccount(id, name, name, "paul@keymail.dev", time.Now()); err != nil { t.Fatalf("CreateAccount(%q): %v", name, err) } return &Account{ID: id, Sqid: name, Name: name} } // TestCLIPrincipalLegacyAppTokenIsInstanceRole is the migration guarantee: // a row written before this change must read back with IDENTICAL authority. func TestCLIPrincipalLegacyAppTokenIsInstanceRole(t *testing.T) { s := openTestStore(t) now := time.Now() acct := mustAccount(t, s, "legacy") raw, err := s.NewAppToken(acct.ID, "titogo", "paul@example.test", "router", now) if err != nil { t.Fatalf("NewAppToken: %v", err) } p, ok := s.CLIPrincipal(raw, now) if !ok { t.Fatal("CLIPrincipal(app token) = !ok; want ok") } if p.Kind != PrincipalService { t.Errorf("Kind = %v; want PrincipalService", p.Kind) } if p.Role != svcrole.Instance { t.Errorf("Role = %q; want %q", p.Role, svcrole.Instance) } if p.AccountID != acct.ID || p.App != "titogo" { t.Errorf("scope = %d/%q; want %d/%q", p.AccountID, p.App, acct.ID, "titogo") } // The minter is a recorded fact, never the owner. if p.Address != "paul@example.test" { t.Errorf("Address = %q; want the minter", p.Address) } } func TestCLIPrincipalUserToken(t *testing.T) { s := openTestStore(t) now := time.Now() raw := mintUserToken(t, s, "paul@example.test", now) p, ok := s.CLIPrincipal(raw, now) if !ok { t.Fatal("CLIPrincipal(user token) = !ok; want ok") } if p.Kind != PrincipalUser { t.Errorf("Kind = %v; want PrincipalUser", p.Kind) } if p.Address != "paul@example.test" { t.Errorf("Address = %q", p.Address) } // A person carries no role and no scope. Anything else here would let a // role check read a person as a service with authority. if p.Role != "" || p.AccountID != 0 || p.App != "" || p.Name != "" { t.Errorf("person carries scope/role: %+v", p) } } func TestCLIPrincipalServiceToken(t *testing.T) { s := openTestStore(t) now := time.Now() acct := mustAccount(t, s, "amadan") raw, err := s.NewServicePrincipal(acct.ID, "hub", "", svcrole.Publish, "paul@example.test", now) if err != nil { t.Fatalf("NewServicePrincipal: %v", err) } p, ok := s.CLIPrincipal(raw, now) if !ok { t.Fatal("CLIPrincipal(service token) = !ok; want ok") } if p.Kind != PrincipalService || p.Role != svcrole.Publish || p.Name != "hub" { t.Errorf("got %+v", p) } if p.AccountID != acct.ID { t.Errorf("AccountID = %d; want %d", p.AccountID, acct.ID) } if p.App != "" { t.Errorf("App = %q; want account-scoped (empty)", p.App) } } // TestServiceTokenIsNotAPerson is the containment property read from the // other side: the person door must not answer for a service credential, and // its refusal must be the one a fabricated token gets. func TestServiceTokenIsNotAPerson(t *testing.T) { s := openTestStore(t) now := time.Now() acct := mustAccount(t, s, "amadan") raw, err := s.NewServicePrincipal(acct.ID, "hub", "", svcrole.Publish, "paul@example.test", now) if err != nil { t.Fatalf("NewServicePrincipal: %v", err) } if addr, ok := s.CLITokenAddress(raw, now); ok { t.Errorf("CLITokenAddress(service token) = %q,true; want \"\",false", addr) } } func TestRevokedServicePrincipalStopsResolving(t *testing.T) { s := openTestStore(t) now := time.Now() acct := mustAccount(t, s, "amadan") raw, err := s.NewServicePrincipal(acct.ID, "hub", "", svcrole.Publish, "paul@example.test", now) if err != nil { t.Fatalf("NewServicePrincipal: %v", err) } if err := s.RevokeServicePrincipal(acct.ID, "hub", now); err != nil { t.Fatalf("RevokeServicePrincipal: %v", err) } if _, ok := s.CLIPrincipal(raw, now); ok { t.Error("CLIPrincipal(revoked) = ok; want !ok") } } // TestRotateKeepsTheNameAndKillsTheOldSecret is why `name` exists as a column // separate from the token hash: rate-limit keys and audit rows stay // continuous across a secret change, and the old secret stops working in the // same call. func TestRotateKeepsTheNameAndKillsTheOldSecret(t *testing.T) { s := openTestStore(t) now := time.Now() acct := mustAccount(t, s, "amadan") old, err := s.NewServicePrincipal(acct.ID, "hub", "", svcrole.Publish, "paul@example.test", now) if err != nil { t.Fatalf("NewServicePrincipal: %v", err) } fresh, err := s.RotateServicePrincipal(acct.ID, "hub", now) if err != nil { t.Fatalf("RotateServicePrincipal: %v", err) } if fresh == old { t.Fatal("rotate returned the same secret") } if _, ok := s.CLIPrincipal(old, now); ok { t.Error("old secret still resolves after rotate") } p, ok := s.CLIPrincipal(fresh, now) if !ok { t.Fatal("fresh secret does not resolve") } if p.Name != "hub" || p.Role != svcrole.Publish { t.Errorf("rotate changed identity: %+v", p) } } // TestNameIsUniquePerAccountAmongLiveRows — the name is the handle every // other surface addresses, so two live rows sharing one would make rotate and // revoke ambiguous. func TestNameIsUniquePerAccountAmongLiveRows(t *testing.T) { s := openTestStore(t) now := time.Now() acct := mustAccount(t, s, "amadan") if _, err := s.NewServicePrincipal(acct.ID, "hub", "", svcrole.Publish, "paul@example.test", now); err != nil { t.Fatalf("first mint: %v", err) } if _, err := s.NewServicePrincipal(acct.ID, "hub", "", svcrole.Operate, "paul@example.test", now); err == nil { t.Fatal("second mint with the same name succeeded; want ErrServiceNameTaken") } // A different account may hold the same name: the namespace is per // account, like an app name, not global like a fleet name. other := mustAccount(t, s, "other") if _, err := s.NewServicePrincipal(other.ID, "hub", "", svcrole.Publish, "paul@example.test", now); err != nil { t.Errorf("same name in another account: %v", err) } } // TestUnparseableRoleRefusesRatherThanFallingThrough: a kind='service' row // whose role name this binary does not know must refuse outright, never // resolve as a role-less service a gate might treat as anything. func TestUnparseableRoleRefusesRatherThanFallingThrough(t *testing.T) { s := openTestStore(t) now := time.Now() acct := mustAccount(t, s, "amadan") raw, err := s.NewServicePrincipal(acct.ID, "hub", "", svcrole.Publish, "paul@example.test", now) if err != nil { t.Fatalf("NewServicePrincipal: %v", err) } if _, err := s.db.Exec(`UPDATE cli_tokens SET role = 'made-up-role' WHERE token_hash = ?`, hashToken(raw)); err != nil { t.Fatal(err) } if _, ok := s.CLIPrincipal(raw, now); ok { t.Error("CLIPrincipal(unparseable role) = ok; want !ok") } } // TestServicePrincipalsListsBothKinds: the account's listing must show // legacy kind='app' rows too, normalized to the instance role, or absorbing // them into the new model would make them invisible to their own owner. func TestServicePrincipalsListsBothKinds(t *testing.T) { s := openTestStore(t) now := time.Now() acct := mustAccount(t, s, "amadan") if _, err := s.NewServicePrincipal(acct.ID, "hub", "", svcrole.Publish, "paul@example.test", now); err != nil { t.Fatalf("NewServicePrincipal: %v", err) } if _, err := s.NewAppToken(acct.ID, "titogo", "paul@example.test", "router", now); err != nil { t.Fatalf("NewAppToken: %v", err) } toks, err := s.ServicePrincipals(acct.ID) if err != nil { t.Fatal(err) } if len(toks) != 2 { t.Fatalf("ServicePrincipals = %d rows, want 2", len(toks)) } var svc, app *CLIToken for i := range toks { switch toks[i].Kind { case cliKindApp: app = &toks[i] case cliKindService: svc = &toks[i] } } if svc == nil || app == nil { t.Fatalf("ServicePrincipals = %+v, want one row of each kind", toks) } if svc.Name != "hub" || svc.Role != string(svcrole.Publish) { t.Errorf("service row = %+v", svc) } if app.Role != string(svcrole.Instance) { t.Errorf("legacy app row Role = %q; want normalized to %q", app.Role, svcrole.Instance) } } // TestRotateAndRevokeIgnoreLegacyNamelessRows: a legacy kind='app' row's name // is empty, and RotateServicePrincipal/RevokeServicePrincipal must never treat // that empty string as a name to match — that would let one call rotate or // revoke an unrelated, unnamed credential. func TestRotateAndRevokeIgnoreLegacyNamelessRows(t *testing.T) { s := openTestStore(t) now := time.Now() acct := mustAccount(t, s, "amadan") app, err := s.NewAppToken(acct.ID, "titogo", "paul@example.test", "router", now) if err != nil { t.Fatalf("NewAppToken: %v", err) } if _, err := s.RotateServicePrincipal(acct.ID, "", now); err == nil { t.Fatal("RotateServicePrincipal(\"\") succeeded against a legacy row; want a refusal") } if err := s.RevokeServicePrincipal(acct.ID, "", now); err == nil { t.Fatal("RevokeServicePrincipal(\"\") succeeded against a legacy row; want a refusal") } // The legacy app token must still work: neither call touched it. if _, ok := s.CLIPrincipal(app, now); !ok { t.Error("legacy app token stopped resolving after a no-op rotate/revoke by empty name") } } // TestRevokeAccountTokenScopesByAccountNotAddress: the only handle a legacy // nameless row has is its listing id, and the account (not the minting // address) must be able to use it. func TestRevokeAccountTokenScopesByAccountNotAddress(t *testing.T) { s := openTestStore(t) now := time.Now() acct := mustAccount(t, s, "amadan") other := mustAccount(t, s, "other") app, err := s.NewAppToken(acct.ID, "titogo", "paul@example.test", "router", now) if err != nil { t.Fatalf("NewAppToken: %v", err) } id := hashToken(app) if err := s.RevokeAccountToken(other.ID, id, now); err == nil { t.Fatal("a different account revoked this one's token") } if _, ok := s.CLIPrincipal(app, now); !ok { t.Fatal("token stopped working after a refused cross-account revoke") } if err := s.RevokeAccountToken(acct.ID, id, now); err != nil { t.Fatalf("RevokeAccountToken: %v", err) } if _, ok := s.CLIPrincipal(app, now); ok { t.Error("token still resolves after RevokeAccountToken") } } // TestNewServicePrincipalRejectsEmptyName: an empty name is not one a caller chose — // it is the default every legacy kind='app' row already carries — and both // RotateServicePrincipal and RevokeServicePrincipal refuse to match it, so a // kind='service' row minted with an empty name would be unaddressable except by // RevokeAccountToken's raw id. The store refuses it at the door instead. func TestNewServicePrincipalRejectsEmptyName(t *testing.T) { s := openTestStore(t) now := time.Now() acct := mustAccount(t, s, "amadan") if _, err := s.NewServicePrincipal(acct.ID, "", "", svcrole.Publish, "paul@example.test", now); err == nil { t.Fatal("NewServicePrincipal(name=\"\") succeeded; want a refusal") } } // TestServiceKindNeverExpiresAndIsService is C8's missing test: a // kind='service' row must report NeverExpires() == true (expires_at is 0 by // design) and IsService() == true — otherwise templates/settings.html would // render every service credential as expiring 1 Jan 1970, the exact failure // C8 exists to prevent. func TestServiceKindNeverExpiresAndIsService(t *testing.T) { s := openTestStore(t) now := time.Now() acct := mustAccount(t, s, "amadan") if _, err := s.NewServicePrincipal(acct.ID, "hub", "", svcrole.Publish, "paul@example.test", now); err != nil { t.Fatalf("NewServicePrincipal: %v", err) } toks, err := s.ServicePrincipals(acct.ID) if err != nil { t.Fatal(err) } if len(toks) != 1 { t.Fatalf("ServicePrincipals = %d rows, want 1", len(toks)) } tok := toks[0] if !tok.IsService() { t.Error("IsService() = false for a kind='service' row") } if tok.IsApp() { t.Error("IsApp() = true for a kind='service' row") } if !tok.NeverExpires() { t.Error("NeverExpires() = false for a service principal; would render as expiring 1 Jan 1970") } } // TestCLITokenAddressRefusesRevokedUserRow exercises a state the Go API // cannot otherwise produce (RevokeCLIToken deletes rather than soft-revokes // a user row): same raw-SQL technique as // TestUnparseableRoleRefusesRatherThanFallingThrough, proving the // revoked_at != 0 branch in CLITokenAddress is live code, not dead code. func TestCLITokenAddressRefusesRevokedUserRow(t *testing.T) { s := openTestStore(t) now := time.Now() raw := mintUserToken(t, s, "paul@example.test", now) if _, err := s.db.Exec(`UPDATE cli_tokens SET revoked_at = ? WHERE token_hash = ?`, now.Unix(), hashToken(raw)); err != nil { t.Fatal(err) } if addr, ok := s.CLITokenAddress(raw, now); ok { t.Errorf("CLITokenAddress(revoked user row) = %q,true; want \"\",false", addr) } } // TestRevokedNameIsFreeForAFreshMint: RevokeServicePrincipal tombstones the // row rather than deleting it (Risk A), and the unique index // (cli_tokens_service_name) is scoped to live rows — a revoked `hub` must // not permanently squat the name. func TestRevokedNameIsFreeForAFreshMint(t *testing.T) { s := openTestStore(t) now := time.Now() acct := mustAccount(t, s, "amadan") if _, err := s.NewServicePrincipal(acct.ID, "hub", "", svcrole.Publish, "paul@example.test", now); err != nil { t.Fatalf("first mint: %v", err) } if err := s.RevokeServicePrincipal(acct.ID, "hub", now); err != nil { t.Fatalf("RevokeServicePrincipal: %v", err) } if _, err := s.NewServicePrincipal(acct.ID, "hub", "", svcrole.Operate, "paul@example.test", now); err != nil { t.Fatalf("re-mint after revoke: %v", err) } } // TestRenameAppMovesServicePrincipalToo is F1's authority bug, made // concrete: an app-scoped service principal that does not follow a rename // becomes an orphaned credential a LATER app of the same (freed) name // inherits — authority nobody minted it for. func TestRenameAppMovesServicePrincipalToo(t *testing.T) { s := openTestStore(t) now := time.Now() acct := mustAccount(t, s, "amadan") if _, err := s.CreateAppIn(acct.ID, "titogo", "paul@example.test", now); err != nil { t.Fatalf("CreateAppIn: %v", err) } raw, err := s.NewServicePrincipal(acct.ID, "hub", "titogo", svcrole.Publish, "paul@example.test", now) if err != nil { t.Fatalf("NewServicePrincipal: %v", err) } if err := s.RenameAppIn(acct.ID, "titogo", "tito"); err != nil { t.Fatalf("RenameAppIn: %v", err) } // The freed name is claimable again — by a brand new app nobody minted a // credential for. if _, err := s.CreateAppIn(acct.ID, "titogo", "paul@example.test", now); err != nil { t.Fatalf("CreateAppIn (fresh titogo): %v", err) } p, ok := s.CLIPrincipal(raw, now) if !ok { t.Fatal("CLIPrincipal(hub) = !ok after rename; want ok") } if p.App != "tito" { t.Errorf("App = %q after rename; want the RENAMED app (tito) — not the freed name a new, different app now holds", p.App) } } // TestMoveAppMovesServicePrincipalToo is the MoveApp twin of the rename // guard fix: an app-scoped service principal must follow its app across // accounts, the same way a legacy app-kind token already does. func TestMoveAppMovesServicePrincipalToo(t *testing.T) { s := openTestStore(t) now := time.Now() from := mustAccount(t, s, "amadan") to := mustAccount(t, s, "other") if _, err := s.CreateAppIn(from.ID, "titogo", "paul@example.test", now); err != nil { t.Fatalf("CreateAppIn: %v", err) } raw, err := s.NewServicePrincipal(from.ID, "hub", "titogo", svcrole.Publish, "paul@example.test", now) if err != nil { t.Fatalf("NewServicePrincipal: %v", err) } if err := s.MoveApp("titogo", from.ID, to.ID); err != nil { t.Fatalf("MoveApp: %v", err) } p, ok := s.CLIPrincipal(raw, now) if !ok { t.Fatal("CLIPrincipal(hub) = !ok after move; want ok") } if p.AccountID != to.ID { t.Errorf("AccountID = %d after move; want %d (the destination account)", p.AccountID, to.ID) } } // TestRenameAppRefusesOntoNameWithStaleServiceCredential pins the widened // half of F1 that TestRenameAppMovesServicePrincipalToo does not reach: the // stale-token COUNT guard itself. Reverting that guard's `kind IN (?, ?)` // back to `kind = ?` (cliKindApp alone) would leave this test's refusal // silently absent — the rename would succeed onto a name a live service // credential still holds. func TestRenameAppRefusesOntoNameWithStaleServiceCredential(t *testing.T) { s := openTestStore(t) now := time.Now() acct := mustAccount(t, s, "amadan") if _, err := s.CreateAppIn(acct.ID, "titogo", "paul@example.test", now); err != nil { t.Fatalf("CreateAppIn: %v", err) } if _, err := s.NewServicePrincipal(acct.ID, "hub", "titogo", svcrole.Publish, "paul@example.test", now); err != nil { t.Fatalf("NewServicePrincipal: %v", err) } // The credential above follows the rename (proven separately by // TestRenameAppMovesServicePrincipalToo), so "titogo" is now clean. A // SECOND credential, minted directly against the freed name with no live // app behind it, stands in for what a purged predecessor's app-kind // token has always left behind — the exact shape the guard's own doc // comment describes, and the only way to construct it without a purge // path this test does not otherwise need. if err := s.RenameAppIn(acct.ID, "titogo", "tito"); err != nil { t.Fatalf("RenameAppIn (move away): %v", err) } if _, err := s.NewServicePrincipal(acct.ID, "leftover", "titogo", svcrole.Operate, "paul@example.test", now); err != nil { t.Fatalf("NewServicePrincipal (leftover): %v", err) } if _, err := s.CreateAppIn(acct.ID, "second", "paul@example.test", now); err != nil { t.Fatalf("CreateAppIn (second): %v", err) } err := s.RenameAppIn(acct.ID, "second", "titogo") if err == nil { t.Fatal("RenameAppIn onto a name with a stale service credential succeeded; want a refusal") } if !strings.Contains(err.Error(), "app credentials from a previous app of that name") { t.Errorf("error = %q, want the stale-token guard's message", err.Error()) } } // TestMoveAppRefusesOntoNameWithStaleServiceCredential is MoveApp's twin of // the test above: the equivalent COUNT guard, at the destination account. func TestMoveAppRefusesOntoNameWithStaleServiceCredential(t *testing.T) { s := openTestStore(t) now := time.Now() from := mustAccount(t, s, "amadan") to := mustAccount(t, s, "other") if _, err := s.CreateAppIn(from.ID, "titogo", "paul@example.test", now); err != nil { t.Fatalf("CreateAppIn: %v", err) } // A leftover, purged-predecessor-shaped service credential already sits // at the DESTINATION under the same app name the move targets. if _, err := s.NewServicePrincipal(to.ID, "leftover", "titogo", svcrole.Operate, "paul@example.test", now); err != nil { t.Fatalf("NewServicePrincipal (leftover at destination): %v", err) } err := s.MoveApp("titogo", from.ID, to.ID) if err == nil { t.Fatal("MoveApp onto a destination with a stale service credential succeeded; want a refusal") } if !strings.Contains(err.Error(), "app credentials for") { t.Errorf("error = %q, want the stale-token guard's message", err.Error()) } } // TestMoveAppRefusesOntoNameCollisionWithAccountScopedCredential is C18: the // stale-token guard above only counts rows still bound to the app being // moved, so it cannot see an ACCOUNT-scoped credential already live at the // destination under the same name — a collision the unique index // (cli_tokens_service_name) would otherwise catch as a raw constraint // error. This is MoveApp's second, narrower guard. func TestMoveAppRefusesOntoNameCollisionWithAccountScopedCredential(t *testing.T) { s := openTestStore(t) now := time.Now() from := mustAccount(t, s, "amadan") to := mustAccount(t, s, "other") if _, err := s.CreateAppIn(from.ID, "titogo", "paul@example.test", now); err != nil { t.Fatalf("CreateAppIn: %v", err) } traveler, err := s.NewServicePrincipal(from.ID, "hub", "titogo", svcrole.Publish, "paul@example.test", now) if err != nil { t.Fatalf("NewServicePrincipal (source, app-scoped): %v", err) } resident, err := s.NewServicePrincipal(to.ID, "hub", "", svcrole.Operate, "paul@example.test", now) if err != nil { t.Fatalf("NewServicePrincipal (destination, account-scoped): %v", err) } if err := s.MoveApp("titogo", from.ID, to.ID); err == nil { t.Fatal("MoveApp onto a name-colliding destination succeeded; want a refusal") } else if !strings.Contains(err.Error(), "hub") || !strings.Contains(err.Error(), "already holds a live service credential") { t.Errorf("error = %q, want it to name the colliding credential", err.Error()) } // The refusal must be transactional: both credentials keep their // original scope, neither dropped nor merged. tp, ok := s.CLIPrincipal(traveler, now) if !ok || tp.AccountID != from.ID || tp.App != "titogo" { t.Errorf("traveling credential changed scope: %+v ok=%v", tp, ok) } rp, ok := s.CLIPrincipal(resident, now) if !ok || rp.AccountID != to.ID || rp.App != "" { t.Errorf("resident credential changed scope: %+v ok=%v", rp, ok) } } // TestRotateAdvancesRotatedAtLeavesCreatedAt is C19: rotated_at is when the // CURRENT secret began (stamped at mint and at every rotate); created_at is // when the credential was granted and never moves again. This is also the // third bullet of C21 read from the other side — reverting the created_at // preservation this test also covers would fail it. func TestRotateAdvancesRotatedAtLeavesCreatedAt(t *testing.T) { s := openTestStore(t) minted := time.Now() acct := mustAccount(t, s, "amadan") if _, err := s.NewServicePrincipal(acct.ID, "hub", "", svcrole.Publish, "paul@example.test", minted); err != nil { t.Fatalf("NewServicePrincipal: %v", err) } rotated := minted.Add(48 * time.Hour) if _, err := s.RotateServicePrincipal(acct.ID, "hub", rotated); err != nil { t.Fatalf("RotateServicePrincipal: %v", err) } toks, err := s.ServicePrincipals(acct.ID) if err != nil { t.Fatal(err) } if len(toks) != 1 { t.Fatalf("ServicePrincipals = %d rows, want 1", len(toks)) } tok := toks[0] if tok.CreatedAt.Unix() != minted.Unix() { t.Errorf("CreatedAt = %v after rotate, want the original mint time %v", tok.CreatedAt, minted) } if tok.RotatedAt.Unix() != rotated.Unix() { t.Errorf("RotatedAt = %v after rotate, want the rotate time %v", tok.RotatedAt, rotated) } } // TestNewServicePrincipalStampsRotatedAtAtMint: the current secret begins // exactly when the credential does, so a freshly minted row's rotated_at // must equal its created_at, not read as 0 (which would misrender as // "never rotated" rather than "rotated when granted"). func TestNewServicePrincipalStampsRotatedAtAtMint(t *testing.T) { s := openTestStore(t) now := time.Now() acct := mustAccount(t, s, "amadan") if _, err := s.NewServicePrincipal(acct.ID, "hub", "", svcrole.Publish, "paul@example.test", now); err != nil { t.Fatalf("NewServicePrincipal: %v", err) } toks, err := s.ServicePrincipals(acct.ID) if err != nil { t.Fatal(err) } if len(toks) != 1 { t.Fatalf("ServicePrincipals = %d rows, want 1", len(toks)) } tok := toks[0] if tok.RotatedAt.Unix() != tok.CreatedAt.Unix() { t.Errorf("RotatedAt = %v, CreatedAt = %v; want them equal at mint", tok.RotatedAt, tok.CreatedAt) } if tok.RotatedAt.Unix() != now.Unix() { t.Errorf("RotatedAt = %v; want the mint time %v", tok.RotatedAt, now) } } // TestRevokeAccountTokenRefusesZeroAccount is C20: the same fact that makes // RevokeAccountToken's kind-less query safe for every real caller — a // person's row always carries account_id = 0 — makes accountID == 0 // catastrophic for a caller that reaches this door by mistake. Proven from // the dangerous side: pass 0 and a real person's own listing id, and assert // their token survives. func TestRevokeAccountTokenRefusesZeroAccount(t *testing.T) { s := openTestStore(t) now := time.Now() raw := mintUserToken(t, s, "paul@example.test", now) id := hashToken(raw) if err := s.RevokeAccountToken(0, id, now); err == nil { t.Fatal("RevokeAccountToken(accountID=0) succeeded; want a refusal") } if _, ok := s.CLITokenAddress(raw, now); !ok { t.Error("a person's token was revoked via RevokeAccountToken(0, ...)") } } // TestRevokeAccountTokenSoftRevokesNotDeletes pins the Risk A resolution // from the reviewer's side: a DELETE (the pre-fix behaviour) would leave // zero rows and fail this test. func TestRevokeAccountTokenSoftRevokesNotDeletes(t *testing.T) { s := openTestStore(t) now := time.Now() acct := mustAccount(t, s, "amadan") app, err := s.NewAppToken(acct.ID, "titogo", "paul@example.test", "router", now) if err != nil { t.Fatalf("NewAppToken: %v", err) } id := hashToken(app) if err := s.RevokeAccountToken(acct.ID, id, now); err != nil { t.Fatalf("RevokeAccountToken: %v", err) } var count int var revokedAt int64 if err := s.db.QueryRow(`SELECT COUNT(*), COALESCE(MAX(revoked_at), 0) FROM cli_tokens WHERE token_hash = ?`, id). Scan(&count, &revokedAt); err != nil { t.Fatal(err) } if count != 1 { t.Fatalf("row count after RevokeAccountToken = %d, want 1 (a DELETE would leave 0)", count) } if revokedAt == 0 { t.Error("revoked_at is still 0 after RevokeAccountToken") } } // TestRevokedServicePrincipalAbsentFromCLITokens pins the Risk B resolution: // dropping CLITokens' revoked_at = 0 filter would leave this test's // assertion false — the revoked row would still render on the minter's own // settings page. func TestRevokedServicePrincipalAbsentFromCLITokens(t *testing.T) { s := openTestStore(t) now := time.Now() acct := mustAccount(t, s, "amadan") if _, err := s.NewServicePrincipal(acct.ID, "hub", "", svcrole.Publish, "paul@example.test", now); err != nil { t.Fatalf("NewServicePrincipal: %v", err) } if err := s.RevokeServicePrincipal(acct.ID, "hub", now); err != nil { t.Fatalf("RevokeServicePrincipal: %v", err) } toks, err := s.CLITokens("paul@example.test") if err != nil { t.Fatal(err) } for _, tok := range toks { if tok.Name == "hub" { t.Fatalf("revoked service principal %q still present in CLITokens: %+v", tok.Name, tok) } } } // TestCLIPrincipalUserRowWithZeroExpiryIsExpired: expires_at = 0 means // "never" for an app or service row and "the epoch" — expired — for a // person's. That asymmetry is deliberate and it is the ONE place the two // resolvers could have drifted apart: CLITokenAddress has always read a // person's 0 as expired, and CLIPrincipal is on its way to replacing it. // NewCLIToken always stamps now+CLITokenTTL, so no such row exists today; // this is the guard against the day one does, because inheriting the // service kinds' "never" would make it an immortal credential. func TestCLIPrincipalUserRowWithZeroExpiryIsExpired(t *testing.T) { s := openTestStore(t) now := time.Now() insert := func(raw string, expires int64) { t.Helper() if _, err := s.db.Exec(`INSERT INTO cli_tokens (token_hash, address, label, remote_addr, created_at, expires_at, kind, account_id, app) VALUES (?, 'paul@example.test', 'terminal', '', ?, ?, ?, 0, '')`, hashToken(raw), now.Unix(), expires, cliKindUser); err != nil { t.Fatal(err) } } // The control: an ordinary, unexpired person's row resolves. Without // this the assertion below could pass on a malformed insert. live, err := randomToken() if err != nil { t.Fatal(err) } insert(live, now.Add(time.Hour).Unix()) p, ok := s.CLIPrincipal(live, now) if !ok || p.Kind != PrincipalUser || p.Address != "paul@example.test" { t.Fatalf("CLIPrincipal(live user row) = %+v,%v; want paul as a person", p, ok) } // The guard: the same row with expires_at = 0. immortal, err := randomToken() if err != nil { t.Fatal(err) } insert(immortal, 0) if p, ok := s.CLIPrincipal(immortal, now); ok { t.Errorf("CLIPrincipal(user row, expires_at=0) = %+v, ok; want refused — 0 is never for a service, the epoch for a person", p) } // And the two resolvers agree, which is the property that has to survive // cliUser being folded into this one. if _, ok := s.CLITokenAddress(immortal, now); ok { t.Error("CLITokenAddress(user row, expires_at=0) = ok; the two resolvers disagree") } }