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 "path/filepath"
5 "reflect"
6 "sort"
7 "sync/atomic"
8 "testing"
9 "time"
10)
11
12// testAccountID hands out distinct account ids for fixtures that just need an
13// account to exist. Real minting is a bucket operation now
14// (domains.MintAccount, which allocates the next free integer with a
15// create-only manifest write) and most of these tests have no bucket in play;
16// they only care that two fixture accounts differ. Starts well above the
17// built-in ops id and any hand-written literal in these tests.
18var testIDs atomic.Int64
19
20func testAccountID() int64 { return 1000 + testIDs.Add(1) }
21
22func openTestStore(t *testing.T) *Store {
23 t.Helper()
24 s, err := Open(filepath.Join(t.TempDir(), "console.db"))
25 if err != nil {
26 t.Fatal(err)
27 }
28 t.Cleanup(func() { s.Close() })
29 return s
30}
31
32func TestOwnersRoundTrip(t *testing.T) {
33 s := openTestStore(t)
34 for _, g := range [][2]string{
35 {"paul@keymail.dev", "kass"},
36 {"paul@keymail.dev", "kass"}, // idempotent
37 {"paul@keymail.dev", "aria"},
38 {"bob@keymail.dev", "kass"},
39 } {
40 if err := s.Grant(g[0], g[1]); err != nil {
41 t.Fatal(err)
42 }
43 }
44 apps, err := s.AppsFor("paul@keymail.dev")
45 if err != nil {
46 t.Fatal(err)
47 }
48 if !reflect.DeepEqual(apps, []string{"aria", "kass"}) {
49 t.Fatalf("AppsFor = %v, want [aria kass]", apps)
50 }
51 owners, err := s.Owners()
52 if err != nil {
53 t.Fatal(err)
54 }
55 want := map[string][]string{
56 "paul@keymail.dev": {"aria", "kass"},
57 "bob@keymail.dev": {"kass"},
58 }
59 if !reflect.DeepEqual(owners, want) {
60 t.Fatalf("Owners = %v, want %v", owners, want)
61 }
62 if err := s.Revoke("paul@keymail.dev", "kass"); err != nil {
63 t.Fatal(err)
64 }
65 apps, _ = s.AppsFor("paul@keymail.dev")
66 if !reflect.DeepEqual(apps, []string{"aria"}) {
67 t.Fatalf("after revoke AppsFor = %v, want [aria]", apps)
68 }
69 if apps, _ := s.AppsFor("nobody@keymail.dev"); len(apps) != 0 {
70 t.Fatalf("AppsFor unknown = %v, want empty", apps)
71 }
72}
73
74func TestSessions(t *testing.T) {
75 s := openTestStore(t)
76 now := time.Now()
77 raw, err := s.CreateSession("paul@keymail.dev", "paul.km-token", now)
78 if err != nil {
79 t.Fatal(err)
80 }
81 if raw == "" {
82 t.Fatal("empty raw session token")
83 }
84 if addr, ok := s.SessionAddress(raw, now.Add(time.Hour)); !ok || addr != "paul@keymail.dev" {
85 t.Fatalf("SessionAddress = %q %v, want paul@keymail.dev true", addr, ok)
86 }
87 if _, ok := s.SessionAddress("not-a-token", now); ok {
88 t.Fatal("bogus token resolved")
89 }
90 if _, ok := s.SessionAddress(raw, now.Add(SessionTTL+time.Minute)); ok {
91 t.Fatal("expired session resolved")
92 }
93 if err := s.DeleteSession(raw); err != nil {
94 t.Fatal(err)
95 }
96 if _, ok := s.SessionAddress(raw, now); ok {
97 t.Fatal("deleted session resolved")
98 }
99}
100
101func TestPurgeExpired(t *testing.T) {
102 s := openTestStore(t)
103 now := time.Now()
104 raw, err := s.CreateSession("paul@keymail.dev", "", now)
105 if err != nil {
106 t.Fatal(err)
107 }
108 conf, err := s.CreateConfirmation("paul@keymail.dev", now)
109 if err != nil {
110 t.Fatal(err)
111 }
112 if err := s.PurgeExpired(now.Add(SessionTTL + time.Hour)); err != nil {
113 t.Fatal(err)
114 }
115 if _, ok := s.SessionAddress(raw, now); ok {
116 t.Fatal("purged session still resolves at an in-window time")
117 }
118 if _, ok := s.TakeConfirmation(conf, now); ok {
119 t.Fatal("purged confirmation still takeable")
120 }
121}
122
123func TestEnsureUser(t *testing.T) {
124 s := openTestStore(t)
125 now := time.Now()
126 created, err := s.EnsureUser("paul@keymail.dev", now)
127 if err != nil || !created {
128 t.Fatalf("first EnsureUser = %v %v, want true nil", created, err)
129 }
130 created, err = s.EnsureUser("paul@keymail.dev", now.Add(time.Hour))
131 if err != nil || created {
132 t.Fatalf("second EnsureUser = %v %v, want false nil", created, err)
133 }
134}
135
136func TestGrantToken(t *testing.T) {
137 s := openTestStore(t)
138 now := time.Now()
139 if tok, err := s.GrantToken("paul@keymail.dev"); err != nil || tok != "" {
140 t.Fatalf("GrantToken unknown = %q %v, want empty nil", tok, err)
141 }
142 if err := s.SetGrantToken("paul@keymail.dev", "paul.tok1", now); err != nil {
143 t.Fatal(err)
144 }
145 if tok, _ := s.GrantToken("paul@keymail.dev"); tok != "paul.tok1" {
146 t.Fatalf("GrantToken = %q, want paul.tok1", tok)
147 }
148 // Re-authorizing rotates the token.
149 if err := s.SetGrantToken("paul@keymail.dev", "paul.tok2", now.Add(time.Hour)); err != nil {
150 t.Fatal(err)
151 }
152 if tok, _ := s.GrantToken("paul@keymail.dev"); tok != "paul.tok2" {
153 t.Fatalf("rotated GrantToken = %q, want paul.tok2", tok)
154 }
155}
156
157func TestConfirmationSingleUse(t *testing.T) {
158 s := openTestStore(t)
159 now := time.Now()
160 raw, err := s.CreateConfirmation("paul@keymail.dev", now)
161 if err != nil {
162 t.Fatal(err)
163 }
164 addr, ok := s.TakeConfirmation(raw, now.Add(time.Minute))
165 if !ok || addr != "paul@keymail.dev" {
166 t.Fatalf("TakeConfirmation = %q %v, want paul@keymail.dev true", addr, ok)
167 }
168 if _, ok := s.TakeConfirmation(raw, now.Add(time.Minute)); ok {
169 t.Fatal("confirmation token worked twice")
170 }
171}
172
173func TestConfirmationExpiresAndBurns(t *testing.T) {
174 s := openTestStore(t)
175 now := time.Now()
176 raw, err := s.CreateConfirmation("paul@keymail.dev", now)
177 if err != nil {
178 t.Fatal(err)
179 }
180 if _, ok := s.TakeConfirmation(raw, now.Add(ConfirmationTTL+time.Minute)); ok {
181 t.Fatal("expired confirmation accepted")
182 }
183 // Burn-then-check: even at a valid time it is now gone.
184 if _, ok := s.TakeConfirmation(raw, now); ok {
185 t.Fatal("expired take did not burn the token")
186 }
187}
188
189func TestSigningKeyStableAcrossReopen(t *testing.T) {
190 path := filepath.Join(t.TempDir(), "console.db")
191 s, err := Open(path)
192 if err != nil {
193 t.Fatal(err)
194 }
195 k1, err := s.SigningKey()
196 if err != nil {
197 t.Fatal(err)
198 }
199 k2, err := s.SigningKey()
200 if err != nil {
201 t.Fatal(err)
202 }
203 if !k1.Equal(k2) {
204 t.Fatal("two SigningKey calls returned different keys")
205 }
206 s.Close()
207 s2, err := Open(path)
208 if err != nil {
209 t.Fatal(err)
210 }
211 defer s2.Close()
212 k3, err := s2.SigningKey()
213 if err != nil {
214 t.Fatal(err)
215 }
216 if !k1.Equal(k3) {
217 t.Fatal("signing key changed across reopen")
218 }
219}
220
221func TestReopenMigrationIdempotent(t *testing.T) {
222 path := filepath.Join(t.TempDir(), "console.db")
223 s, err := Open(path)
224 if err != nil {
225 t.Fatal(err)
226 }
227 if err := s.Grant("paul@keymail.dev", "kass"); err != nil {
228 t.Fatal(err)
229 }
230 s.Close()
231 s2, err := Open(path)
232 if err != nil {
233 t.Fatalf("reopen: %v", err)
234 }
235 defer s2.Close()
236 apps, err := s2.AppsFor("paul@keymail.dev")
237 if err != nil || len(apps) != 1 {
238 t.Fatalf("after reopen AppsFor = %v %v", apps, err)
239 }
240}
241
242func TestAuditEvents(t *testing.T) {
243 s := openTestStore(t)
244 base := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)
245 for i, ev := range []Event{
246 {App: "kass", Address: "paul@keymail.dev", Action: "promote", Detail: "promoted v1 to stable"},
247 {App: "kass", Address: "paul@keymail.dev", Action: "canary-create", Detail: "created canary rc at v2"},
248 {App: "aria", Address: "bob@keymail.dev", Action: "promote", Detail: "promoted z1 to stable"},
249 } {
250 if err := s.RecordEvent(OpsAccountID, ev.App, ev.Address, ev.Action, ev.Detail, base.Add(time.Duration(i)*time.Minute)); err != nil {
251 t.Fatal(err)
252 }
253 }
254 events, err := s.Events(OpsAccountID, "kass", 10)
255 if err != nil {
256 t.Fatal(err)
257 }
258 if len(events) != 2 {
259 t.Fatalf("Events(kass) = %+v, want 2 rows", events)
260 }
261 // Newest first, and no cross-app leakage.
262 if events[0].Action != "canary-create" || events[1].Action != "promote" {
263 t.Fatalf("order = %s, %s — want canary-create first", events[0].Action, events[1].Action)
264 }
265 if events[0].Address != "paul@keymail.dev" || events[0].Detail != "created canary rc at v2" {
266 t.Fatalf("event = %+v", events[0])
267 }
268 if !events[0].CreatedAt.Equal(base.Add(time.Minute)) {
269 t.Fatalf("CreatedAt = %v, want %v", events[0].CreatedAt, base.Add(time.Minute))
270 }
271 // The cap trims oldest-first.
272 if events, _ := s.Events(OpsAccountID, "kass", 1); len(events) != 1 || events[0].Action != "canary-create" {
273 t.Fatalf("Events(kass, 1) = %+v", events)
274 }
275 if events, _ := s.Events(OpsAccountID, "ghost", 10); len(events) != 0 {
276 t.Fatalf("Events(ghost) = %+v, want empty", events)
277 }
278}
279
280func TestStructuredActivityRoundTrip(t *testing.T) {
281 s := openTestStore(t)
282 now := time.Date(2026, 9, 8, 9, 30, 0, 0, time.UTC)
283 want := Activity{
284 Action: "instance-create", TargetType: "instance",
285 Target: "web.example.test", Message: "add the public web copy",
286 Detail: "created instance web.example.test (cli)",
287 }
288 if err := s.RecordActivity(OpsAccountID, "kass", "paul@keymail.dev", want, now); err != nil {
289 t.Fatal(err)
290 }
291 events, err := s.Events(OpsAccountID, "kass", 1)
292 if err != nil || len(events) != 1 {
293 t.Fatalf("Events = %+v, %v", events, err)
294 }
295 got := events[0]
296 if got.Action != want.Action || got.TargetType != want.TargetType || got.Target != want.Target ||
297 got.Message != want.Message || got.Detail != want.Detail {
298 t.Fatalf("event = %+v, want activity %+v", got, want)
299 }
300 if !got.CreatedAt.Equal(now) {
301 t.Fatalf("CreatedAt = %v, want %v", got.CreatedAt, now)
302 }
303}
304
305func TestOperatorRoundTrip(t *testing.T) {
306 s := openTestStore(t)
307 if op, err := s.IsOperator("paul@keymail.dev"); err != nil || op {
308 t.Fatalf("IsOperator before any grant = %v %v, want false nil", op, err)
309 }
310 // Box-side only: SetOperator works even though this address has never
311 // signed in (no users row from EnsureUser), exactly like an app grant.
312 if err := s.SetOperator("paul@keymail.dev", true); err != nil {
313 t.Fatal(err)
314 }
315 if op, err := s.IsOperator("paul@keymail.dev"); err != nil || !op {
316 t.Fatalf("IsOperator after grant = %v %v, want true nil", op, err)
317 }
318 if op, err := s.IsOperator("bob@keymail.dev"); err != nil || op {
319 t.Fatalf("IsOperator(bob) = %v %v, want false nil", op, err)
320 }
321 // A real sign-up (EnsureUser) after the operator bit was set must not
322 // clobber it back to 0.
323 if _, err := s.EnsureUser("paul@keymail.dev", time.Now()); err != nil {
324 t.Fatal(err)
325 }
326 if op, err := s.IsOperator("paul@keymail.dev"); err != nil || !op {
327 t.Fatalf("IsOperator after EnsureUser = %v %v, want true nil (grant survives sign-up)", op, err)
328 }
329 if err := s.SetOperator("paul@keymail.dev", false); err != nil {
330 t.Fatal(err)
331 }
332 if op, err := s.IsOperator("paul@keymail.dev"); err != nil || op {
333 t.Fatalf("IsOperator after revoke = %v %v, want false nil", op, err)
334 }
335}
336
337func TestOperatorMigrationIdempotent(t *testing.T) {
338 path := filepath.Join(t.TempDir(), "console.db")
339 s, err := Open(path)
340 if err != nil {
341 t.Fatal(err)
342 }
343 if err := s.SetOperator("paul@keymail.dev", true); err != nil {
344 t.Fatal(err)
345 }
346 s.Close()
347 // Re-running migrate (every Open) must not fail on the operator column
348 // already existing, and the bit must survive the reopen.
349 s2, err := Open(path)
350 if err != nil {
351 t.Fatalf("reopen: %v", err)
352 }
353 defer s2.Close()
354 if op, err := s2.IsOperator("paul@keymail.dev"); err != nil || !op {
355 t.Fatalf("IsOperator after reopen = %v %v, want true nil", op, err)
356 }
357}
358
359// TestOperatorsList: the alert watcher's recipient list is exactly the
360// addresses holding the operator bit, sorted, and revoking removes you.
361func TestOperatorsList(t *testing.T) {
362 s := openTestStore(t)
363 if ops, err := s.Operators(); err != nil || len(ops) != 0 {
364 t.Fatalf("Operators on an empty store = %v %v, want none", ops, err)
365 }
366 for _, a := range []string{"zoe@keymail.dev", "amy@keymail.dev"} {
367 if err := s.SetOperator(a, true); err != nil {
368 t.Fatal(err)
369 }
370 }
371 // A plain user (never an operator) is not on the list.
372 if _, err := s.EnsureUser("bob@keymail.dev", time.Now()); err != nil {
373 t.Fatal(err)
374 }
375 ops, err := s.Operators()
376 if err != nil {
377 t.Fatal(err)
378 }
379 if !reflect.DeepEqual(ops, []string{"amy@keymail.dev", "zoe@keymail.dev"}) {
380 t.Fatalf("Operators = %v", ops)
381 }
382 if err := s.SetOperator("zoe@keymail.dev", false); err != nil {
383 t.Fatal(err)
384 }
385 if ops, _ := s.Operators(); !reflect.DeepEqual(ops, []string{"amy@keymail.dev"}) {
386 t.Fatalf("Operators after revoke = %v", ops)
387 }
388}
389
390// TestAlertSeenRoundTrip: the watcher's memory, one row per recipient.
391// "Never told them" and "told them it was quiet" are different answers —
392// the first is news on a fire, the second is what makes the second pass
393// quiet — and one operator's row must never answer for another's.
394func TestAlertSeenRoundTrip(t *testing.T) {
395 s := openTestStore(t)
396 const amy, zoe = "amy@keymail.dev", "zoe@keymail.dev"
397 if firing, known, err := s.AlertSeen("box-a", "disk", amy); err != nil || known || firing {
398 t.Fatalf("AlertSeen before anything = %v %v %v, want false false nil", firing, known, err)
399 }
400 if err := s.SetAlertSeen("box-a", "disk", amy, true); err != nil {
401 t.Fatal(err)
402 }
403 if firing, known, err := s.AlertSeen("box-a", "disk", amy); err != nil || !known || !firing {
404 t.Fatalf("AlertSeen after fire = %v %v %v, want true true nil", firing, known, err)
405 }
406 // Same (box, rule, address) upserts rather than duplicating.
407 if err := s.SetAlertSeen("box-a", "disk", amy, false); err != nil {
408 t.Fatal(err)
409 }
410 if firing, known, err := s.AlertSeen("box-a", "disk", amy); err != nil || !known || firing {
411 t.Fatalf("AlertSeen after clear = %v %v %v, want false true nil", firing, known, err)
412 }
413 // Rules, boxes and addresses are all independent parts of the key.
414 if _, known, _ := s.AlertSeen("box-a", "mem", amy); known {
415 t.Fatal("AlertSeen(box-a, mem) is known — the rule key is not being used")
416 }
417 if _, known, _ := s.AlertSeen("box-b", "disk", amy); known {
418 t.Fatal("AlertSeen(box-b, disk) is known — the box key is not being used")
419 }
420 if _, known, _ := s.AlertSeen("box-a", "disk", zoe); known {
421 t.Fatal("AlertSeen(box-a, disk, zoe) is known — amy's row is answering for zoe")
422 }
423 // The synthetic "nobody was mailable" recipient is its own row.
424 if err := s.SetAlertSeen("box-a", "disk", "", true); err != nil {
425 t.Fatal(err)
426 }
427 if firing, known, err := s.AlertSeen("box-a", "disk", ""); err != nil || !known || !firing {
428 t.Fatalf("AlertSeen for the empty address = %v %v %v, want true true nil", firing, known, err)
429 }
430 if firing, _, _ := s.AlertSeen("box-a", "disk", amy); firing {
431 t.Fatal("the synthetic row overwrote amy's")
432 }
433}
434
435// TestTrashAppRowRoundTrip: a trashed app vanishes from the live listings but
436// stays visible — and its name stays claimed — in the trashed ones, until
437// restore or purge.
438func TestTrashAppRowRoundTrip(t *testing.T) {
439 s := openTestStore(t)
440 now := time.Now()
441 acct := testAccountID()
442 if created, err := s.CreateAppIn(acct, "zephyr", "paul@keymail.dev", now); err != nil || !created {
443 t.Fatalf("CreateAppIn = %v %v, want true nil", created, err)
444 }
445 if err := s.TrashAppRow(acct, "zephyr", now); err != nil {
446 t.Fatal(err)
447 }
448 // Gone from the live doors.
449 if apps, err := s.AppsIn(acct); err != nil || len(apps) != 0 {
450 t.Fatalf("AppsIn after trash = %v %v, want empty", apps, err)
451 }
452 if has, err := s.HasAppIn(acct, "zephyr"); err != nil || has {
453 t.Fatalf("HasAppIn after trash = %v %v, want false", has, err)
454 }
455 // Still visible via the trash doors.
456 if has, err := s.HasTrashedAppIn(acct, "zephyr"); err != nil || !has {
457 t.Fatalf("HasTrashedAppIn after trash = %v %v, want true", has, err)
458 }
459 trashed, err := s.TrashedAppsIn(acct)
460 if err != nil || len(trashed) != 1 || trashed[0].App != "zephyr" {
461 t.Fatalf("TrashedAppsIn = %+v %v, want one zephyr row", trashed, err)
462 }
463 if !trashed[0].DeletedAt.Equal(time.Unix(now.Unix(), 0).UTC()) {
464 t.Fatalf("TrashedAppsIn DeletedAt = %v, want %v", trashed[0].DeletedAt, now)
465 }
466 // The name is still claimed — claim-once still refuses it. The PK row
467 // exists; this is not a fresh claim succeeding by accident.
468 if created, err := s.CreateAppIn(acct, "zephyr", "bob@keymail.dev", now); err != nil || created {
469 t.Fatalf("CreateAppIn on a trashed name = %v %v, want false nil", created, err)
470 }
471 // Trashing again does NOT move the cooloff clock.
472 later := now.Add(time.Hour)
473 if err := s.TrashAppRow(acct, "zephyr", later); err != nil {
474 t.Fatal(err)
475 }
476 trashed, err = s.TrashedAppsIn(acct)
477 if err != nil || len(trashed) != 1 || !trashed[0].DeletedAt.Equal(time.Unix(now.Unix(), 0).UTC()) {
478 t.Fatalf("second TrashAppRow moved the timestamp: %+v %v", trashed, err)
479 }
480 // Restore: visible again everywhere live, gone from trash.
481 if err := s.RestoreAppRow(acct, "zephyr"); err != nil {
482 t.Fatal(err)
483 }
484 if apps, err := s.AppsIn(acct); err != nil || len(apps) != 1 || apps[0] != "zephyr" {
485 t.Fatalf("AppsIn after restore = %v %v, want [zephyr]", apps, err)
486 }
487 if has, err := s.HasTrashedAppIn(acct, "zephyr"); err != nil || has {
488 t.Fatalf("HasTrashedAppIn after restore = %v %v, want false", has, err)
489 }
490 // Purge: the row is gone outright, and the name frees for reclaiming.
491 if err := s.TrashAppRow(acct, "zephyr", now); err != nil {
492 t.Fatal(err)
493 }
494 if err := s.PurgeAppRow(acct, "zephyr"); err != nil {
495 t.Fatal(err)
496 }
497 if has, err := s.HasTrashedAppIn(acct, "zephyr"); err != nil || has {
498 t.Fatalf("HasTrashedAppIn after purge = %v %v, want false", has, err)
499 }
500 if created, err := s.CreateAppIn(acct, "zephyr", "carol@keymail.dev", now); err != nil || !created {
501 t.Fatalf("CreateAppIn after purge = %v %v, want true nil — the name should be free", created, err)
502 }
503}
504
505// TestAppPlacementRoundTrip: a fresh app reads unplaced, SetAppPlacement
506// stamps a target AppPlacement reads back, and clearing (target "") returns
507// it to unplaced — the store-of-record fleets.go's appPlacement now
508// consults first (2026-08-06 ruling), ahead of the older route-derived
509// fallback.
510func TestAppPlacementRoundTrip(t *testing.T) {
511 s := openTestStore(t)
512 now := time.Now()
513 acct := testAccountID()
514 if _, err := s.CreateAppIn(acct, "titogo", "paul@keymail.dev", now); err != nil {
515 t.Fatal(err)
516 }
517 if p, err := s.AppPlacement(acct, "titogo"); err != nil || p != "" {
518 t.Fatalf("AppPlacement of a fresh app = %q %v, want \"\" nil", p, err)
519 }
520 // An app this store has no row for at all also reads as unplaced, not an
521 // error.
522 if p, err := s.AppPlacement(acct, "no-such-app"); err != nil || p != "" {
523 t.Fatalf("AppPlacement of an unknown app = %q %v, want \"\" nil", p, err)
524 }
525 if err := s.SetAppPlacement(acct, "titogo", "fleet/pi"); err != nil {
526 t.Fatal(err)
527 }
528 if p, err := s.AppPlacement(acct, "titogo"); err != nil || p != "fleet/pi" {
529 t.Fatalf("AppPlacement after set = %q %v, want fleet/pi", p, err)
530 }
531 if err := s.SetAppPlacement(acct, "titogo", ""); err != nil {
532 t.Fatal(err)
533 }
534 if p, err := s.AppPlacement(acct, "titogo"); err != nil || p != "" {
535 t.Fatalf("AppPlacement after clear = %q %v, want \"\"", p, err)
536 }
537}
538
539// TestAppCountCountsTrashedRows: account deletion's pre-flight must keep
540// refusing while a trashed app's payload still exists, so the count is
541// deliberately unfiltered by deleted_at.
542// TestPurgeAppRowRefusesLiveRow: purge is the one irreversible verb here, so a
543// live row must be structurally unpurgeable even if a caller gets confused —
544// the console row and a bucket marker can diverge (a restore that raced or
545// out-lived a failed marker delete elsewhere), and that must never be the
546// thing that frees a serving app's name.
547func TestPurgeAppRowRefusesLiveRow(t *testing.T) {
548 s := openTestStore(t)
549 now := time.Now()
550 acct := testAccountID()
551 if _, err := s.CreateAppIn(acct, "kass", "paul@keymail.dev", now); err != nil {
552 t.Fatal(err)
553 }
554 if err := s.TrashAppRow(acct, "kass", now); err != nil {
555 t.Fatal(err)
556 }
557 if err := s.RestoreAppRow(acct, "kass"); err != nil {
558 t.Fatal(err)
559 }
560 // Live again: PurgeAppRow must refuse, not free the name.
561 if err := s.PurgeAppRow(acct, "kass"); err != nil {
562 t.Fatal(err)
563 }
564 if has, err := s.HasAppIn(acct, "kass"); err != nil || !has {
565 t.Fatalf("HasAppIn after purging a restored (live) row = %v %v, want true — the row must survive", has, err)
566 }
567 if created, err := s.CreateAppIn(acct, "kass", "bob@keymail.dev", now); err != nil || created {
568 t.Fatalf("CreateAppIn after purging a live row = %v %v, want false — the name is still reserved", created, err)
569 }
570 // Trashed: PurgeAppRow now takes effect.
571 if err := s.TrashAppRow(acct, "kass", now); err != nil {
572 t.Fatal(err)
573 }
574 if err := s.PurgeAppRow(acct, "kass"); err != nil {
575 t.Fatal(err)
576 }
577 if has, err := s.HasTrashedAppIn(acct, "kass"); err != nil || has {
578 t.Fatalf("HasTrashedAppIn after purging a trashed row = %v %v, want false — the row should be gone", has, err)
579 }
580 if created, err := s.CreateAppIn(acct, "kass", "bob@keymail.dev", now); err != nil || !created {
581 t.Fatalf("CreateAppIn after purge = %v %v, want true — the name should be free", created, err)
582 }
583}
584
585func TestAppCountCountsTrashedRows(t *testing.T) {
586 s := openTestStore(t)
587 now := time.Now()
588 acct := testAccountID()
589 if _, err := s.CreateAppIn(acct, "kass", "paul@keymail.dev", now); err != nil {
590 t.Fatal(err)
591 }
592 if err := s.TrashAppRow(acct, "kass", now); err != nil {
593 t.Fatal(err)
594 }
595 if n, err := s.AppCount(acct); err != nil || n != 1 {
596 t.Fatalf("AppCount with only a trashed row = %d %v, want 1", n, err)
597 }
598}
599
600// TestDeleteAccountMarksNotRemoves: the console must remember tombstones —
601// deleting the row would forget the account was ever seen and re-arm the
602// resurrection attack the spec's §8 describes (see DeleteAccount's comment).
603func TestDeleteAccountMarksNotRemoves(t *testing.T) {
604 s := openTestStore(t)
605 now := time.Now()
606 id := testAccountID()
607 sqid := "test-del-1"
608 if err := s.CreateAccount(id, sqid, "Testco", "paul@keymail.dev", now); err != nil {
609 t.Fatal(err)
610 }
611 if err := s.DeleteAccount(id, now); err != nil {
612 t.Fatal(err)
613 }
614 // Invisible via every filtered door.
615 if a, err := s.AccountBySqid(sqid); err != nil || a != nil {
616 t.Fatalf("AccountBySqid after delete = %+v %v, want nil nil", a, err)
617 }
618 if a, err := s.AccountByID(id); err != nil || a != nil {
619 t.Fatalf("AccountByID after delete = %+v %v, want nil nil", a, err)
620 }
621 // But the console still remembers it: the row is marked, not gone.
622 a, deleted, err := s.AccountAnyBySqid(sqid)
623 if err != nil {
624 t.Fatal(err)
625 }
626 if a == nil {
627 t.Fatal("AccountAnyBySqid after delete = nil, want the marked row")
628 }
629 if !deleted {
630 t.Fatal("AccountAnyBySqid after delete reports deleted=false")
631 }
632 if a.ID != id || a.Sqid != sqid || a.Name != "Testco" {
633 t.Fatalf("AccountAnyBySqid = %+v, want id=%d sqid=%s name=Testco", a, id, sqid)
634 }
635}
636
637func TestDeleteAccountRefusesOps(t *testing.T) {
638 s := openTestStore(t)
639 if err := s.DeleteAccount(OpsAccountID, time.Now()); err == nil {
640 t.Fatal("DeleteAccount(ops) succeeded, want a refusal")
641 }
642}
643
644// TestReapAccountClearsAndMarks: materialization's verb for a manifest
645// tombstoned on a LATER pass — memberships, invites and apps rows are
646// deleted outright, last_account references are cleared, and the accounts
647// row is marked, never removed, for the same tombstone-memory reason
648// DeleteAccount marks it.
649func TestReapAccountClearsAndMarks(t *testing.T) {
650 s := openTestStore(t)
651 now := time.Now()
652 id := testAccountID()
653 sqid := "test-reap-1"
654 if err := s.CreateAccount(id, sqid, "Reapco", "paul@keymail.dev", now); err != nil {
655 t.Fatal(err)
656 }
657 if err := s.AddMember(id, "bob@keymail.dev", RoleMember, now); err != nil {
658 t.Fatal(err)
659 }
660 if _, err := s.CreateAppIn(id, "leftover", "paul@keymail.dev", now); err != nil {
661 t.Fatal(err)
662 }
663 if _, err := s.CreateInvite(id, "carol@keymail.dev", "paul@keymail.dev", now); err != nil {
664 t.Fatal(err)
665 }
666 if err := s.SetLastAccount("bob@keymail.dev", id); err != nil {
667 t.Fatal(err)
668 }
669
670 if err := s.ReapAccount(id, now); err != nil {
671 t.Fatal(err)
672 }
673
674 if role, err := s.Role(id, "paul@keymail.dev"); err != nil || role != "" {
675 t.Fatalf("Role(owner) after reap = %q %v, want empty", role, err)
676 }
677 if role, err := s.Role(id, "bob@keymail.dev"); err != nil || role != "" {
678 t.Fatalf("Role(member) after reap = %q %v, want empty", role, err)
679 }
680 if apps, err := s.AppsIn(id); err != nil || len(apps) != 0 {
681 t.Fatalf("AppsIn after reap = %v %v, want empty", apps, err)
682 }
683 if trashed, err := s.TrashedAppsIn(id); err != nil || len(trashed) != 0 {
684 t.Fatalf("TrashedAppsIn after reap = %v %v, want empty — reap deletes app rows, it does not trash them", trashed, err)
685 }
686 if invites, err := s.PendingInvites(id); err != nil || len(invites) != 0 {
687 t.Fatalf("PendingInvites after reap = %v %v, want empty", invites, err)
688 }
689 if last, err := s.LastAccount("bob@keymail.dev"); err != nil || last != 0 {
690 t.Fatalf("LastAccount after reap = %d %v, want 0", last, err)
691 }
692 if a, err := s.AccountBySqid(sqid); err != nil || a != nil {
693 t.Fatalf("AccountBySqid after reap = %+v %v, want nil nil", a, err)
694 }
695 a, deleted, err := s.AccountAnyBySqid(sqid)
696 if err != nil || a == nil || !deleted {
697 t.Fatalf("AccountAnyBySqid after reap = %+v %v %v, want a marked row", a, deleted, err)
698 }
699}
700
701func TestReapAccountRefusesOps(t *testing.T) {
702 s := openTestStore(t)
703 if err := s.ReapAccount(OpsAccountID, time.Now()); err == nil {
704 t.Fatal("ReapAccount(ops) succeeded, want a refusal")
705 }
706}
707
708func TestCreateAppClaimOnce(t *testing.T) {
709 s := openTestStore(t)
710 now := time.Now()
711 created, err := s.CreateApp("zephyr", "paul@keymail.dev", now)
712 if err != nil || !created {
713 t.Fatalf("first CreateApp = %v %v, want true nil", created, err)
714 }
715 created, err = s.CreateApp("zephyr", "bob@keymail.dev", now)
716 if err != nil || created {
717 t.Fatalf("second CreateApp = %v %v, want false nil", created, err)
718 }
719 if has, _ := s.HasApp("zephyr"); !has {
720 t.Fatal("HasApp(zephyr) = false after claim")
721 }
722 if has, _ := s.HasApp("ghost"); has {
723 t.Fatal("HasApp(ghost) = true")
724 }
725 // HasOwners sees CLI-style grants independently of the apps table.
726 if has, _ := s.HasOwners("kass"); has {
727 t.Fatal("HasOwners before any grant")
728 }
729 if err := s.Grant("paul@keymail.dev", "kass"); err != nil {
730 t.Fatal(err)
731 }
732 if has, _ := s.HasOwners("kass"); !has {
733 t.Fatal("HasOwners(kass) = false after grant")
734 }
735}
736
737// AppsWithActionSince answers each (account, app) with a row of the action
738// at or after since, once, by the account's sqid. Older rows and other
739// actions are left out.
740func TestAppsWithActionSince(t *testing.T) {
741 t.Parallel()
742 s := openTestStore(t)
743 now := time.Now()
744 one, other := testAccountID(), testAccountID()
745 for id, sqid := range map[int64]string{one: "acct-one", other: "acct-other"} {
746 if err := s.CreateAccount(id, sqid, sqid, "bob@example.com", now); err != nil {
747 t.Fatal(err)
748 }
749 }
750 for _, r := range []struct {
751 acct int64
752 app string
753 action string
754 at time.Time
755 }{
756 {one, "shop", "run-start", now.Add(-time.Hour)},
757 {one, "shop", "run-start", now.Add(-2 * time.Hour)}, // the same pair again
758 {one, "cafe", "run-start", now.Add(-49 * time.Hour)},
759 {one, "blog", "run-end", now.Add(-time.Hour)},
760 {one, "edge", "run-start", now.Add(-48 * time.Hour)}, // exactly since: in
761 {other, "shop", "run-start", now.Add(-time.Minute)},
762 } {
763 if err := s.RecordActivity(r.acct, r.app, "bob@example.com", Activity{Action: r.action}, r.at); err != nil {
764 t.Fatal(err)
765 }
766 }
767 got, err := s.AppsWithActionSince("run-start", now.Add(-48*time.Hour))
768 if err != nil {
769 t.Fatal(err)
770 }
771 want := []AppRef{{Sqid: "acct-one", App: "edge"}, {Sqid: "acct-one", App: "shop"}, {Sqid: "acct-other", App: "shop"}}
772 sort.Slice(got, func(i, j int) bool { return got[i].Sqid+"/"+got[i].App < got[j].Sqid+"/"+got[j].App })
773 sort.Slice(want, func(i, j int) bool { return want[i].Sqid+"/"+want[i].App < want[j].Sqid+"/"+want[j].App })
774 if !reflect.DeepEqual(got, want) {
775 t.Fatalf("AppsWithActionSince = %+v, want %+v", got, want)
776 }
777}
778