package main // Region-neutral minting (multi-edge Phase 1, docs/multi-edge.md): the cloud // draws a new space's HOSTNAME id from the shared global ledger (internal/idalloc) // when configured, else from its own cloud.db PK — today's behaviour, byte for // byte, on any box without the shared bucket. These tests pin both sides of that // fork, for the regular AND the demo create path, and prove the cloud.db quota / // entitlement bookkeeping is untouched when the hostname id comes from the ledger. import ( "context" "fmt" "io" "net/http" "net/http/httptest" "strings" "testing" "time" "lchat/internal/idalloc" ) // fakeLedgerS3 is a minimal, correct compare-and-swap S3 over one object — just // enough for idalloc's counter: path-style GET (404 when absent) and conditional // PUT honouring If-None-Match / If-Match with a real 412. Modelled on idalloc's // own test fake, kept local since that one isn't exported. type fakeLedgerS3 struct { body []byte etag string present bool seq int } func (f *fakeLedgerS3) handler(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: if !f.present { w.Header().Set("Content-Type", "application/xml") w.WriteHeader(http.StatusNotFound) io.WriteString(w, `NoSuchKeyno such key`) return } w.Header().Set("ETag", f.etag) w.WriteHeader(http.StatusOK) w.Write(f.body) case http.MethodPut: if r.Header.Get("If-None-Match") == "*" && f.present { f.precondition412(w) return } if m := r.Header.Get("If-Match"); m != "" && (!f.present || m != f.etag) { f.precondition412(w) return } body, _ := io.ReadAll(r.Body) f.seq++ f.body, f.etag, f.present = body, fmt.Sprintf("\"e%d\"", f.seq), true w.Header().Set("ETag", f.etag) w.WriteHeader(http.StatusOK) default: w.WriteHeader(http.StatusNotImplemented) } } func (f *fakeLedgerS3) precondition412(w http.ResponseWriter) { w.Header().Set("Content-Type", "application/xml") w.WriteHeader(http.StatusPreconditionFailed) io.WriteString(w, `PreconditionFailedprecondition failed`) } // ledgerEnv points the deliberate LCHAT_CLOUD_ID_LEDGER opt-in + the shared // LCHAT_HIBERNATE_* bucket at an httptest S3 fake, and returns its URL — so tests // enable the ledger through the SAME gate + env runCloud uses, never by reaching // past it. Static AWS creds satisfy the SDK signer offline (as in idalloc_test.go). func ledgerEnv(t *testing.T) string { t.Helper() fake := &fakeLedgerS3{} srv := httptest.NewServer(http.HandlerFunc(fake.handler)) t.Cleanup(srv.Close) t.Setenv("AWS_ACCESS_KEY_ID", "test") t.Setenv("AWS_SECRET_ACCESS_KEY", "test") t.Setenv("AWS_REGION", "auto") t.Setenv("LCHAT_CLOUD_ID_LEDGER", "1") t.Setenv("LCHAT_HIBERNATE_BUCKET", "idbucket") t.Setenv("LCHAT_HIBERNATE_PREFIX", "pfx") t.Setenv("LCHAT_HIBERNATE_ENDPOINT", srv.URL) return srv.URL } // newFakeLedger builds the allocator through newCloudLedger — the real gate — so // the cloud sees exactly the allocator a flagged, bucketed box would construct. func newFakeLedger(t *testing.T) *idalloc.Allocator { t.Helper() ledgerEnv(t) a, err := newCloudLedger(context.Background()) if err != nil { t.Fatalf("newCloudLedger: %v", err) } if a == nil { t.Fatal("newCloudLedger returned nil with the flag + bucket set") } return a } // hostLabelID cuts the sqid label off a host and decodes it to its id. func hostLabelID(t *testing.T, host string) int64 { t.Helper() label, _, ok := strings.Cut(host, ".") if !ok { t.Fatalf("host %q has no label", host) } id, ok := spaceID(label) if !ok { t.Fatalf("label %q of host %q is not a canonical sqid", label, host) } return id } // With the ledger configured, a regular AND a demo create take the hostname id // from the global allocator, not the cloud.db PK — and two creates get distinct, // increasing global ids. The counter is pre-advanced so ledger ids diverge from // the local PK sequence (both start at 1), making the source unambiguous. func TestCloudLedgerHostnamesUseGlobalIds(t *testing.T) { srv, s := newTestCloud(t, okControl(t)) s.demoTTL, s.demoMaxLive = 24*time.Hour, 10 alloc := newFakeLedger(t) ctx := context.Background() for i := 0; i < 5; i++ { if _, err := alloc.Allocate(ctx); err != nil { t.Fatalf("pre-advance %d: %v", i, err) } } // The counter's high-water is now 5, so the server's next draws are 6, 7, 8. s.ids = alloc tok := mintCaller(t, srv, "home") // Two free creates: hostnames decode to the global ids 6 then 7. var hosts []string for i := 0; i < 2; i++ { resp := createSpace(t, srv, tok, "u") if resp.StatusCode != http.StatusCreated { t.Fatalf("free create %d: HTTP %d", i, resp.StatusCode) } hosts = append(hosts, decodeBody(t, resp)["host"].(string)) } if got := hostLabelID(t, hosts[0]); got != 6 { t.Errorf("first ledger hostname id = %d, want 6 (global counter, not PK 1)", got) } if got := hostLabelID(t, hosts[1]); got != 7 { t.Errorf("second ledger hostname id = %d, want 7 (strictly increasing)", got) } // The cloud.db PK is still the LOCAL sequence and decoupled from the address: // the first space's row is PK 1 even though its hostname encodes 6. if pk, _, _, ok, err := s.db.spaceByHost(hosts[0]); err != nil || !ok || pk != 1 { t.Errorf("first space PK = %d (ok=%v err=%v), want local PK 1 — hostname id must not be the PK", pk, ok, err) } // A demo create draws from the same global counter (next is 8). resp := createKind(t, srv, tok, "d", "demo") if resp.StatusCode != http.StatusCreated { t.Fatalf("demo create: HTTP %d", resp.StatusCode) } demoHost := decodeBody(t, resp)["host"].(string) if got := hostLabelID(t, demoHost); got != 8 { t.Errorf("demo ledger hostname id = %d, want 8 (shared global counter)", got) } } // With NO allocator (the default: production, self-hosters, any un-bucketed box), // both the regular and demo paths build the hostname from the cloud.db PK exactly // as today — the hostname id equals the row's PK. func TestCloudNoLedgerUsesLocalPK(t *testing.T) { srv, s := newTestCloud(t, okControl(t)) // s.ids stays nil s.demoTTL, s.demoMaxLive = 24*time.Hour, 10 tok := mintCaller(t, srv, "home") // Fresh DB → PKs 1, 2, …; a free then a demo create both encode their own PK. free := decodeBody(t, mustCreate(t, srv, tok, "u", ""))["host"].(string) demo := decodeBody(t, mustCreate(t, srv, tok, "u", "demo"))["host"].(string) for _, tc := range []struct { name, host string }{{"free", free}, {"demo", demo}} { pk, _, _, ok, err := s.db.spaceByHost(tc.host) if err != nil || !ok { t.Fatalf("%s spaceByHost(%q): ok=%v err=%v", tc.name, tc.host, ok, err) } if got := hostLabelID(t, tc.host); got != pk { t.Errorf("%s hostname id = %d, want cloud.db PK %d (no-ledger path unchanged)", tc.name, got, pk) } } } // Quota + entitlement bookkeeping is untouched when the hostname id comes from // the ledger: the free quota still counts cloud.db rows (not global ids), the // (quota+1)th free create is refused, and a demo still slips past with a demo // entitlement recorded in the ledger. func TestCloudLedgerPreservesQuotaAndEntitlement(t *testing.T) { srv, s := newTestCloud(t, okControl(t)) // harness quota = 3 s.demoTTL, s.demoMaxLive = 24*time.Hour, 10 s.ids = newFakeLedger(t) tok := mintCaller(t, srv, "home") for i := 0; i < s.quota; i++ { if resp := createSpace(t, srv, tok, "bob"); resp.StatusCode != http.StatusCreated { t.Fatalf("free create %d: HTTP %d", i, resp.StatusCode) } } if resp := createSpace(t, srv, tok, "bob"); resp.StatusCode != http.StatusConflict { t.Fatalf("over-quota free create: HTTP %d, want 409 (quota counts cloud.db rows, not ledger ids)", resp.StatusCode) } // A demo bypasses the full free quota, and its ledger row is a demo. resp := createKind(t, srv, tok, "bob", "demo") if resp.StatusCode != http.StatusCreated { t.Fatalf("demo past full quota: HTTP %d", resp.StatusCode) } demoHost := decodeBody(t, resp)["host"].(string) var ent string if err := s.db.sql.QueryRow(`SELECT entitlement FROM spaces WHERE host = ?`, demoHost).Scan(&ent); err != nil || ent != entDemo { t.Fatalf("ledger demo entitlement = %q err=%v, want %q", ent, err, entDemo) } // The demo didn't consume a free slot: a following free create is still // refused at quota, proving the quota ledger is untouched by the ledger path. if resp := createSpace(t, srv, tok, "bob"); resp.StatusCode != http.StatusConflict { t.Fatalf("free create after demo: HTTP %d, want 409 (quota unchanged)", resp.StatusCode) } } // The gate is deliberate: LCHAT_HIBERNATE_BUCKET alone must NOT turn the ledger // on — only the explicit LCHAT_CLOUD_ID_LEDGER opt-in does, and it fails fast if // the shared bucket is missing. This guard is the whole point of the flag. func TestCloudLedgerGate(t *testing.T) { ledgerEnv(t) // sets AWS creds + flag + bucket + endpoint ctx := context.Background() // Bucket present, flag OFF → no ledger (the load-bearing guard). t.Setenv("LCHAT_CLOUD_ID_LEDGER", "") if a, err := newCloudLedger(ctx); err != nil || a != nil { t.Fatalf("bucket set but flag off: got (%v, %v), want (nil, nil) — bucket alone must NOT activate the ledger", a, err) } // Flag ON, bucket MISSING → fail fast, never silent-off. t.Setenv("LCHAT_CLOUD_ID_LEDGER", "1") t.Setenv("LCHAT_HIBERNATE_BUCKET", "") if a, err := newCloudLedger(ctx); err == nil || a != nil { t.Fatalf("flag on, bucket empty: got (%v, %v), want a hard error (fail fast)", a, err) } // Flag ON + bucket → an allocator. t.Setenv("LCHAT_HIBERNATE_BUCKET", "idbucket") if a, err := newCloudLedger(ctx); err != nil || a == nil { t.Fatalf("flag on + bucket: got (%v, %v), want a non-nil allocator", a, err) } } // Behaviour-level guard: with the bucket present but the flag OFF, the cloud // still mints local-PK hostnames — newCloudLedger returns nil, so s.ids stays // nil and nothing draws from the global counter. func TestCloudBucketWithoutFlagUsesLocalPK(t *testing.T) { ledgerEnv(t) t.Setenv("LCHAT_CLOUD_ID_LEDGER", "") // bucket stays set; flag off ids, err := newCloudLedger(context.Background()) if err != nil { t.Fatal(err) } if ids != nil { t.Fatal("bucket alone built an allocator — the flag guard failed") } srv, s := newTestCloud(t, okControl(t)) s.ids = ids // nil tok := mintCaller(t, srv, "home") host := decodeBody(t, mustCreate(t, srv, tok, "u", ""))["host"].(string) pk, _, _, ok, err := s.db.spaceByHost(host) if err != nil || !ok { t.Fatalf("spaceByHost(%q): ok=%v err=%v", host, ok, err) } if got := hostLabelID(t, host); got != pk { t.Errorf("bucket-without-flag hostname id = %d, want cloud.db PK %d (hostnames stay local)", got, pk) } } // mustCreate posts a create (kind "" for free, "demo" for a demo) and fails on // any non-201. func mustCreate(t *testing.T, srv *httptest.Server, token, requester, kind string) *http.Response { t.Helper() var resp *http.Response if kind == "" { resp = createSpace(t, srv, token, requester) } else { resp = createKind(t, srv, token, requester, kind) } if resp.StatusCode != http.StatusCreated { t.Fatalf("create (kind %q): HTTP %d", kind, resp.StatusCode) } return resp }