eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1package 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
10import (
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.
27type fakeLedgerS3 struct {
28 body []byte
29 etag string
30 present bool
31 seq int
32}
33
34func (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
65func (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).
75func 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.
92func 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.
106func 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.
123func 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.
172func 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.
197func 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.
231func 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.
258func 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.
284func 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