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 "context"
5 "encoding/json"
6 "strings"
7 "testing"
8 "time"
9
10 "github.com/carlosframework/platform/internal/domains"
11 "github.com/carlosframework/platform/internal/release"
12)
13
14// The manifest is where account identity LEAVES the console: the registry on
15// every box materializes exactly what this writes, and never assigns anything
16// of its own.
17func TestWriteAccountManifestRoundTrips(t *testing.T) {
18 s := release.NewDirStore(t.TempDir())
19 ctx := context.Background()
20 if err := WriteAccountManifest(ctx, s, 42, "cdef", "Kass", time.Unix(1700000000, 0)); err != nil {
21 t.Fatal(err)
22 }
23 body, _, err := s.Get(ctx, "accounts/cdef/account.json")
24 if err != nil {
25 t.Fatal(err)
26 }
27 var m struct {
28 ID int64 `json:"id"`
29 Sqid string `json:"sqid"`
30 Name string `json:"name"`
31 CreatedAt string `json:"created_at"`
32 }
33 if err := json.Unmarshal(body, &m); err != nil {
34 t.Fatal(err)
35 }
36 if m.ID != 42 || m.Sqid != "cdef" || m.Name != "Kass" || m.CreatedAt == "" {
37 t.Errorf("manifest = %+v", m)
38 }
39 // Overwrite-safe: a rename must land, not lose to the CAS gate.
40 if err := WriteAccountManifest(ctx, s, 42, "cdef", "Kassidy", time.Unix(1700000000, 0)); err != nil {
41 t.Fatalf("rewrite: %v", err)
42 }
43 body, _, _ = s.Get(ctx, "accounts/cdef/account.json")
44 json.Unmarshal(body, &m)
45 if m.Name != "Kassidy" {
46 t.Errorf("rewrite lost the rename: %+v", m)
47 }
48}
49
50// The sqid a minted account carries must be the frozen encoder over its id —
51// nothing else, ever, or a platform domain stops resolving to its account.
52func TestMintedSqidIsTheFrozenEncoding(t *testing.T) {
53 for id := int64(2); id < 60; id++ {
54 if got := domains.Sqid(id); got != domains.Sqid(id) || got == "" {
55 t.Fatalf("Sqid(%d) unstable/empty: %q", id, got)
56 }
57 }
58 if got := domains.Sqid(1); got != release.OpsAccount {
59 t.Fatalf("ops sqid = %q, want %q — the frozen encoding moved", got, release.OpsAccount)
60 }
61}
62
63// Account ids are SEQUENTIAL, and allocated in the BUCKET
64// (domains.MintAccount) — not by the registry, not by a console row number.
65// Identity survives a registry rebuild for the reason it always did: minted
66// once, recorded in the manifest, materialized-never-assigned. What sequential
67// buys is a sqid a human can use: a 40-bit random id encoded to NINE
68// characters (kass.nyfbskdye.oncarlos.com), which nobody can read out over a
69// phone or type without a typo. It gives up nothing in secrecy, because sqids
70// were never secret — every platform hostname's certificate is published in
71// Certificate Transparency within seconds of issue.
72func TestConsoleMintsSequentialIdsIntoTheBucket(t *testing.T) {
73 rel := release.NewDirStore(t.TempDir())
74 ctx := t.Context()
75 var last int64
76 for i := 0; i < 3; i++ {
77 id, sqid, err := domains.MintAccount(ctx, rel, "Account", "", time.Now())
78 if err != nil {
79 t.Fatal(err)
80 }
81 if id <= domains.ReservedAccountID {
82 t.Fatalf("minted id %d collides with ops/zero", id)
83 }
84 if last != 0 && id != last+1 {
85 t.Errorf("minted %d after %d, want the next integer", id, last)
86 }
87 if len(sqid) > 3 {
88 t.Errorf("sqid %q is %d characters — the whole point is that it is short", sqid, len(sqid))
89 }
90 last = id
91 }
92}
93
94func TestCreateAccountMakesOwner(t *testing.T) {
95 st := openTestStore(t)
96 if err := st.CreateAccount(42, "cdef", "Kass", "paul@keymail.dev", time.Now()); err != nil {
97 t.Fatal(err)
98 }
99 role, _ := st.Role(42, "paul@keymail.dev")
100 if role != "owner" {
101 t.Errorf("creator role = %q, want owner", role)
102 }
103 if role, _ := st.Role(42, "someone@keymail.dev"); role != "" {
104 t.Errorf("non-member role = %q, want empty", role)
105 }
106 accts, err := st.AccountsFor("paul@keymail.dev")
107 if err != nil {
108 t.Fatal(err)
109 }
110 if len(accts) != 1 || accts[0].Sqid != "cdef" || accts[0].Role != "owner" || accts[0].Name != "Kass" {
111 t.Errorf("AccountsFor = %+v", accts)
112 }
113 if accts, _ := st.AccountsFor("someone@keymail.dev"); len(accts) != 0 {
114 t.Errorf("a non-member sees accounts: %+v", accts)
115 }
116}
117
118// Every account has at least one owner, always: the last one cannot be
119// removed (transfer first). Otherwise an account becomes unadministrable —
120// nobody left who can invite, rename, or delete it.
121func TestLastOwnerCannotBeRemoved(t *testing.T) {
122 st := openTestStore(t)
123 if err := st.CreateAccount(42, "cdef", "Kass", "paul@keymail.dev", time.Now()); err != nil {
124 t.Fatal(err)
125 }
126 if err := st.RemoveMember(42, "paul@keymail.dev"); err == nil {
127 t.Fatal("removing the last owner must fail")
128 }
129 // A member is not a second owner: demoting the only owner is the same
130 // hole by another name.
131 if err := st.AddMember(42, "kim@keymail.dev", "member", time.Now()); err != nil {
132 t.Fatal(err)
133 }
134 if err := st.RemoveMember(42, "paul@keymail.dev"); err == nil {
135 t.Fatal("a member does not make the owner removable")
136 }
137 if err := st.AddMember(42, "sam@keymail.dev", "owner", time.Now()); err != nil {
138 t.Fatal(err)
139 }
140 if err := st.RemoveMember(42, "paul@keymail.dev"); err != nil {
141 t.Fatalf("removable once a second owner exists: %v", err)
142 }
143}
144
145func TestLastOwnerCannotBeDemoted(t *testing.T) {
146 st := openTestStore(t)
147 if err := st.CreateAccount(42, "cdef", "Kass", "paul@keymail.dev", time.Now()); err != nil {
148 t.Fatal(err)
149 }
150 if err := st.AddMember(42, "paul@keymail.dev", "member", time.Now()); err == nil {
151 t.Fatal("demoting the last owner must fail")
152 }
153 if role, _ := st.Role(42, "paul@keymail.dev"); role != "owner" {
154 t.Errorf("refused demotion still changed the role: %q", role)
155 }
156}
157
158func TestInviteSingleUseAndExpiring(t *testing.T) {
159 st := openTestStore(t)
160 now := time.Now()
161 if err := st.CreateAccount(42, "cdef", "Kass", "paul@keymail.dev", now); err != nil {
162 t.Fatal(err)
163 }
164 tok, err := st.CreateInvite(42, "sam@keymail.dev", "paul@keymail.dev", now)
165 if err != nil {
166 t.Fatal(err)
167 }
168 if _, err := st.AcceptInvite(tok, "wrong@keymail.dev", now); err == nil {
169 t.Error("an invite must only be acceptable by its addressee")
170 }
171 if _, err := st.AcceptInvite(tok, "sam@keymail.dev", now); err != nil {
172 t.Fatal(err)
173 }
174 if role, _ := st.Role(42, "sam@keymail.dev"); role != "member" {
175 t.Errorf("accepted invite role = %q, want member", role)
176 }
177 if _, err := st.AcceptInvite(tok, "sam@keymail.dev", now); err == nil {
178 t.Error("invite must be single-use")
179 }
180 tok2, _ := st.CreateInvite(42, "kim@keymail.dev", "paul@keymail.dev", now)
181 if _, err := st.AcceptInvite(tok2, "kim@keymail.dev", now.Add(8*24*time.Hour)); err == nil {
182 t.Error("invite must expire after 7 days")
183 }
184}
185
186func TestInvitesListedAndRevocable(t *testing.T) {
187 st := openTestStore(t)
188 now := time.Now()
189 if err := st.CreateAccount(42, "cdef", "Kass", "paul@keymail.dev", now); err != nil {
190 t.Fatal(err)
191 }
192 tok, _ := st.CreateInvite(42, "sam@keymail.dev", "paul@keymail.dev", now)
193 pending, err := st.PendingInvites(42)
194 if err != nil || len(pending) != 1 || pending[0].Address != "sam@keymail.dev" {
195 t.Fatalf("PendingInvites = %+v %v", pending, err)
196 }
197 if err := st.RevokeInvite(tok); err != nil {
198 t.Fatal(err)
199 }
200 if pending, _ := st.PendingInvites(42); len(pending) != 0 {
201 t.Errorf("revoked invite still pending: %+v", pending)
202 }
203 if _, err := st.AcceptInvite(tok, "sam@keymail.dev", now); err == nil {
204 t.Error("a revoked invite must not be acceptable")
205 }
206}
207
208func TestLastAccountRoundTrips(t *testing.T) {
209 st := openTestStore(t)
210 if err := st.CreateAccount(42, "cdef", "Kass", "paul@keymail.dev", time.Now()); err != nil {
211 t.Fatal(err)
212 }
213 // No history yet is 0, not an error.
214 if got, err := st.LastAccount("paul@keymail.dev"); err != nil || got != 0 {
215 t.Fatalf("LastAccount before any visit = %d, %v; want 0, nil", got, err)
216 }
217 if err := st.SetLastAccount("paul@keymail.dev", 42); err != nil {
218 t.Fatal(err)
219 }
220 if got, _ := st.LastAccount("paul@keymail.dev"); got != 42 {
221 t.Errorf("LastAccount = %d, want 42", got)
222 }
223 // It works for an address that has never signed in (the invite case).
224 if err := st.SetLastAccount("newcomer@keymail.dev", 42); err != nil {
225 t.Fatal(err)
226 }
227 if got, _ := st.LastAccount("newcomer@keymail.dev"); got != 42 {
228 t.Errorf("LastAccount(newcomer) = %d, want 42", got)
229 }
230}
231
232// A per-app grant was never deployment-wide authority, and membership IS
233// authorization: "member of the account AND the account claims the app" means
234// one membership of ops is access to console, platform-site, status and every
235// other app that lives there. Sweeping grant holders into ops did exactly that
236// on the flagship — someone holding ONE grant for a toy app could promote and
237// roll back kass, keymail and the console. Non-operators must stay OUT, and be
238// reported so they get accounts of their own.
239func TestBackfillLeavesGrantHoldersOutOfOpsAndReportsThem(t *testing.T) {
240 st := openTestStore(t)
241 for _, g := range [][2]string{
242 {"dev@keymail.dev", "toy"},
243 {"dev@keymail.dev", "another-toy"},
244 {"ops@keymail.dev", "console"},
245 } {
246 if err := st.Grant(g[0], g[1]); err != nil {
247 t.Fatal(err)
248 }
249 }
250 if err := st.SetOperator("ops@keymail.dev", true); err != nil {
251 t.Fatal(err)
252 }
253
254 needs, err := st.BackfillOpsMemberships(time.Now())
255 if err != nil {
256 t.Fatal(err)
257 }
258 if role, _ := st.Role(OpsAccountID, "dev@keymail.dev"); role != "" {
259 t.Errorf("app-grant holder got role %q in ops — that is the widening this fixes", role)
260 }
261 if role, _ := st.Role(OpsAccountID, "ops@keymail.dev"); role != RoleOwner {
262 t.Errorf("operator role = %q, want owner", role)
263 }
264 // Left out is not the same as forgotten: the operator has to be told who
265 // needs an account, with their apps, or the migration silently loses them.
266 if len(needs) != 1 || needs[0].Address != "dev@keymail.dev" {
267 t.Fatalf("report = %+v, want just dev@keymail.dev", needs)
268 }
269 if got := strings.Join(needs[0].Apps, ","); got != "another-toy,toy" {
270 t.Errorf("reported apps = %q, want both, sorted", got)
271 }
272 // The app names are still claimed in ops, which is where every
273 // pre-accounts app lived — migrate-app is what moves them out.
274 apps, _ := st.AppsIn(OpsAccountID)
275 if len(apps) != 3 {
276 t.Errorf("AppsIn(ops) = %v, want every granted app claimed", apps)
277 }
278
279 // Idempotent: a re-run changes nothing and reports the same people, so a
280 // deployment that has already run this (with the OLD semantics, cleaned up
281 // by hand) neither loses the cleanup nor re-applies the widening.
282 again, err := st.BackfillOpsMemberships(time.Now())
283 if err != nil {
284 t.Fatal(err)
285 }
286 if len(again) != 1 || again[0].Address != "dev@keymail.dev" {
287 t.Errorf("second report = %+v, want the same", again)
288 }
289 if role, _ := st.Role(OpsAccountID, "dev@keymail.dev"); role != "" {
290 t.Errorf("second run put the grant holder into ops as %q", role)
291 }
292 if role, _ := st.Role(OpsAccountID, "ops@keymail.dev"); role != RoleOwner {
293 t.Errorf("second run changed the operator to %q", role)
294 }
295}
296
297// The flagship shape: the backfill has already run once with the old
298// semantics, and the widened membership was removed by hand. Re-running must
299// not put it back, and must not touch a membership an operator has since
300// created deliberately with `console member`.
301func TestBackfillNeverResurrectsARemovedMembership(t *testing.T) {
302 st := openTestStore(t)
303 now := time.Now()
304 if err := st.Grant("dev@keymail.dev", "toy"); err != nil {
305 t.Fatal(err)
306 }
307 if err := st.Grant("paul@keymail.dev", "kass"); err != nil {
308 t.Fatal(err)
309 }
310 if err := st.SetOperator("paul@keymail.dev", true); err != nil {
311 t.Fatal(err)
312 }
313 if _, err := st.BackfillOpsMemberships(now); err != nil {
314 t.Fatal(err)
315 }
316 // An operator deliberately admits somebody as a plain member.
317 if err := st.AddMember(OpsAccountID, "trusted@keymail.dev", RoleMember, now); err != nil {
318 t.Fatal(err)
319 }
320
321 needs, err := st.BackfillOpsMemberships(now)
322 if err != nil {
323 t.Fatal(err)
324 }
325 if role, _ := st.Role(OpsAccountID, "dev@keymail.dev"); role != "" {
326 t.Errorf("the removed membership came back as %q", role)
327 }
328 if role, _ := st.Role(OpsAccountID, "trusted@keymail.dev"); role != RoleMember {
329 t.Errorf("deliberate membership changed to %q, want member", role)
330 }
331 // Somebody an operator has already dealt with is not reported again.
332 for _, h := range needs {
333 if h.Address == "trusted@keymail.dev" || h.Address == "paul@keymail.dev" {
334 t.Errorf("reported %s, who is already a member of ops", h.Address)
335 }
336 }
337}
338
339// With grants but NO operator, nobody is promoted. The old code promoted the
340// earliest grant holder to OWNER of ops so the account would have someone who
341// could administer it — but ops holds the console itself, and that promotion
342// is the same widening by a quieter route. An ownerless ops is recoverable
343// box-side (`console member -add <addr> bab -role owner`), which is precisely
344// the tool that did not exist when that fallback was written.
345func TestBackfillPromotesNobodyWhenThereIsNoOperator(t *testing.T) {
346 st := openTestStore(t)
347 base := time.Unix(1700000000, 0)
348 if err := st.GrantAt("second@keymail.dev", "b", base.Add(time.Hour)); err != nil {
349 t.Fatal(err)
350 }
351 if err := st.GrantAt("first@keymail.dev", "a", base); err != nil {
352 t.Fatal(err)
353 }
354 needs, err := st.BackfillOpsMemberships(time.Now())
355 if err != nil {
356 t.Fatal(err)
357 }
358 members, err := st.Members(OpsAccountID)
359 if err != nil {
360 t.Fatal(err)
361 }
362 if len(members) != 0 {
363 t.Errorf("Members(ops) = %+v, want nobody — a grant is not authority over ops", members)
364 }
365 if len(needs) != 2 {
366 t.Errorf("report = %+v, want both grant holders", needs)
367 }
368}
369
370// AdoptManifestOwner is how a bucket manifest's `owner` becomes a membership.
371// It only ever fills a VACANCY: the manifest names the account's first owner,
372// and once an account has one, membership is the console's business and a
373// re-read of a write-once object must not touch it.
374func TestAdoptManifestOwnerOnlyEverFillsAVacancy(t *testing.T) {
375 st := openTestStore(t)
376 now := time.Unix(1700000000, 0)
377
378 // The shape `carlos accounts create` leaves: a row with no members at all.
379 if _, err := st.EnsureAccount(3, "bac", "Kass", now); err != nil {
380 t.Fatal(err)
381 }
382 adopted, err := st.AdoptManifestOwner(3, "paul@keymail.dev", now)
383 if err != nil || !adopted {
384 t.Fatalf("AdoptManifestOwner = %v %v, want it to fill the vacancy", adopted, err)
385 }
386 if role, _ := st.Role(3, "paul@keymail.dev"); role != RoleOwner {
387 t.Errorf("role after adoption = %q, want owner", role)
388 }
389 // Idempotent: every console start re-reads the same manifest, so a second
390 // pass must report "nothing to do" rather than re-writing.
391 if adopted, err := st.AdoptManifestOwner(3, "paul@keymail.dev", now); err != nil || adopted {
392 t.Errorf("second adoption = %v %v, want false", adopted, err)
393 }
394
395 // An account that already has an owner is administered by the console now.
396 // A manifest naming somebody else must NOT hand them authority — that would
397 // make a write-once bucket object a standing grant, re-applied on every
398 // restart, exactly the shape of the backfill bug above.
399 if adopted, err := st.AdoptManifestOwner(3, "someone@keymail.dev", now); err != nil || adopted {
400 t.Errorf("adoption against an owned account = %v %v, want false", adopted, err)
401 }
402 if role, _ := st.Role(3, "someone@keymail.dev"); role != "" {
403 t.Errorf("a manifest gave %q to a stranger on an already-owned account", role)
404 }
405 if role, _ := st.Role(3, "paul@keymail.dev"); role != RoleOwner {
406 t.Errorf("the existing owner was changed to %q", role)
407 }
408}
409
410// The account has members but no OWNER — an invite was accepted (invites always
411// grant member) into an account `carlos accounts create` left ownerless. The
412// manifest's owner is the one the operator named, so they are promoted rather
413// than added twice.
414func TestAdoptManifestOwnerPromotesTheNamedMember(t *testing.T) {
415 st := openTestStore(t)
416 now := time.Unix(1700000000, 0)
417 if _, err := st.EnsureAccount(3, "bac", "Kass", now); err != nil {
418 t.Fatal(err)
419 }
420 if err := st.AddMember(3, "paul@keymail.dev", RoleMember, now); err != nil {
421 t.Fatal(err)
422 }
423 adopted, err := st.AdoptManifestOwner(3, "paul@keymail.dev", now)
424 if err != nil || !adopted {
425 t.Fatalf("AdoptManifestOwner = %v %v, want the named member promoted", adopted, err)
426 }
427 if role, _ := st.Role(3, "paul@keymail.dev"); role != RoleOwner {
428 t.Errorf("role = %q, want owner", role)
429 }
430}
431
432// FOUND IN PRODUCTION, not in review — and it was undoing the migration it
433// ran alongside.
434//
435// `carlos accounts migrate` + `console migrate-app` moved jelly out of ops and
436// into account bac. The legacy `owners` grant for jelly stays readable on
437// purpose (the backfill's comment says so: the grants stay while the migration
438// is verified). The very next console RESTART re-ran this backfill, saw a grant
439// for "jelly", saw no jelly row in OPS, and created one — so the same app
440// existed in two accounts at once, and kass and keymail were queued to get the
441// same treatment on the restart after that. Every migration would have been
442// undone, indefinitely, by the ordinary act of restarting the console.
443//
444// The rule the SQL was missing: the backfill claims a name that nothing claims
445// yet. An app with a row in ANY account has already been placed — by the
446// migration, by the console, by a previous backfill — and placing it again is
447// not a claim, it is a duplicate.
448func TestBackfillNeverRecreatesAnAppThatLivesInAnotherAccount(t *testing.T) {
449 st := openTestStore(t)
450 now := time.Unix(1700000000, 0)
451 const migrated = int64(3)
452 if err := st.CreateAccount(migrated, "bac", "Jelly", "paul@keymail.dev", now); err != nil {
453 t.Fatal(err)
454 }
455 if _, err := st.CreateAppIn(migrated, "jelly", "paul@keymail.dev", now); err != nil {
456 t.Fatal(err)
457 }
458 // Exactly the live shape: the app is migrated, and its pre-accounts grant
459 // is still sitting in the owners table waiting to be revoked.
460 if err := st.GrantAt("dev@keymail.dev", "jelly", now); err != nil {
461 t.Fatal(err)
462 }
463 // A grant-only app that was never migrated still has to be claimed in ops —
464 // that is the job this function exists for, and the fix must not cost it.
465 if err := st.GrantAt("dev@keymail.dev", "toy", now); err != nil {
466 t.Fatal(err)
467 }
468
469 if _, err := st.BackfillOpsMemberships(now.Add(time.Hour)); err != nil {
470 t.Fatal(err)
471 }
472
473 if ok, err := st.HasAppIn(OpsAccountID, "jelly"); err != nil || ok {
474 t.Error("the backfill recreated jelly in ops — the migration is undone on every restart")
475 }
476 holders, err := st.AppAccounts("jelly")
477 if err != nil {
478 t.Fatal(err)
479 }
480 if len(holders) != 1 || holders[0] != migrated {
481 t.Errorf("jelly is claimed by accounts %v, want only %d", holders, migrated)
482 }
483 // The surviving row is the migrated one, unchanged: same owner, same
484 // creation time. A backfill that "fixed" it by rewriting the row would be
485 // the same bug wearing a different hat.
486 var createdBy string
487 var createdAt int64
488 if err := st.db.QueryRow(`SELECT created_by, created_at FROM apps WHERE app = 'jelly'`).
489 Scan(&createdBy, &createdAt); err != nil {
490 t.Fatal(err)
491 }
492 if createdBy != "paul@keymail.dev" || createdAt != now.Unix() {
493 t.Errorf("migrated jelly row = (%s, %d), want it untouched (paul@keymail.dev, %d)",
494 createdBy, createdAt, now.Unix())
495 }
496 if ok, err := st.HasAppIn(OpsAccountID, "toy"); err != nil || !ok {
497 t.Error("a grant-only app that was never migrated lost its ops claim")
498 }
499}
500
501// The advice is the whole point of the report: an operator reading the log
502// must be able to copy three commands per person, not go and design a
503// migration. It names the apps, so nobody is left behind.
504func TestGrantHolderAdviceIsCopyPasteable(t *testing.T) {
505 msg := grantHolderAdvice([]GrantHolder{
506 {Address: "dev@keymail.dev", Apps: []string{"toy", "other"}},
507 {Address: "sam@keymail.dev", Apps: []string{"blog"}},
508 })
509 for _, want := range []string{
510 "2 grant holders need accounts",
511 "dev@keymail.dev", "toy", "other", "sam@keymail.dev", "blog",
512 // The -owner flag is required, so advice that omits it is advice that
513 // fails when pasted. And the `console member -add … -role owner` step is
514 // gone: the manifest owner materializes into the membership, which is
515 // the point — a step an operator can skip is a step that produces an
516 // account nobody can open, as it did in production.
517 "carlos accounts create --name <name> --owner <address>", "console migrate-app",
518 } {
519 if !strings.Contains(msg, want) {
520 t.Errorf("advice does not mention %q: %s", want, msg)
521 }
522 }
523 if grantHolderAdvice(nil) != "" {
524 t.Errorf("advice for nobody = %q, want silence", grantHolderAdvice(nil))
525 }
526}
527
528// The console half of `carlos accounts migrate`: the app→account row moves,
529// and re-running is a no-op rather than an error — an operator who is not
530// sure whether the move landed must be able to just run it again.
531func TestMoveAppBetweenAccounts(t *testing.T) {
532 st := openTestStore(t)
533 now := time.Now()
534 if err := st.CreateAccount(OpsAccountID, opsSqid, "ops", "paul@keymail.dev", now); err != nil {
535 t.Fatal(err)
536 }
537 if err := st.CreateAccount(42, "cdef", "Kass", "paul@keymail.dev", now); err != nil {
538 t.Fatal(err)
539 }
540 if _, err := st.CreateAppIn(OpsAccountID, "kass", "paul@keymail.dev", now); err != nil {
541 t.Fatal(err)
542 }
543
544 if got, err := st.AppAccounts("kass"); err != nil || len(got) != 1 || got[0] != OpsAccountID {
545 t.Fatalf("AppAccounts = %v %v, want [1]", got, err)
546 }
547 if err := st.MoveApp("kass", OpsAccountID, 42); err != nil {
548 t.Fatal(err)
549 }
550 if got, _ := st.AppAccounts("kass"); len(got) != 1 || got[0] != 42 {
551 t.Fatalf("after move AppAccounts = %v, want [42]", got)
552 }
553 if apps, _ := st.AppsIn(OpsAccountID); len(apps) != 0 {
554 t.Errorf("app still in the source account: %v", apps)
555 }
556 // Idempotent: the row is already where it was asked to go.
557 if err := st.MoveApp("kass", OpsAccountID, 42); err != nil {
558 t.Fatalf("re-running the move must be a no-op: %v", err)
559 }
560 if got, _ := st.AppAccounts("kass"); len(got) != 1 || got[0] != 42 {
561 t.Fatalf("re-run changed things: %v", got)
562 }
563 // Moving onto a name the destination already holds is refused, not
564 // silently merged: two different apps would become one row.
565 if _, err := st.CreateAppIn(OpsAccountID, "kass", "paul@keymail.dev", now); err != nil {
566 t.Fatal(err)
567 }
568 if err := st.MoveApp("kass", OpsAccountID, 42); err == nil {
569 t.Error("moving onto an existing name must fail")
570 }
571 if got, _ := st.AppAccounts("kass"); len(got) != 2 {
572 t.Errorf("refused move lost a row: %v", got)
573 }
574 // The app's other rows are untouched by a move of one of them.
575 if got, err := st.AppAccounts("nosuchapp"); err != nil || len(got) != 0 {
576 t.Errorf("AppAccounts(absent) = %v %v, want empty nil", got, err)
577 }
578}
579
580// Account names are deliberately not unique — identity is the sqid — and
581// AccountsFor must order deterministically (creation order) so "your first
582// membership" is a stable answer.
583func TestAccountsForOrdersByCreation(t *testing.T) {
584 st := openTestStore(t)
585 now := time.Now()
586 if err := st.CreateAccount(7, "cdef", "Blog", "paul@keymail.dev", now); err != nil {
587 t.Fatal(err)
588 }
589 if err := st.CreateAccount(9, "cdeg", "Blog", "paul@keymail.dev", now.Add(time.Second)); err != nil {
590 t.Fatal(err)
591 }
592 accts, _ := st.AccountsFor("paul@keymail.dev")
593 if len(accts) != 2 || accts[0].Sqid != "cdef" || accts[1].Sqid != "cdeg" {
594 t.Fatalf("AccountsFor = %+v, want cdef then cdeg", accts)
595 }
596}
597
598func TestAccountBySqidAndAppOwnership(t *testing.T) {
599 st := openTestStore(t)
600 now := time.Now()
601 if err := st.CreateAccount(42, "cdef", "Kass", "paul@keymail.dev", now); err != nil {
602 t.Fatal(err)
603 }
604 a, err := st.AccountBySqid("cdef")
605 if err != nil || a == nil || a.ID != 42 || a.Name != "Kass" {
606 t.Fatalf("AccountBySqid = %+v %v", a, err)
607 }
608 if a, err := st.AccountBySqid("nosuch"); err != nil || a != nil {
609 t.Fatalf("AccountBySqid(absent) = %+v %v, want nil nil", a, err)
610 }
611 // Apps belong to an account now; the same name may exist in two.
612 if created, err := st.CreateAppIn(42, "blog", "paul@keymail.dev", now); err != nil || !created {
613 t.Fatalf("CreateAppIn = %v %v", created, err)
614 }
615 if created, err := st.CreateAppIn(42, "blog", "paul@keymail.dev", now); err != nil || created {
616 t.Fatalf("second CreateAppIn in the same account = %v %v, want false nil", created, err)
617 }
618 if err := st.CreateAccount(43, "cdeg", "Other", "sam@keymail.dev", now); err != nil {
619 t.Fatal(err)
620 }
621 if created, err := st.CreateAppIn(43, "blog", "sam@keymail.dev", now); err != nil || !created {
622 t.Fatalf("same app name in another account must be free: %v %v", created, err)
623 }
624 apps, err := st.AppsIn(42)
625 if err != nil || len(apps) != 1 || apps[0] != "blog" {
626 t.Fatalf("AppsIn(42) = %v %v", apps, err)
627 }
628}
629