| 1 | package console |
| 2 | |
| 3 | import ( |
| 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. |
| 17 | func 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. |
| 52 | func 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. |
| 72 | func 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 | |
| 94 | func 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. |
| 121 | func 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 | |
| 145 | func 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 | |
| 158 | func 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 | |
| 186 | func 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 | |
| 208 | func 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. |
| 239 | func 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`. |
| 301 | func 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. |
| 345 | func 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. |
| 374 | func 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. |
| 414 | func 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. |
| 448 | func 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. |
| 504 | func 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. |
| 531 | func 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. |
| 583 | func 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 | |
| 598 | func 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 | |