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
}