| 1 | package main |
| 2 | |
| 3 | // Region-neutral minting (multi-edge Phase 1, docs/multi-edge.md): the cloud |
| 4 | // draws a new space's HOSTNAME id from the shared global ledger (internal/idalloc) |
| 5 | // when configured, else from its own cloud.db PK — today's behaviour, byte for |
| 6 | // byte, on any box without the shared bucket. These tests pin both sides of that |
| 7 | // fork, for the regular AND the demo create path, and prove the cloud.db quota / |
| 8 | // entitlement bookkeeping is untouched when the hostname id comes from the ledger. |
| 9 | |
| 10 | import ( |
| 11 | "context" |
| 12 | "fmt" |
| 13 | "io" |
| 14 | "net/http" |
| 15 | "net/http/httptest" |
| 16 | "strings" |
| 17 | "testing" |
| 18 | "time" |
| 19 | |
| 20 | "lchat/internal/idalloc" |
| 21 | ) |
| 22 | |
| 23 | // fakeLedgerS3 is a minimal, correct compare-and-swap S3 over one object — just |
| 24 | // enough for idalloc's counter: path-style GET (404 when absent) and conditional |
| 25 | // PUT honouring If-None-Match / If-Match with a real 412. Modelled on idalloc's |
| 26 | // own test fake, kept local since that one isn't exported. |
| 27 | type fakeLedgerS3 struct { |
| 28 | body []byte |
| 29 | etag string |
| 30 | present bool |
| 31 | seq int |
| 32 | } |
| 33 | |
| 34 | func (f *fakeLedgerS3) handler(w http.ResponseWriter, r *http.Request) { |
| 35 | switch r.Method { |
| 36 | case http.MethodGet: |
| 37 | if !f.present { |
| 38 | w.Header().Set("Content-Type", "application/xml") |
| 39 | w.WriteHeader(http.StatusNotFound) |
| 40 | io.WriteString(w, `<Error><Code>NoSuchKey</Code><Message>no such key</Message></Error>`) |
| 41 | return |
| 42 | } |
| 43 | w.Header().Set("ETag", f.etag) |
| 44 | w.WriteHeader(http.StatusOK) |
| 45 | w.Write(f.body) |
| 46 | case http.MethodPut: |
| 47 | if r.Header.Get("If-None-Match") == "*" && f.present { |
| 48 | f.precondition412(w) |
| 49 | return |
| 50 | } |
| 51 | if m := r.Header.Get("If-Match"); m != "" && (!f.present || m != f.etag) { |
| 52 | f.precondition412(w) |
| 53 | return |
| 54 | } |
| 55 | body, _ := io.ReadAll(r.Body) |
| 56 | f.seq++ |
| 57 | f.body, f.etag, f.present = body, fmt.Sprintf("\"e%d\"", f.seq), true |
| 58 | w.Header().Set("ETag", f.etag) |
| 59 | w.WriteHeader(http.StatusOK) |
| 60 | default: |
| 61 | w.WriteHeader(http.StatusNotImplemented) |
| 62 | } |
| 63 | } |
| 64 | |
| 65 | func (f *fakeLedgerS3) precondition412(w http.ResponseWriter) { |
| 66 | w.Header().Set("Content-Type", "application/xml") |
| 67 | w.WriteHeader(http.StatusPreconditionFailed) |
| 68 | io.WriteString(w, `<Error><Code>PreconditionFailed</Code><Message>precondition failed</Message></Error>`) |
| 69 | } |
| 70 | |
| 71 | // ledgerEnv points the deliberate LCHAT_CLOUD_ID_LEDGER opt-in + the shared |
| 72 | // LCHAT_HIBERNATE_* bucket at an httptest S3 fake, and returns its URL — so tests |
| 73 | // enable the ledger through the SAME gate + env runCloud uses, never by reaching |
| 74 | // past it. Static AWS creds satisfy the SDK signer offline (as in idalloc_test.go). |
| 75 | func ledgerEnv(t *testing.T) string { |
| 76 | t.Helper() |
| 77 | fake := &fakeLedgerS3{} |
| 78 | srv := httptest.NewServer(http.HandlerFunc(fake.handler)) |
| 79 | t.Cleanup(srv.Close) |
| 80 | t.Setenv("AWS_ACCESS_KEY_ID", "test") |
| 81 | t.Setenv("AWS_SECRET_ACCESS_KEY", "test") |
| 82 | t.Setenv("AWS_REGION", "auto") |
| 83 | t.Setenv("LCHAT_CLOUD_ID_LEDGER", "1") |
| 84 | t.Setenv("LCHAT_HIBERNATE_BUCKET", "idbucket") |
| 85 | t.Setenv("LCHAT_HIBERNATE_PREFIX", "pfx") |
| 86 | t.Setenv("LCHAT_HIBERNATE_ENDPOINT", srv.URL) |
| 87 | return srv.URL |
| 88 | } |
| 89 | |
| 90 | // newFakeLedger builds the allocator through newCloudLedger — the real gate — so |
| 91 | // the cloud sees exactly the allocator a flagged, bucketed box would construct. |
| 92 | func newFakeLedger(t *testing.T) *idalloc.Allocator { |
| 93 | t.Helper() |
| 94 | ledgerEnv(t) |
| 95 | a, err := newCloudLedger(context.Background()) |
| 96 | if err != nil { |
| 97 | t.Fatalf("newCloudLedger: %v", err) |
| 98 | } |
| 99 | if a == nil { |
| 100 | t.Fatal("newCloudLedger returned nil with the flag + bucket set") |
| 101 | } |
| 102 | return a |
| 103 | } |
| 104 | |
| 105 | // hostLabelID cuts the sqid label off a host and decodes it to its id. |
| 106 | func hostLabelID(t *testing.T, host string) int64 { |
| 107 | t.Helper() |
| 108 | label, _, ok := strings.Cut(host, ".") |
| 109 | if !ok { |
| 110 | t.Fatalf("host %q has no label", host) |
| 111 | } |
| 112 | id, ok := spaceID(label) |
| 113 | if !ok { |
| 114 | t.Fatalf("label %q of host %q is not a canonical sqid", label, host) |
| 115 | } |
| 116 | return id |
| 117 | } |
| 118 | |
| 119 | // With the ledger configured, a regular AND a demo create take the hostname id |
| 120 | // from the global allocator, not the cloud.db PK — and two creates get distinct, |
| 121 | // increasing global ids. The counter is pre-advanced so ledger ids diverge from |
| 122 | // the local PK sequence (both start at 1), making the source unambiguous. |
| 123 | func TestCloudLedgerHostnamesUseGlobalIds(t *testing.T) { |
| 124 | srv, s := newTestCloud(t, okControl(t)) |
| 125 | s.demoTTL, s.demoMaxLive = 24*time.Hour, 10 |
| 126 | alloc := newFakeLedger(t) |
| 127 | ctx := context.Background() |
| 128 | for i := 0; i < 5; i++ { |
| 129 | if _, err := alloc.Allocate(ctx); err != nil { |
| 130 | t.Fatalf("pre-advance %d: %v", i, err) |
| 131 | } |
| 132 | } |
| 133 | // The counter's high-water is now 5, so the server's next draws are 6, 7, 8. |
| 134 | s.ids = alloc |
| 135 | tok := mintCaller(t, srv, "home") |
| 136 | |
| 137 | // Two free creates: hostnames decode to the global ids 6 then 7. |
| 138 | var hosts []string |
| 139 | for i := 0; i < 2; i++ { |
| 140 | resp := createSpace(t, srv, tok, "u") |
| 141 | if resp.StatusCode != http.StatusCreated { |
| 142 | t.Fatalf("free create %d: HTTP %d", i, resp.StatusCode) |
| 143 | } |
| 144 | hosts = append(hosts, decodeBody(t, resp)["host"].(string)) |
| 145 | } |
| 146 | if got := hostLabelID(t, hosts[0]); got != 6 { |
| 147 | t.Errorf("first ledger hostname id = %d, want 6 (global counter, not PK 1)", got) |
| 148 | } |
| 149 | if got := hostLabelID(t, hosts[1]); got != 7 { |
| 150 | t.Errorf("second ledger hostname id = %d, want 7 (strictly increasing)", got) |
| 151 | } |
| 152 | // The cloud.db PK is still the LOCAL sequence and decoupled from the address: |
| 153 | // the first space's row is PK 1 even though its hostname encodes 6. |
| 154 | if pk, _, _, ok, err := s.db.spaceByHost(hosts[0]); err != nil || !ok || pk != 1 { |
| 155 | t.Errorf("first space PK = %d (ok=%v err=%v), want local PK 1 — hostname id must not be the PK", pk, ok, err) |
| 156 | } |
| 157 | |
| 158 | // A demo create draws from the same global counter (next is 8). |
| 159 | resp := createKind(t, srv, tok, "d", "demo") |
| 160 | if resp.StatusCode != http.StatusCreated { |
| 161 | t.Fatalf("demo create: HTTP %d", resp.StatusCode) |
| 162 | } |
| 163 | demoHost := decodeBody(t, resp)["host"].(string) |
| 164 | if got := hostLabelID(t, demoHost); got != 8 { |
| 165 | t.Errorf("demo ledger hostname id = %d, want 8 (shared global counter)", got) |
| 166 | } |
| 167 | } |
| 168 | |
| 169 | // With NO allocator (the default: production, self-hosters, any un-bucketed box), |
| 170 | // both the regular and demo paths build the hostname from the cloud.db PK exactly |
| 171 | // as today — the hostname id equals the row's PK. |
| 172 | func TestCloudNoLedgerUsesLocalPK(t *testing.T) { |
| 173 | srv, s := newTestCloud(t, okControl(t)) // s.ids stays nil |
| 174 | s.demoTTL, s.demoMaxLive = 24*time.Hour, 10 |
| 175 | tok := mintCaller(t, srv, "home") |
| 176 | |
| 177 | // Fresh DB → PKs 1, 2, …; a free then a demo create both encode their own PK. |
| 178 | free := decodeBody(t, mustCreate(t, srv, tok, "u", ""))["host"].(string) |
| 179 | demo := decodeBody(t, mustCreate(t, srv, tok, "u", "demo"))["host"].(string) |
| 180 | for _, tc := range []struct { |
| 181 | name, host string |
| 182 | }{{"free", free}, {"demo", demo}} { |
| 183 | pk, _, _, ok, err := s.db.spaceByHost(tc.host) |
| 184 | if err != nil || !ok { |
| 185 | t.Fatalf("%s spaceByHost(%q): ok=%v err=%v", tc.name, tc.host, ok, err) |
| 186 | } |
| 187 | if got := hostLabelID(t, tc.host); got != pk { |
| 188 | t.Errorf("%s hostname id = %d, want cloud.db PK %d (no-ledger path unchanged)", tc.name, got, pk) |
| 189 | } |
| 190 | } |
| 191 | } |
| 192 | |
| 193 | // Quota + entitlement bookkeeping is untouched when the hostname id comes from |
| 194 | // the ledger: the free quota still counts cloud.db rows (not global ids), the |
| 195 | // (quota+1)th free create is refused, and a demo still slips past with a demo |
| 196 | // entitlement recorded in the ledger. |
| 197 | func TestCloudLedgerPreservesQuotaAndEntitlement(t *testing.T) { |
| 198 | srv, s := newTestCloud(t, okControl(t)) // harness quota = 3 |
| 199 | s.demoTTL, s.demoMaxLive = 24*time.Hour, 10 |
| 200 | s.ids = newFakeLedger(t) |
| 201 | tok := mintCaller(t, srv, "home") |
| 202 | |
| 203 | for i := 0; i < s.quota; i++ { |
| 204 | if resp := createSpace(t, srv, tok, "bob"); resp.StatusCode != http.StatusCreated { |
| 205 | t.Fatalf("free create %d: HTTP %d", i, resp.StatusCode) |
| 206 | } |
| 207 | } |
| 208 | if resp := createSpace(t, srv, tok, "bob"); resp.StatusCode != http.StatusConflict { |
| 209 | t.Fatalf("over-quota free create: HTTP %d, want 409 (quota counts cloud.db rows, not ledger ids)", resp.StatusCode) |
| 210 | } |
| 211 | // A demo bypasses the full free quota, and its ledger row is a demo. |
| 212 | resp := createKind(t, srv, tok, "bob", "demo") |
| 213 | if resp.StatusCode != http.StatusCreated { |
| 214 | t.Fatalf("demo past full quota: HTTP %d", resp.StatusCode) |
| 215 | } |
| 216 | demoHost := decodeBody(t, resp)["host"].(string) |
| 217 | var ent string |
| 218 | if err := s.db.sql.QueryRow(`SELECT entitlement FROM spaces WHERE host = ?`, demoHost).Scan(&ent); err != nil || ent != entDemo { |
| 219 | t.Fatalf("ledger demo entitlement = %q err=%v, want %q", ent, err, entDemo) |
| 220 | } |
| 221 | // The demo didn't consume a free slot: a following free create is still |
| 222 | // refused at quota, proving the quota ledger is untouched by the ledger path. |
| 223 | if resp := createSpace(t, srv, tok, "bob"); resp.StatusCode != http.StatusConflict { |
| 224 | t.Fatalf("free create after demo: HTTP %d, want 409 (quota unchanged)", resp.StatusCode) |
| 225 | } |
| 226 | } |
| 227 | |
| 228 | // The gate is deliberate: LCHAT_HIBERNATE_BUCKET alone must NOT turn the ledger |
| 229 | // on — only the explicit LCHAT_CLOUD_ID_LEDGER opt-in does, and it fails fast if |
| 230 | // the shared bucket is missing. This guard is the whole point of the flag. |
| 231 | func TestCloudLedgerGate(t *testing.T) { |
| 232 | ledgerEnv(t) // sets AWS creds + flag + bucket + endpoint |
| 233 | ctx := context.Background() |
| 234 | |
| 235 | // Bucket present, flag OFF → no ledger (the load-bearing guard). |
| 236 | t.Setenv("LCHAT_CLOUD_ID_LEDGER", "") |
| 237 | if a, err := newCloudLedger(ctx); err != nil || a != nil { |
| 238 | t.Fatalf("bucket set but flag off: got (%v, %v), want (nil, nil) — bucket alone must NOT activate the ledger", a, err) |
| 239 | } |
| 240 | |
| 241 | // Flag ON, bucket MISSING → fail fast, never silent-off. |
| 242 | t.Setenv("LCHAT_CLOUD_ID_LEDGER", "1") |
| 243 | t.Setenv("LCHAT_HIBERNATE_BUCKET", "") |
| 244 | if a, err := newCloudLedger(ctx); err == nil || a != nil { |
| 245 | t.Fatalf("flag on, bucket empty: got (%v, %v), want a hard error (fail fast)", a, err) |
| 246 | } |
| 247 | |
| 248 | // Flag ON + bucket → an allocator. |
| 249 | t.Setenv("LCHAT_HIBERNATE_BUCKET", "idbucket") |
| 250 | if a, err := newCloudLedger(ctx); err != nil || a == nil { |
| 251 | t.Fatalf("flag on + bucket: got (%v, %v), want a non-nil allocator", a, err) |
| 252 | } |
| 253 | } |
| 254 | |
| 255 | // Behaviour-level guard: with the bucket present but the flag OFF, the cloud |
| 256 | // still mints local-PK hostnames — newCloudLedger returns nil, so s.ids stays |
| 257 | // nil and nothing draws from the global counter. |
| 258 | func TestCloudBucketWithoutFlagUsesLocalPK(t *testing.T) { |
| 259 | ledgerEnv(t) |
| 260 | t.Setenv("LCHAT_CLOUD_ID_LEDGER", "") // bucket stays set; flag off |
| 261 | ids, err := newCloudLedger(context.Background()) |
| 262 | if err != nil { |
| 263 | t.Fatal(err) |
| 264 | } |
| 265 | if ids != nil { |
| 266 | t.Fatal("bucket alone built an allocator — the flag guard failed") |
| 267 | } |
| 268 | |
| 269 | srv, s := newTestCloud(t, okControl(t)) |
| 270 | s.ids = ids // nil |
| 271 | tok := mintCaller(t, srv, "home") |
| 272 | host := decodeBody(t, mustCreate(t, srv, tok, "u", ""))["host"].(string) |
| 273 | pk, _, _, ok, err := s.db.spaceByHost(host) |
| 274 | if err != nil || !ok { |
| 275 | t.Fatalf("spaceByHost(%q): ok=%v err=%v", host, ok, err) |
| 276 | } |
| 277 | if got := hostLabelID(t, host); got != pk { |
| 278 | t.Errorf("bucket-without-flag hostname id = %d, want cloud.db PK %d (hostnames stay local)", got, pk) |
| 279 | } |
| 280 | } |
| 281 | |
| 282 | // mustCreate posts a create (kind "" for free, "demo" for a demo) and fails on |
| 283 | // any non-201. |
| 284 | func mustCreate(t *testing.T, srv *httptest.Server, token, requester, kind string) *http.Response { |
| 285 | t.Helper() |
| 286 | var resp *http.Response |
| 287 | if kind == "" { |
| 288 | resp = createSpace(t, srv, token, requester) |
| 289 | } else { |
| 290 | resp = createKind(t, srv, token, requester, kind) |
| 291 | } |
| 292 | if resp.StatusCode != http.StatusCreated { |
| 293 | t.Fatalf("create (kind %q): HTTP %d", kind, resp.StatusCode) |
| 294 | } |
| 295 | return resp |
| 296 | } |
| 297 | |