| 1 | package domains_test |
| 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 | // Account ids are SEQUENTIAL, and the sqid length is why: the frozen encoder |
| 15 | // is base-23, so a 40-bit id encodes to nine characters — |
| 16 | // kass.nyfbskdye.oncarlos.com — which nobody can read out, type, or trust. |
| 17 | // Sequential ids give bab, bac, bad…, growing a character every 23x. |
| 18 | func TestNextAccountIDIsTheNextFreeInteger(t *testing.T) { |
| 19 | ctx := t.Context() |
| 20 | s := release.NewDirStore(t.TempDir()) |
| 21 | |
| 22 | // An empty bucket starts at 2: 0 is not an id and 1 is reserved for the |
| 23 | // built-in ops account on every deployment. |
| 24 | got, err := domains.NextAccountID(ctx, s) |
| 25 | if err != nil { |
| 26 | t.Fatal(err) |
| 27 | } |
| 28 | if got != 2 { |
| 29 | t.Errorf("NextAccountID(empty) = %d, want 2", got) |
| 30 | } |
| 31 | if len(domains.Sqid(got)) > 3 { |
| 32 | t.Errorf("Sqid(%d) = %q — sequential ids exist to keep this short", got, domains.Sqid(got)) |
| 33 | } |
| 34 | |
| 35 | for _, id := range []int64{2, 7} { |
| 36 | writeManifest(t, s, id, domains.Sqid(id)) |
| 37 | } |
| 38 | if got, err := domains.NextAccountID(ctx, s); err != nil || got != 8 { |
| 39 | t.Errorf("NextAccountID = %d (%v), want 8 — one past the highest claimed", got, err) |
| 40 | } |
| 41 | } |
| 42 | |
| 43 | // A manifest that disagrees with itself still CONSUMES its id. It is skipped |
| 44 | // for materialization (it is corruption, not an account), but handing its id |
| 45 | // to somebody else would make two objects claim one identity — the one thing |
| 46 | // the whole minting scheme exists to prevent. |
| 47 | func TestNextAccountIDCountsIdsItWouldNotMaterialize(t *testing.T) { |
| 48 | ctx := t.Context() |
| 49 | s := release.NewDirStore(t.TempDir()) |
| 50 | if _, err := s.Put(ctx, "accounts/wrong/account.json", |
| 51 | []byte(`{"id":9,"sqid":"wrong","name":"Corrupt"}`), ""); err != nil { |
| 52 | t.Fatal(err) |
| 53 | } |
| 54 | if _, err := s.Put(ctx, "accounts/junk/account.json", []byte("{not json"), ""); err != nil { |
| 55 | t.Fatal(err) |
| 56 | } |
| 57 | if got, err := domains.NextAccountID(ctx, s); err != nil || got != 10 { |
| 58 | t.Errorf("NextAccountID = %d (%v), want 10 — a claimed id is claimed", got, err) |
| 59 | } |
| 60 | } |
| 61 | |
| 62 | // Two boxes minting at once must never agree on an id. The listing that picks |
| 63 | // max+1 is not a lock, so the manifest write is CREATE-ONLY: the loser gets a |
| 64 | // CAS conflict, re-lists, and takes the next one. Overwriting would erase an |
| 65 | // account's identity and re-home every domain it owns. |
| 66 | func TestMintAccountLosesTheRaceAndTakesTheNextID(t *testing.T) { |
| 67 | ctx := t.Context() |
| 68 | inner := release.NewDirStore(t.TempDir()) |
| 69 | planted := false |
| 70 | s := &racingStore{Store: inner, beforePut: func(key string) { |
| 71 | if planted { |
| 72 | return |
| 73 | } |
| 74 | planted = true |
| 75 | // Another minter got there first, between our List and our Put. |
| 76 | writeManifest(t, inner, 2, domains.Sqid(2)) |
| 77 | }} |
| 78 | |
| 79 | id, sqid, err := domains.MintAccount(ctx, s, "Kass", "", time.Unix(1700000000, 0)) |
| 80 | if err != nil { |
| 81 | t.Fatal(err) |
| 82 | } |
| 83 | if id != 3 || sqid != domains.Sqid(3) { |
| 84 | t.Errorf("minted (%d, %q), want the next free id 3 (%q)", id, sqid, domains.Sqid(3)) |
| 85 | } |
| 86 | // The competitor's manifest is untouched. |
| 87 | body, _, err := inner.Get(ctx, "accounts/"+domains.Sqid(2)+"/account.json") |
| 88 | if err != nil { |
| 89 | t.Fatal(err) |
| 90 | } |
| 91 | var m struct { |
| 92 | Name string `json:"name"` |
| 93 | } |
| 94 | if err := json.Unmarshal(body, &m); err != nil || m.Name != "Competitor" { |
| 95 | t.Errorf("the racing minter's manifest was overwritten: %s", body) |
| 96 | } |
| 97 | // And ours landed, with the id in the body matching the sqid in the path. |
| 98 | body, _, err = inner.Get(ctx, "accounts/"+sqid+"/account.json") |
| 99 | if err != nil { |
| 100 | t.Fatal(err) |
| 101 | } |
| 102 | var mine struct { |
| 103 | ID int64 `json:"id"` |
| 104 | Sqid string `json:"sqid"` |
| 105 | Name string `json:"name"` |
| 106 | } |
| 107 | if err := json.Unmarshal(body, &mine); err != nil { |
| 108 | t.Fatal(err) |
| 109 | } |
| 110 | if mine.ID != id || mine.Sqid != sqid || mine.Name != "Kass" { |
| 111 | t.Errorf("minted manifest = %+v, want id %d sqid %q", mine, id, sqid) |
| 112 | } |
| 113 | } |
| 114 | |
| 115 | // Retries are BOUNDED. A store that conflicts forever is a broken deployment, |
| 116 | // not something to spin on: fail loudly rather than loop. |
| 117 | func TestMintAccountGivesUpLoudly(t *testing.T) { |
| 118 | ctx := t.Context() |
| 119 | inner := release.NewDirStore(t.TempDir()) |
| 120 | next := int64(2) |
| 121 | s := &racingStore{Store: inner, beforePut: func(key string) { |
| 122 | writeManifest(t, inner, next, domains.Sqid(next)) |
| 123 | next++ |
| 124 | }} |
| 125 | _, _, err := domains.MintAccount(ctx, s, "Kass", "", time.Now()) |
| 126 | if err == nil { |
| 127 | t.Fatal("minting against a permanently racing store succeeded") |
| 128 | } |
| 129 | if !strings.Contains(err.Error(), "account") { |
| 130 | t.Errorf("give-up error does not say what failed: %v", err) |
| 131 | } |
| 132 | } |
| 133 | |
| 134 | // FOUND IN PRODUCTION: `carlos accounts create` recorded an owner NOWHERE, so |
| 135 | // the accounts kass and keymail were migrated into were unreachable through the |
| 136 | // console until someone ran `console member -add … -role owner` by hand. The |
| 137 | // operator hit exactly that and could not open either account. |
| 138 | // |
| 139 | // The manifest is where the fix lives, because the bucket is the control plane: |
| 140 | // whoever materializes the account — this box's console, or any other box's — |
| 141 | // reads the same object and lands on the same owner. An account created from |
| 142 | // the CLI and one created from the console now converge on the same end state. |
| 143 | func TestMintAccountRecordsTheOwnerInTheManifest(t *testing.T) { |
| 144 | ctx := t.Context() |
| 145 | s := release.NewDirStore(t.TempDir()) |
| 146 | _, sqid, err := domains.MintAccount(ctx, s, "Kass", " Paul@Keymail.dev ", time.Unix(1700000000, 0)) |
| 147 | if err != nil { |
| 148 | t.Fatal(err) |
| 149 | } |
| 150 | body, _, err := s.Get(ctx, domains.AccountManifestKey(sqid)) |
| 151 | if err != nil { |
| 152 | t.Fatal(err) |
| 153 | } |
| 154 | var m domains.AccountManifest |
| 155 | if err := json.Unmarshal(body, &m); err != nil { |
| 156 | t.Fatal(err) |
| 157 | } |
| 158 | // NORMALIZED at the mint, not at the reader: the console's memberships key |
| 159 | // is literal text with no COLLATE NOCASE, so a manifest carrying |
| 160 | // "Paul@Keymail.dev" would materialize a membership the real session can |
| 161 | // never match — an owner who still cannot get in, which is the very failure |
| 162 | // this field exists to end. |
| 163 | if m.Owner != "paul@keymail.dev" { |
| 164 | t.Errorf("manifest owner = %q, want the trimmed, lowercased address", m.Owner) |
| 165 | } |
| 166 | } |
| 167 | |
| 168 | // Absent is legal and means ownerless: every manifest written before this field |
| 169 | // existed has no owner, and they must keep materializing exactly as they did. |
| 170 | func TestMintAccountLeavesTheOwnerOutWhenThereIsNone(t *testing.T) { |
| 171 | ctx := t.Context() |
| 172 | s := release.NewDirStore(t.TempDir()) |
| 173 | _, sqid, err := domains.MintAccount(ctx, s, "Ownerless", "", time.Unix(1700000000, 0)) |
| 174 | if err != nil { |
| 175 | t.Fatal(err) |
| 176 | } |
| 177 | body, _, err := s.Get(ctx, domains.AccountManifestKey(sqid)) |
| 178 | if err != nil { |
| 179 | t.Fatal(err) |
| 180 | } |
| 181 | if strings.Contains(string(body), "owner") { |
| 182 | t.Errorf("manifest carries an owner key for an ownerless account: %s", body) |
| 183 | } |
| 184 | } |
| 185 | |
| 186 | // A manifest owner that keymail could never authenticate is a grant nobody can |
| 187 | // claim — the same reason `console member` refuses a malformed address. Caught |
| 188 | // at the MINT, because the manifest is written once and read forever: a bad |
| 189 | // address here is permanent, and the id it consumed is not reusable. |
| 190 | func TestMintAccountRefusesAnUnusableOwner(t *testing.T) { |
| 191 | ctx := t.Context() |
| 192 | for _, bad := range []string{"paul", "@keymail.dev", "paul@", "paul @keymail.dev"} { |
| 193 | s := release.NewDirStore(t.TempDir()) |
| 194 | if _, _, err := domains.MintAccount(ctx, s, "Kass", bad, time.Now()); err == nil { |
| 195 | t.Errorf("MintAccount with owner %q succeeded", bad) |
| 196 | } |
| 197 | // And nothing was written: a refused mint must not burn an id or leave |
| 198 | // an account behind. |
| 199 | if keys, err := s.List(ctx, "accounts/"); err != nil || len(keys) != 0 { |
| 200 | t.Errorf("owner %q left %v in the bucket", bad, keys) |
| 201 | } |
| 202 | } |
| 203 | } |
| 204 | |
| 205 | // One rule, in one place. The console's sign-in form, `console member -add` and |
| 206 | // now `carlos accounts create` must all accept and reject the same strings — |
| 207 | // an address one of them takes and another refuses is a membership somebody |
| 208 | // can create and nobody can use. |
| 209 | func TestAddressNormalizationAndValidity(t *testing.T) { |
| 210 | if got := domains.NormalizeAddress(" Paul@Keymail.dev\n"); got != "paul@keymail.dev" { |
| 211 | t.Errorf("NormalizeAddress = %q", got) |
| 212 | } |
| 213 | for _, ok := range []string{"paul@keymail.dev", "a@b"} { |
| 214 | if !domains.ValidAddress(ok) { |
| 215 | t.Errorf("ValidAddress(%q) = false", ok) |
| 216 | } |
| 217 | } |
| 218 | for _, bad := range []string{"", "paul", "@keymail.dev", "paul@", "paul@key mail.dev"} { |
| 219 | if domains.ValidAddress(bad) { |
| 220 | t.Errorf("ValidAddress(%q) = true", bad) |
| 221 | } |
| 222 | } |
| 223 | } |
| 224 | |
| 225 | func writeManifest(t *testing.T, s release.Store, id int64, sqid string) { |
| 226 | t.Helper() |
| 227 | body := []byte(`{"id":` + itoa(id) + `,"sqid":"` + sqid + `","name":"Competitor",` + |
| 228 | `"created_at":"2026-08-02T09:00:00Z"}`) |
| 229 | if _, err := s.Put(context.Background(), "accounts/"+sqid+"/account.json", body, ""); err != nil { |
| 230 | t.Fatal(err) |
| 231 | } |
| 232 | } |
| 233 | |
| 234 | func itoa(i int64) string { |
| 235 | b, _ := json.Marshal(i) |
| 236 | return string(b) |
| 237 | } |
| 238 | |
| 239 | // racingStore runs a hook immediately before each Put, so a test can plant the |
| 240 | // object the minter is about to create — the one interleaving that matters and |
| 241 | // the one a sequential allocator has to survive. |
| 242 | type racingStore struct { |
| 243 | release.Store |
| 244 | beforePut func(key string) |
| 245 | } |
| 246 | |
| 247 | func (r *racingStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) { |
| 248 | r.beforePut(key) |
| 249 | return r.Store.Put(ctx, key, body, ifMatch) |
| 250 | } |
| 251 | |