carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package console
2
3import (
4 "strings"
5 "testing"
6 "time"
7
8 "github.com/carlosframework/platform/internal/svcrole"
9)
10
11// mustAccount mints a fresh account for a test, using the same constructor
12// the console itself calls (Store.CreateAccount) and the package's own
13// testAccountID for a collision-free id.
14func mustAccount(t *testing.T, s *Store, name string) *Account {
15 t.Helper()
16 id := testAccountID()
17 if err := s.CreateAccount(id, name, name, "paul@keymail.dev", time.Now()); err != nil {
18 t.Fatalf("CreateAccount(%q): %v", name, err)
19 }
20 return &Account{ID: id, Sqid: name, Name: name}
21}
22
23// TestCLIPrincipalLegacyAppTokenIsInstanceRole is the migration guarantee:
24// a row written before this change must read back with IDENTICAL authority.
25func TestCLIPrincipalLegacyAppTokenIsInstanceRole(t *testing.T) {
26 s := openTestStore(t)
27 now := time.Now()
28 acct := mustAccount(t, s, "legacy")
29 raw, err := s.NewAppToken(acct.ID, "titogo", "paul@example.test", "router", now)
30 if err != nil {
31 t.Fatalf("NewAppToken: %v", err)
32 }
33 p, ok := s.CLIPrincipal(raw, now)
34 if !ok {
35 t.Fatal("CLIPrincipal(app token) = !ok; want ok")
36 }
37 if p.Kind != PrincipalService {
38 t.Errorf("Kind = %v; want PrincipalService", p.Kind)
39 }
40 if p.Role != svcrole.Instance {
41 t.Errorf("Role = %q; want %q", p.Role, svcrole.Instance)
42 }
43 if p.AccountID != acct.ID || p.App != "titogo" {
44 t.Errorf("scope = %d/%q; want %d/%q", p.AccountID, p.App, acct.ID, "titogo")
45 }
46 // The minter is a recorded fact, never the owner.
47 if p.Address != "paul@example.test" {
48 t.Errorf("Address = %q; want the minter", p.Address)
49 }
50}
51
52func TestCLIPrincipalUserToken(t *testing.T) {
53 s := openTestStore(t)
54 now := time.Now()
55 raw := mintUserToken(t, s, "paul@example.test", now)
56 p, ok := s.CLIPrincipal(raw, now)
57 if !ok {
58 t.Fatal("CLIPrincipal(user token) = !ok; want ok")
59 }
60 if p.Kind != PrincipalUser {
61 t.Errorf("Kind = %v; want PrincipalUser", p.Kind)
62 }
63 if p.Address != "paul@example.test" {
64 t.Errorf("Address = %q", p.Address)
65 }
66 // A person carries no role and no scope. Anything else here would let a
67 // role check read a person as a service with authority.
68 if p.Role != "" || p.AccountID != 0 || p.App != "" || p.Name != "" {
69 t.Errorf("person carries scope/role: %+v", p)
70 }
71}
72
73func TestCLIPrincipalServiceToken(t *testing.T) {
74 s := openTestStore(t)
75 now := time.Now()
76 acct := mustAccount(t, s, "amadan")
77 raw, err := s.NewServicePrincipal(acct.ID, "hub", "", svcrole.Publish, "paul@example.test", now)
78 if err != nil {
79 t.Fatalf("NewServicePrincipal: %v", err)
80 }
81 p, ok := s.CLIPrincipal(raw, now)
82 if !ok {
83 t.Fatal("CLIPrincipal(service token) = !ok; want ok")
84 }
85 if p.Kind != PrincipalService || p.Role != svcrole.Publish || p.Name != "hub" {
86 t.Errorf("got %+v", p)
87 }
88 if p.AccountID != acct.ID {
89 t.Errorf("AccountID = %d; want %d", p.AccountID, acct.ID)
90 }
91 if p.App != "" {
92 t.Errorf("App = %q; want account-scoped (empty)", p.App)
93 }
94}
95
96// TestServiceTokenIsNotAPerson is the containment property read from the
97// other side: the person door must not answer for a service credential, and
98// its refusal must be the one a fabricated token gets.
99func TestServiceTokenIsNotAPerson(t *testing.T) {
100 s := openTestStore(t)
101 now := time.Now()
102 acct := mustAccount(t, s, "amadan")
103 raw, err := s.NewServicePrincipal(acct.ID, "hub", "", svcrole.Publish, "paul@example.test", now)
104 if err != nil {
105 t.Fatalf("NewServicePrincipal: %v", err)
106 }
107 if addr, ok := s.CLITokenAddress(raw, now); ok {
108 t.Errorf("CLITokenAddress(service token) = %q,true; want \"\",false", addr)
109 }
110}
111
112func TestRevokedServicePrincipalStopsResolving(t *testing.T) {
113 s := openTestStore(t)
114 now := time.Now()
115 acct := mustAccount(t, s, "amadan")
116 raw, err := s.NewServicePrincipal(acct.ID, "hub", "", svcrole.Publish, "paul@example.test", now)
117 if err != nil {
118 t.Fatalf("NewServicePrincipal: %v", err)
119 }
120 if err := s.RevokeServicePrincipal(acct.ID, "hub", now); err != nil {
121 t.Fatalf("RevokeServicePrincipal: %v", err)
122 }
123 if _, ok := s.CLIPrincipal(raw, now); ok {
124 t.Error("CLIPrincipal(revoked) = ok; want !ok")
125 }
126}
127
128// TestRotateKeepsTheNameAndKillsTheOldSecret is why `name` exists as a column
129// separate from the token hash: rate-limit keys and audit rows stay
130// continuous across a secret change, and the old secret stops working in the
131// same call.
132func TestRotateKeepsTheNameAndKillsTheOldSecret(t *testing.T) {
133 s := openTestStore(t)
134 now := time.Now()
135 acct := mustAccount(t, s, "amadan")
136 old, err := s.NewServicePrincipal(acct.ID, "hub", "", svcrole.Publish, "paul@example.test", now)
137 if err != nil {
138 t.Fatalf("NewServicePrincipal: %v", err)
139 }
140 fresh, err := s.RotateServicePrincipal(acct.ID, "hub", now)
141 if err != nil {
142 t.Fatalf("RotateServicePrincipal: %v", err)
143 }
144 if fresh == old {
145 t.Fatal("rotate returned the same secret")
146 }
147 if _, ok := s.CLIPrincipal(old, now); ok {
148 t.Error("old secret still resolves after rotate")
149 }
150 p, ok := s.CLIPrincipal(fresh, now)
151 if !ok {
152 t.Fatal("fresh secret does not resolve")
153 }
154 if p.Name != "hub" || p.Role != svcrole.Publish {
155 t.Errorf("rotate changed identity: %+v", p)
156 }
157}
158
159// TestNameIsUniquePerAccountAmongLiveRows — the name is the handle every
160// other surface addresses, so two live rows sharing one would make rotate and
161// revoke ambiguous.
162func TestNameIsUniquePerAccountAmongLiveRows(t *testing.T) {
163 s := openTestStore(t)
164 now := time.Now()
165 acct := mustAccount(t, s, "amadan")
166 if _, err := s.NewServicePrincipal(acct.ID, "hub", "", svcrole.Publish, "paul@example.test", now); err != nil {
167 t.Fatalf("first mint: %v", err)
168 }
169 if _, err := s.NewServicePrincipal(acct.ID, "hub", "", svcrole.Operate, "paul@example.test", now); err == nil {
170 t.Fatal("second mint with the same name succeeded; want ErrServiceNameTaken")
171 }
172 // A different account may hold the same name: the namespace is per
173 // account, like an app name, not global like a fleet name.
174 other := mustAccount(t, s, "other")
175 if _, err := s.NewServicePrincipal(other.ID, "hub", "", svcrole.Publish, "paul@example.test", now); err != nil {
176 t.Errorf("same name in another account: %v", err)
177 }
178}
179
180// TestUnparseableRoleRefusesRatherThanFallingThrough: a kind='service' row
181// whose role name this binary does not know must refuse outright, never
182// resolve as a role-less service a gate might treat as anything.
183func TestUnparseableRoleRefusesRatherThanFallingThrough(t *testing.T) {
184 s := openTestStore(t)
185 now := time.Now()
186 acct := mustAccount(t, s, "amadan")
187 raw, err := s.NewServicePrincipal(acct.ID, "hub", "", svcrole.Publish, "paul@example.test", now)
188 if err != nil {
189 t.Fatalf("NewServicePrincipal: %v", err)
190 }
191 if _, err := s.db.Exec(`UPDATE cli_tokens SET role = 'made-up-role' WHERE token_hash = ?`, hashToken(raw)); err != nil {
192 t.Fatal(err)
193 }
194 if _, ok := s.CLIPrincipal(raw, now); ok {
195 t.Error("CLIPrincipal(unparseable role) = ok; want !ok")
196 }
197}
198
199// TestServicePrincipalsListsBothKinds: the account's listing must show
200// legacy kind='app' rows too, normalized to the instance role, or absorbing
201// them into the new model would make them invisible to their own owner.
202func TestServicePrincipalsListsBothKinds(t *testing.T) {
203 s := openTestStore(t)
204 now := time.Now()
205 acct := mustAccount(t, s, "amadan")
206 if _, err := s.NewServicePrincipal(acct.ID, "hub", "", svcrole.Publish, "paul@example.test", now); err != nil {
207 t.Fatalf("NewServicePrincipal: %v", err)
208 }
209 if _, err := s.NewAppToken(acct.ID, "titogo", "paul@example.test", "router", now); err != nil {
210 t.Fatalf("NewAppToken: %v", err)
211 }
212 toks, err := s.ServicePrincipals(acct.ID)
213 if err != nil {
214 t.Fatal(err)
215 }
216 if len(toks) != 2 {
217 t.Fatalf("ServicePrincipals = %d rows, want 2", len(toks))
218 }
219 var svc, app *CLIToken
220 for i := range toks {
221 switch toks[i].Kind {
222 case cliKindApp:
223 app = &toks[i]
224 case cliKindService:
225 svc = &toks[i]
226 }
227 }
228 if svc == nil || app == nil {
229 t.Fatalf("ServicePrincipals = %+v, want one row of each kind", toks)
230 }
231 if svc.Name != "hub" || svc.Role != string(svcrole.Publish) {
232 t.Errorf("service row = %+v", svc)
233 }
234 if app.Role != string(svcrole.Instance) {
235 t.Errorf("legacy app row Role = %q; want normalized to %q", app.Role, svcrole.Instance)
236 }
237}
238
239// TestRotateAndRevokeIgnoreLegacyNamelessRows: a legacy kind='app' row's name
240// is empty, and RotateServicePrincipal/RevokeServicePrincipal must never treat
241// that empty string as a name to match — that would let one call rotate or
242// revoke an unrelated, unnamed credential.
243func TestRotateAndRevokeIgnoreLegacyNamelessRows(t *testing.T) {
244 s := openTestStore(t)
245 now := time.Now()
246 acct := mustAccount(t, s, "amadan")
247 app, err := s.NewAppToken(acct.ID, "titogo", "paul@example.test", "router", now)
248 if err != nil {
249 t.Fatalf("NewAppToken: %v", err)
250 }
251 if _, err := s.RotateServicePrincipal(acct.ID, "", now); err == nil {
252 t.Fatal("RotateServicePrincipal(\"\") succeeded against a legacy row; want a refusal")
253 }
254 if err := s.RevokeServicePrincipal(acct.ID, "", now); err == nil {
255 t.Fatal("RevokeServicePrincipal(\"\") succeeded against a legacy row; want a refusal")
256 }
257 // The legacy app token must still work: neither call touched it.
258 if _, ok := s.CLIPrincipal(app, now); !ok {
259 t.Error("legacy app token stopped resolving after a no-op rotate/revoke by empty name")
260 }
261}
262
263// TestRevokeAccountTokenScopesByAccountNotAddress: the only handle a legacy
264// nameless row has is its listing id, and the account (not the minting
265// address) must be able to use it.
266func TestRevokeAccountTokenScopesByAccountNotAddress(t *testing.T) {
267 s := openTestStore(t)
268 now := time.Now()
269 acct := mustAccount(t, s, "amadan")
270 other := mustAccount(t, s, "other")
271 app, err := s.NewAppToken(acct.ID, "titogo", "paul@example.test", "router", now)
272 if err != nil {
273 t.Fatalf("NewAppToken: %v", err)
274 }
275 id := hashToken(app)
276 if err := s.RevokeAccountToken(other.ID, id, now); err == nil {
277 t.Fatal("a different account revoked this one's token")
278 }
279 if _, ok := s.CLIPrincipal(app, now); !ok {
280 t.Fatal("token stopped working after a refused cross-account revoke")
281 }
282 if err := s.RevokeAccountToken(acct.ID, id, now); err != nil {
283 t.Fatalf("RevokeAccountToken: %v", err)
284 }
285 if _, ok := s.CLIPrincipal(app, now); ok {
286 t.Error("token still resolves after RevokeAccountToken")
287 }
288}
289
290// TestNewServicePrincipalRejectsEmptyName: an empty name is not one a caller chose —
291// it is the default every legacy kind='app' row already carries — and both
292// RotateServicePrincipal and RevokeServicePrincipal refuse to match it, so a
293// kind='service' row minted with an empty name would be unaddressable except by
294// RevokeAccountToken's raw id. The store refuses it at the door instead.
295func TestNewServicePrincipalRejectsEmptyName(t *testing.T) {
296 s := openTestStore(t)
297 now := time.Now()
298 acct := mustAccount(t, s, "amadan")
299 if _, err := s.NewServicePrincipal(acct.ID, "", "", svcrole.Publish, "paul@example.test", now); err == nil {
300 t.Fatal("NewServicePrincipal(name=\"\") succeeded; want a refusal")
301 }
302}
303
304// TestServiceKindNeverExpiresAndIsService is C8's missing test: a
305// kind='service' row must report NeverExpires() == true (expires_at is 0 by
306// design) and IsService() == true — otherwise templates/settings.html would
307// render every service credential as expiring 1 Jan 1970, the exact failure
308// C8 exists to prevent.
309func TestServiceKindNeverExpiresAndIsService(t *testing.T) {
310 s := openTestStore(t)
311 now := time.Now()
312 acct := mustAccount(t, s, "amadan")
313 if _, err := s.NewServicePrincipal(acct.ID, "hub", "", svcrole.Publish, "paul@example.test", now); err != nil {
314 t.Fatalf("NewServicePrincipal: %v", err)
315 }
316 toks, err := s.ServicePrincipals(acct.ID)
317 if err != nil {
318 t.Fatal(err)
319 }
320 if len(toks) != 1 {
321 t.Fatalf("ServicePrincipals = %d rows, want 1", len(toks))
322 }
323 tok := toks[0]
324 if !tok.IsService() {
325 t.Error("IsService() = false for a kind='service' row")
326 }
327 if tok.IsApp() {
328 t.Error("IsApp() = true for a kind='service' row")
329 }
330 if !tok.NeverExpires() {
331 t.Error("NeverExpires() = false for a service principal; would render as expiring 1 Jan 1970")
332 }
333}
334
335// TestCLITokenAddressRefusesRevokedUserRow exercises a state the Go API
336// cannot otherwise produce (RevokeCLIToken deletes rather than soft-revokes
337// a user row): same raw-SQL technique as
338// TestUnparseableRoleRefusesRatherThanFallingThrough, proving the
339// revoked_at != 0 branch in CLITokenAddress is live code, not dead code.
340func TestCLITokenAddressRefusesRevokedUserRow(t *testing.T) {
341 s := openTestStore(t)
342 now := time.Now()
343 raw := mintUserToken(t, s, "paul@example.test", now)
344 if _, err := s.db.Exec(`UPDATE cli_tokens SET revoked_at = ? WHERE token_hash = ?`, now.Unix(), hashToken(raw)); err != nil {
345 t.Fatal(err)
346 }
347 if addr, ok := s.CLITokenAddress(raw, now); ok {
348 t.Errorf("CLITokenAddress(revoked user row) = %q,true; want \"\",false", addr)
349 }
350}
351
352// TestRevokedNameIsFreeForAFreshMint: RevokeServicePrincipal tombstones the
353// row rather than deleting it (Risk A), and the unique index
354// (cli_tokens_service_name) is scoped to live rows — a revoked `hub` must
355// not permanently squat the name.
356func TestRevokedNameIsFreeForAFreshMint(t *testing.T) {
357 s := openTestStore(t)
358 now := time.Now()
359 acct := mustAccount(t, s, "amadan")
360 if _, err := s.NewServicePrincipal(acct.ID, "hub", "", svcrole.Publish, "paul@example.test", now); err != nil {
361 t.Fatalf("first mint: %v", err)
362 }
363 if err := s.RevokeServicePrincipal(acct.ID, "hub", now); err != nil {
364 t.Fatalf("RevokeServicePrincipal: %v", err)
365 }
366 if _, err := s.NewServicePrincipal(acct.ID, "hub", "", svcrole.Operate, "paul@example.test", now); err != nil {
367 t.Fatalf("re-mint after revoke: %v", err)
368 }
369}
370
371// TestRenameAppMovesServicePrincipalToo is F1's authority bug, made
372// concrete: an app-scoped service principal that does not follow a rename
373// becomes an orphaned credential a LATER app of the same (freed) name
374// inherits — authority nobody minted it for.
375func TestRenameAppMovesServicePrincipalToo(t *testing.T) {
376 s := openTestStore(t)
377 now := time.Now()
378 acct := mustAccount(t, s, "amadan")
379 if _, err := s.CreateAppIn(acct.ID, "titogo", "paul@example.test", now); err != nil {
380 t.Fatalf("CreateAppIn: %v", err)
381 }
382 raw, err := s.NewServicePrincipal(acct.ID, "hub", "titogo", svcrole.Publish, "paul@example.test", now)
383 if err != nil {
384 t.Fatalf("NewServicePrincipal: %v", err)
385 }
386 if err := s.RenameAppIn(acct.ID, "titogo", "tito"); err != nil {
387 t.Fatalf("RenameAppIn: %v", err)
388 }
389 // The freed name is claimable again — by a brand new app nobody minted a
390 // credential for.
391 if _, err := s.CreateAppIn(acct.ID, "titogo", "paul@example.test", now); err != nil {
392 t.Fatalf("CreateAppIn (fresh titogo): %v", err)
393 }
394 p, ok := s.CLIPrincipal(raw, now)
395 if !ok {
396 t.Fatal("CLIPrincipal(hub) = !ok after rename; want ok")
397 }
398 if p.App != "tito" {
399 t.Errorf("App = %q after rename; want the RENAMED app (tito) — not the freed name a new, different app now holds", p.App)
400 }
401}
402
403// TestMoveAppMovesServicePrincipalToo is the MoveApp twin of the rename
404// guard fix: an app-scoped service principal must follow its app across
405// accounts, the same way a legacy app-kind token already does.
406func TestMoveAppMovesServicePrincipalToo(t *testing.T) {
407 s := openTestStore(t)
408 now := time.Now()
409 from := mustAccount(t, s, "amadan")
410 to := mustAccount(t, s, "other")
411 if _, err := s.CreateAppIn(from.ID, "titogo", "paul@example.test", now); err != nil {
412 t.Fatalf("CreateAppIn: %v", err)
413 }
414 raw, err := s.NewServicePrincipal(from.ID, "hub", "titogo", svcrole.Publish, "paul@example.test", now)
415 if err != nil {
416 t.Fatalf("NewServicePrincipal: %v", err)
417 }
418 if err := s.MoveApp("titogo", from.ID, to.ID); err != nil {
419 t.Fatalf("MoveApp: %v", err)
420 }
421 p, ok := s.CLIPrincipal(raw, now)
422 if !ok {
423 t.Fatal("CLIPrincipal(hub) = !ok after move; want ok")
424 }
425 if p.AccountID != to.ID {
426 t.Errorf("AccountID = %d after move; want %d (the destination account)", p.AccountID, to.ID)
427 }
428}
429
430// TestRenameAppRefusesOntoNameWithStaleServiceCredential pins the widened
431// half of F1 that TestRenameAppMovesServicePrincipalToo does not reach: the
432// stale-token COUNT guard itself. Reverting that guard's `kind IN (?, ?)`
433// back to `kind = ?` (cliKindApp alone) would leave this test's refusal
434// silently absent — the rename would succeed onto a name a live service
435// credential still holds.
436func TestRenameAppRefusesOntoNameWithStaleServiceCredential(t *testing.T) {
437 s := openTestStore(t)
438 now := time.Now()
439 acct := mustAccount(t, s, "amadan")
440 if _, err := s.CreateAppIn(acct.ID, "titogo", "paul@example.test", now); err != nil {
441 t.Fatalf("CreateAppIn: %v", err)
442 }
443 if _, err := s.NewServicePrincipal(acct.ID, "hub", "titogo", svcrole.Publish, "paul@example.test", now); err != nil {
444 t.Fatalf("NewServicePrincipal: %v", err)
445 }
446 // The credential above follows the rename (proven separately by
447 // TestRenameAppMovesServicePrincipalToo), so "titogo" is now clean. A
448 // SECOND credential, minted directly against the freed name with no live
449 // app behind it, stands in for what a purged predecessor's app-kind
450 // token has always left behind — the exact shape the guard's own doc
451 // comment describes, and the only way to construct it without a purge
452 // path this test does not otherwise need.
453 if err := s.RenameAppIn(acct.ID, "titogo", "tito"); err != nil {
454 t.Fatalf("RenameAppIn (move away): %v", err)
455 }
456 if _, err := s.NewServicePrincipal(acct.ID, "leftover", "titogo", svcrole.Operate, "paul@example.test", now); err != nil {
457 t.Fatalf("NewServicePrincipal (leftover): %v", err)
458 }
459 if _, err := s.CreateAppIn(acct.ID, "second", "paul@example.test", now); err != nil {
460 t.Fatalf("CreateAppIn (second): %v", err)
461 }
462 err := s.RenameAppIn(acct.ID, "second", "titogo")
463 if err == nil {
464 t.Fatal("RenameAppIn onto a name with a stale service credential succeeded; want a refusal")
465 }
466 if !strings.Contains(err.Error(), "app credentials from a previous app of that name") {
467 t.Errorf("error = %q, want the stale-token guard's message", err.Error())
468 }
469}
470
471// TestMoveAppRefusesOntoNameWithStaleServiceCredential is MoveApp's twin of
472// the test above: the equivalent COUNT guard, at the destination account.
473func TestMoveAppRefusesOntoNameWithStaleServiceCredential(t *testing.T) {
474 s := openTestStore(t)
475 now := time.Now()
476 from := mustAccount(t, s, "amadan")
477 to := mustAccount(t, s, "other")
478 if _, err := s.CreateAppIn(from.ID, "titogo", "paul@example.test", now); err != nil {
479 t.Fatalf("CreateAppIn: %v", err)
480 }
481 // A leftover, purged-predecessor-shaped service credential already sits
482 // at the DESTINATION under the same app name the move targets.
483 if _, err := s.NewServicePrincipal(to.ID, "leftover", "titogo", svcrole.Operate, "paul@example.test", now); err != nil {
484 t.Fatalf("NewServicePrincipal (leftover at destination): %v", err)
485 }
486 err := s.MoveApp("titogo", from.ID, to.ID)
487 if err == nil {
488 t.Fatal("MoveApp onto a destination with a stale service credential succeeded; want a refusal")
489 }
490 if !strings.Contains(err.Error(), "app credentials for") {
491 t.Errorf("error = %q, want the stale-token guard's message", err.Error())
492 }
493}
494
495// TestMoveAppRefusesOntoNameCollisionWithAccountScopedCredential is C18: the
496// stale-token guard above only counts rows still bound to the app being
497// moved, so it cannot see an ACCOUNT-scoped credential already live at the
498// destination under the same name — a collision the unique index
499// (cli_tokens_service_name) would otherwise catch as a raw constraint
500// error. This is MoveApp's second, narrower guard.
501func TestMoveAppRefusesOntoNameCollisionWithAccountScopedCredential(t *testing.T) {
502 s := openTestStore(t)
503 now := time.Now()
504 from := mustAccount(t, s, "amadan")
505 to := mustAccount(t, s, "other")
506 if _, err := s.CreateAppIn(from.ID, "titogo", "paul@example.test", now); err != nil {
507 t.Fatalf("CreateAppIn: %v", err)
508 }
509 traveler, err := s.NewServicePrincipal(from.ID, "hub", "titogo", svcrole.Publish, "paul@example.test", now)
510 if err != nil {
511 t.Fatalf("NewServicePrincipal (source, app-scoped): %v", err)
512 }
513 resident, err := s.NewServicePrincipal(to.ID, "hub", "", svcrole.Operate, "paul@example.test", now)
514 if err != nil {
515 t.Fatalf("NewServicePrincipal (destination, account-scoped): %v", err)
516 }
517 if err := s.MoveApp("titogo", from.ID, to.ID); err == nil {
518 t.Fatal("MoveApp onto a name-colliding destination succeeded; want a refusal")
519 } else if !strings.Contains(err.Error(), "hub") || !strings.Contains(err.Error(), "already holds a live service credential") {
520 t.Errorf("error = %q, want it to name the colliding credential", err.Error())
521 }
522 // The refusal must be transactional: both credentials keep their
523 // original scope, neither dropped nor merged.
524 tp, ok := s.CLIPrincipal(traveler, now)
525 if !ok || tp.AccountID != from.ID || tp.App != "titogo" {
526 t.Errorf("traveling credential changed scope: %+v ok=%v", tp, ok)
527 }
528 rp, ok := s.CLIPrincipal(resident, now)
529 if !ok || rp.AccountID != to.ID || rp.App != "" {
530 t.Errorf("resident credential changed scope: %+v ok=%v", rp, ok)
531 }
532}
533
534// TestRotateAdvancesRotatedAtLeavesCreatedAt is C19: rotated_at is when the
535// CURRENT secret began (stamped at mint and at every rotate); created_at is
536// when the credential was granted and never moves again. This is also the
537// third bullet of C21 read from the other side — reverting the created_at
538// preservation this test also covers would fail it.
539func TestRotateAdvancesRotatedAtLeavesCreatedAt(t *testing.T) {
540 s := openTestStore(t)
541 minted := time.Now()
542 acct := mustAccount(t, s, "amadan")
543 if _, err := s.NewServicePrincipal(acct.ID, "hub", "", svcrole.Publish, "paul@example.test", minted); err != nil {
544 t.Fatalf("NewServicePrincipal: %v", err)
545 }
546 rotated := minted.Add(48 * time.Hour)
547 if _, err := s.RotateServicePrincipal(acct.ID, "hub", rotated); err != nil {
548 t.Fatalf("RotateServicePrincipal: %v", err)
549 }
550 toks, err := s.ServicePrincipals(acct.ID)
551 if err != nil {
552 t.Fatal(err)
553 }
554 if len(toks) != 1 {
555 t.Fatalf("ServicePrincipals = %d rows, want 1", len(toks))
556 }
557 tok := toks[0]
558 if tok.CreatedAt.Unix() != minted.Unix() {
559 t.Errorf("CreatedAt = %v after rotate, want the original mint time %v", tok.CreatedAt, minted)
560 }
561 if tok.RotatedAt.Unix() != rotated.Unix() {
562 t.Errorf("RotatedAt = %v after rotate, want the rotate time %v", tok.RotatedAt, rotated)
563 }
564}
565
566// TestNewServicePrincipalStampsRotatedAtAtMint: the current secret begins
567// exactly when the credential does, so a freshly minted row's rotated_at
568// must equal its created_at, not read as 0 (which would misrender as
569// "never rotated" rather than "rotated when granted").
570func TestNewServicePrincipalStampsRotatedAtAtMint(t *testing.T) {
571 s := openTestStore(t)
572 now := time.Now()
573 acct := mustAccount(t, s, "amadan")
574 if _, err := s.NewServicePrincipal(acct.ID, "hub", "", svcrole.Publish, "paul@example.test", now); err != nil {
575 t.Fatalf("NewServicePrincipal: %v", err)
576 }
577 toks, err := s.ServicePrincipals(acct.ID)
578 if err != nil {
579 t.Fatal(err)
580 }
581 if len(toks) != 1 {
582 t.Fatalf("ServicePrincipals = %d rows, want 1", len(toks))
583 }
584 tok := toks[0]
585 if tok.RotatedAt.Unix() != tok.CreatedAt.Unix() {
586 t.Errorf("RotatedAt = %v, CreatedAt = %v; want them equal at mint", tok.RotatedAt, tok.CreatedAt)
587 }
588 if tok.RotatedAt.Unix() != now.Unix() {
589 t.Errorf("RotatedAt = %v; want the mint time %v", tok.RotatedAt, now)
590 }
591}
592
593// TestRevokeAccountTokenRefusesZeroAccount is C20: the same fact that makes
594// RevokeAccountToken's kind-less query safe for every real caller — a
595// person's row always carries account_id = 0 — makes accountID == 0
596// catastrophic for a caller that reaches this door by mistake. Proven from
597// the dangerous side: pass 0 and a real person's own listing id, and assert
598// their token survives.
599func TestRevokeAccountTokenRefusesZeroAccount(t *testing.T) {
600 s := openTestStore(t)
601 now := time.Now()
602 raw := mintUserToken(t, s, "paul@example.test", now)
603 id := hashToken(raw)
604 if err := s.RevokeAccountToken(0, id, now); err == nil {
605 t.Fatal("RevokeAccountToken(accountID=0) succeeded; want a refusal")
606 }
607 if _, ok := s.CLITokenAddress(raw, now); !ok {
608 t.Error("a person's token was revoked via RevokeAccountToken(0, ...)")
609 }
610}
611
612// TestRevokeAccountTokenSoftRevokesNotDeletes pins the Risk A resolution
613// from the reviewer's side: a DELETE (the pre-fix behaviour) would leave
614// zero rows and fail this test.
615func TestRevokeAccountTokenSoftRevokesNotDeletes(t *testing.T) {
616 s := openTestStore(t)
617 now := time.Now()
618 acct := mustAccount(t, s, "amadan")
619 app, err := s.NewAppToken(acct.ID, "titogo", "paul@example.test", "router", now)
620 if err != nil {
621 t.Fatalf("NewAppToken: %v", err)
622 }
623 id := hashToken(app)
624 if err := s.RevokeAccountToken(acct.ID, id, now); err != nil {
625 t.Fatalf("RevokeAccountToken: %v", err)
626 }
627 var count int
628 var revokedAt int64
629 if err := s.db.QueryRow(`SELECT COUNT(*), COALESCE(MAX(revoked_at), 0) FROM cli_tokens WHERE token_hash = ?`, id).
630 Scan(&count, &revokedAt); err != nil {
631 t.Fatal(err)
632 }
633 if count != 1 {
634 t.Fatalf("row count after RevokeAccountToken = %d, want 1 (a DELETE would leave 0)", count)
635 }
636 if revokedAt == 0 {
637 t.Error("revoked_at is still 0 after RevokeAccountToken")
638 }
639}
640
641// TestRevokedServicePrincipalAbsentFromCLITokens pins the Risk B resolution:
642// dropping CLITokens' revoked_at = 0 filter would leave this test's
643// assertion false — the revoked row would still render on the minter's own
644// settings page.
645func TestRevokedServicePrincipalAbsentFromCLITokens(t *testing.T) {
646 s := openTestStore(t)
647 now := time.Now()
648 acct := mustAccount(t, s, "amadan")
649 if _, err := s.NewServicePrincipal(acct.ID, "hub", "", svcrole.Publish, "paul@example.test", now); err != nil {
650 t.Fatalf("NewServicePrincipal: %v", err)
651 }
652 if err := s.RevokeServicePrincipal(acct.ID, "hub", now); err != nil {
653 t.Fatalf("RevokeServicePrincipal: %v", err)
654 }
655 toks, err := s.CLITokens("paul@example.test")
656 if err != nil {
657 t.Fatal(err)
658 }
659 for _, tok := range toks {
660 if tok.Name == "hub" {
661 t.Fatalf("revoked service principal %q still present in CLITokens: %+v", tok.Name, tok)
662 }
663 }
664}
665
666// TestCLIPrincipalUserRowWithZeroExpiryIsExpired: expires_at = 0 means
667// "never" for an app or service row and "the epoch" — expired — for a
668// person's. That asymmetry is deliberate and it is the ONE place the two
669// resolvers could have drifted apart: CLITokenAddress has always read a
670// person's 0 as expired, and CLIPrincipal is on its way to replacing it.
671// NewCLIToken always stamps now+CLITokenTTL, so no such row exists today;
672// this is the guard against the day one does, because inheriting the
673// service kinds' "never" would make it an immortal credential.
674func TestCLIPrincipalUserRowWithZeroExpiryIsExpired(t *testing.T) {
675 s := openTestStore(t)
676 now := time.Now()
677 insert := func(raw string, expires int64) {
678 t.Helper()
679 if _, err := s.db.Exec(`INSERT INTO cli_tokens
680(token_hash, address, label, remote_addr, created_at, expires_at, kind, account_id, app)
681VALUES (?, 'paul@example.test', 'terminal', '', ?, ?, ?, 0, '')`,
682 hashToken(raw), now.Unix(), expires, cliKindUser); err != nil {
683 t.Fatal(err)
684 }
685 }
686
687 // The control: an ordinary, unexpired person's row resolves. Without
688 // this the assertion below could pass on a malformed insert.
689 live, err := randomToken()
690 if err != nil {
691 t.Fatal(err)
692 }
693 insert(live, now.Add(time.Hour).Unix())
694 p, ok := s.CLIPrincipal(live, now)
695 if !ok || p.Kind != PrincipalUser || p.Address != "paul@example.test" {
696 t.Fatalf("CLIPrincipal(live user row) = %+v,%v; want paul as a person", p, ok)
697 }
698
699 // The guard: the same row with expires_at = 0.
700 immortal, err := randomToken()
701 if err != nil {
702 t.Fatal(err)
703 }
704 insert(immortal, 0)
705 if p, ok := s.CLIPrincipal(immortal, now); ok {
706 t.Errorf("CLIPrincipal(user row, expires_at=0) = %+v, ok; want refused — 0 is never for a service, the epoch for a person", p)
707 }
708 // And the two resolvers agree, which is the property that has to survive
709 // cliUser being folded into this one.
710 if _, ok := s.CLITokenAddress(immortal, now); ok {
711 t.Error("CLITokenAddress(user row, expires_at=0) = ok; the two resolvers disagree")
712 }
713}
714