eleven / messenger Public

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

Plain git — no account needed to clone.

Download

Download this file

1package main
2
3// Demo spaces, slice 1 (demo.go): the kind=demo create branch, the
4// unconditional TTL reap with discard, and the structural no-backups
5// guarantees (never hibernated → never replicated; discarded → never
6// archived). The main reaper's guards are also pinned to NOT apply to demos —
7// content protects a free space, never a demo.
8
9import (
10 "bytes"
11 "encoding/json"
12 "net/http"
13 "net/http/httptest"
14 "os"
15 "path/filepath"
16 "strings"
17 "sync"
18 "testing"
19 "time"
20)
21
22// createKind posts a create with an explicit kind.
23func createKind(t *testing.T, srv *httptest.Server, token, requester, kind string) *http.Response {
24 t.Helper()
25 body, _ := json.Marshal(map[string]string{"requester": requester, "kind": kind})
26 req, _ := http.NewRequest("POST", srv.URL+"/api/instances", bytes.NewReader(body))
27 req.Header.Set("Authorization", "Bearer "+token)
28 resp, err := http.DefaultClient.Do(req)
29 if err != nil {
30 t.Fatal(err)
31 }
32 t.Cleanup(func() { resp.Body.Close() })
33 return resp
34}
35
36func TestCreateDemoSpace(t *testing.T) {
37 srv, s := newTestCloud(t, okControl(t))
38 s.demoTTL, s.demoMaxLive = 24*time.Hour, 10
39 token := mintCaller(t, srv, "home")
40
41 resp := createKind(t, srv, token, "alice", "demo")
42 if resp.StatusCode != http.StatusCreated {
43 t.Fatalf("demo create: HTTP %d", resp.StatusCode)
44 }
45 body := decodeBody(t, resp)
46 if body["kind"] != "demo" {
47 t.Fatalf("kind = %v", body["kind"])
48 }
49 exp, ok := body["expires_at"].(float64)
50 if !ok || int64(exp) < time.Now().Add(23*time.Hour).Unix() {
51 t.Fatalf("expires_at = %v, want ~now+24h", body["expires_at"])
52 }
53 var ent string
54 if err := s.db.sql.QueryRow(`SELECT entitlement FROM spaces WHERE host = ?`, body["host"]).Scan(&ent); err != nil || ent != entDemo {
55 t.Fatalf("ledger entitlement = %q err=%v, want demo", ent, err)
56 }
57}
58
59// Demos neither consume nor are blocked by the free-space quota.
60func TestDemoSkipsFreeQuota(t *testing.T) {
61 srv, s := newTestCloud(t, okControl(t))
62 s.demoTTL, s.demoMaxLive = 24*time.Hour, 10
63 token := mintCaller(t, srv, "home")
64
65 // Fill the free quota (harness default 3)...
66 for i := 0; i < 3; i++ {
67 if resp := createKind(t, srv, token, "bob", ""); resp.StatusCode != http.StatusCreated {
68 t.Fatalf("free create %d: HTTP %d", i, resp.StatusCode)
69 }
70 }
71 if resp := createKind(t, srv, token, "bob", ""); resp.StatusCode != http.StatusConflict {
72 t.Fatalf("4th free create: HTTP %d, want 409", resp.StatusCode)
73 }
74 // ...a demo still goes through past a full quota...
75 if resp := createKind(t, srv, token, "bob", "demo"); resp.StatusCode != http.StatusCreated {
76 t.Fatalf("demo past full quota: HTTP %d", resp.StatusCode)
77 }
78 // ...and doesn't tighten it further.
79 if resp := createKind(t, srv, token, "bob", ""); resp.StatusCode != http.StatusConflict {
80 t.Fatalf("free create after demo: HTTP %d, want 409 (quota unchanged)", resp.StatusCode)
81 }
82}
83
84func TestDemoDisabledAndBadKind(t *testing.T) {
85 srv, _ := newTestCloud(t, okControl(t)) // demoTTL zero: the shipped default
86 token := mintCaller(t, srv, "home")
87
88 resp := createKind(t, srv, token, "carol", "demo")
89 if resp.StatusCode != http.StatusNotImplemented {
90 t.Fatalf("demo while disabled: HTTP %d, want 501", resp.StatusCode)
91 }
92 if body := decodeBody(t, resp); body["error"] != "demo_unavailable" {
93 t.Fatalf("error = %v", body["error"])
94 }
95 if resp := createKind(t, srv, token, "carol", "banana"); resp.StatusCode != http.StatusBadRequest {
96 t.Fatalf("bad kind: HTTP %d, want 400", resp.StatusCode)
97 }
98}
99
100func TestDemoLiveCap(t *testing.T) {
101 srv, s := newTestCloud(t, okControl(t))
102 s.demoTTL, s.demoMaxLive = 24*time.Hour, 1
103 token := mintCaller(t, srv, "home")
104
105 if resp := createKind(t, srv, token, "dave", "demo"); resp.StatusCode != http.StatusCreated {
106 t.Fatalf("first demo: HTTP %d", resp.StatusCode)
107 }
108 resp := createKind(t, srv, token, "erin", "demo")
109 if resp.StatusCode != http.StatusServiceUnavailable {
110 t.Fatalf("capped demo: HTTP %d, want 503", resp.StatusCode)
111 }
112 if body := decodeBody(t, resp); body["error"] != "demo_busy" {
113 t.Fatalf("error = %v", body["error"])
114 }
115}
116
117// demoControl fakes controld's DELETE endpoint, recording host → discard flag.
118// Deliberately NO stats route: a demo reap that consulted stats would fail
119// loudly here — pinning that the reap is unconditional.
120func demoControl(t *testing.T, deleted map[string]string, mu *sync.Mutex) string {
121 t.Helper()
122 mux := http.NewServeMux()
123 mux.HandleFunc("DELETE /instances/{host}", func(w http.ResponseWriter, r *http.Request) {
124 mu.Lock()
125 deleted[r.PathValue("host")] = r.URL.Query().Get("discard")
126 mu.Unlock()
127 writeJSON(w, http.StatusOK, map[string]any{"ok": true})
128 })
129 return fakeControl(t, mux.ServeHTTP)
130}
131
132// seedDemo inserts a demo row directly (finalized unless host == "").
133func seedDemo(t *testing.T, db *cloudDB, host string, createdAt int64) int64 {
134 t.Helper()
135 var h any
136 if host != "" {
137 h = host
138 }
139 res, err := db.sql.Exec(
140 `INSERT INTO spaces(host, caller_id, requester, created_at, entitlement) VALUES(?, 'c', 'r', ?, 'demo')`,
141 h, createdAt)
142 if err != nil {
143 t.Fatal(err)
144 }
145 id, _ := res.LastInsertId()
146 return id
147}
148
149func TestDemoReapUnconditionalWithDiscard(t *testing.T) {
150 db := openTestCloudDB(t)
151 now := time.Unix(2_000_000_000, 0)
152 expired := seedDemo(t, db, "old.example.com", now.Add(-25*time.Hour).Unix())
153 fresh := seedDemo(t, db, "new.example.com", now.Add(-1*time.Hour).Unix())
154 pending := seedDemo(t, db, "", now.Add(-30*time.Hour).Unix())
155
156 deleted := map[string]string{}
157 var mu sync.Mutex
158 s := &cloudServer{db: db, control: controlClient(demoControl(t, deleted, &mu)), controlToken: "t", demoTTL: 24 * time.Hour}
159
160 if reaped := s.demoReapOnce(now); reaped != 1 {
161 t.Fatalf("reaped = %d, want 1", reaped)
162 }
163 if deleted["old.example.com"] != "1" {
164 t.Fatalf("expired demo not deleted with discard=1: %v", deleted)
165 }
166 if _, ok := deleted["new.example.com"]; ok {
167 t.Fatal("fresh demo was deleted")
168 }
169 for id, want := range map[int64]bool{expired: false, fresh: true, pending: false} {
170 var n int
171 if err := db.sql.QueryRow(`SELECT COUNT(*) FROM spaces WHERE id = ?`, id).Scan(&n); err != nil {
172 t.Fatal(err)
173 }
174 if (n == 1) != want {
175 t.Fatalf("row %d present=%v, want %v", id, n == 1, want)
176 }
177 }
178}
179
180func TestDemoReapDryRun(t *testing.T) {
181 db := openTestCloudDB(t)
182 now := time.Unix(2_000_000_000, 0)
183 seedDemo(t, db, "old.example.com", now.Add(-48*time.Hour).Unix())
184
185 deleted := map[string]string{}
186 var mu sync.Mutex
187 s := &cloudServer{db: db, control: controlClient(demoControl(t, deleted, &mu)), controlToken: "t",
188 demoTTL: 24 * time.Hour, reapDryRun: true}
189 if reaped := s.demoReapOnce(now); reaped != 0 {
190 t.Fatalf("dry-run reaped %d", reaped)
191 }
192 if len(deleted) != 0 {
193 t.Fatalf("dry-run called control delete: %v", deleted)
194 }
195 var n int
196 if err := db.sql.QueryRow(`SELECT COUNT(*) FROM spaces`).Scan(&n); err != nil || n != 1 {
197 t.Fatalf("rows = %d err=%v, want 1 untouched", n, err)
198 }
199}
200
201// The MAIN reaper must never see demos: its candidate query is free-only, so
202// even an ancient demo full of content is not its business (the demo reaper
203// owns that lifecycle, on its own clock).
204func TestMainReaperIgnoresDemos(t *testing.T) {
205 db := openTestCloudDB(t)
206 now := time.Unix(2_000_000_000, 0)
207 seedDemo(t, db, "olddemo.example.com", now.Add(-40*24*time.Hour).Unix())
208
209 cands, err := db.spacesOlderThan(now.Add(-14 * 24 * time.Hour).Unix())
210 if err != nil {
211 t.Fatal(err)
212 }
213 if len(cands) != 0 {
214 t.Fatalf("main reaper candidates include a demo: %+v", cands)
215 }
216}
217
218func TestCapForDemo(t *testing.T) {
219 s := &cloudServer{storageCaps: map[string]int64{"free": 2e9, "demo": 25e6}}
220 if got := s.capFor(entDemo, "", 0); got != 25e6 {
221 t.Fatalf("demo cap = %d, want its own 25MB entry", got)
222 }
223 s.storageCaps = map[string]int64{"free": 2e9}
224 if got := s.capFor(entDemo, "", 0); got != 2e9 {
225 t.Fatalf("demo cap without a demo entry = %d, want the free fallback", got)
226 }
227}
228
229// A demo create is never activator-managed, even on a hibernate-by-default
230// box — the structural half of "no backups": no lease, no replica, no prefix.
231// (That a FREE create on the same box is born asleep is pinned by the
232// existing hibernate-creates tests; no wakeOnProbe here because the demo
233// path is materialized — the fixture's fake systemctl serves its probe.)
234func TestControldDemoCreateNeverHibernates(t *testing.T) {
235 reg := controlTestEnv(t)
236 c := &controld{reg: reg, tokenHash: hashToken("test-control-token"), maxInstances: 10, minFreeGB: 5,
237 hibernateCreates: true}
238 srv := httptest.NewServer(c.handler())
239 defer srv.Close()
240
241 body, _ := json.Marshal(map[string]string{"host": "demo1.example.com", "entitlement": "demo"})
242 req, _ := http.NewRequest("POST", srv.URL+"/instances", bytes.NewReader(body))
243 req.Header.Set("Authorization", "Bearer test-control-token")
244 resp, err := http.DefaultClient.Do(req)
245 if err != nil {
246 t.Fatal(err)
247 }
248 defer resp.Body.Close()
249 if resp.StatusCode != http.StatusCreated {
250 t.Fatalf("demo create: HTTP %d", resp.StatusCode)
251 }
252 rt, err := reg.get("demo1.example.com")
253 if err != nil || rt == nil {
254 t.Fatalf("registry row missing: %v", err)
255 }
256 if rt.Hibernate {
257 t.Fatal("demo create was registered activator-managed — it must never touch S3")
258 }
259}
260
261// discard=1 removes an instance's files outright; the default still archives.
262func TestControldDeleteDiscard(t *testing.T) {
263 reg := controlTestEnv(t)
264 c := &controld{reg: reg, tokenHash: hashToken("test-control-token"), maxInstances: 10, minFreeGB: 5}
265 srv := httptest.NewServer(c.handler())
266 defer srv.Close()
267
268 del := func(host, query string) {
269 t.Helper()
270 req, _ := http.NewRequest("DELETE", srv.URL+"/instances/"+host+query, nil)
271 req.Header.Set("Authorization", "Bearer test-control-token")
272 resp, err := http.DefaultClient.Do(req)
273 if err != nil {
274 t.Fatal(err)
275 }
276 defer resp.Body.Close()
277 if resp.StatusCode != http.StatusOK {
278 t.Fatalf("delete %s%s: HTTP %d", host, query, resp.StatusCode)
279 }
280 }
281 trashEntries := func() []string {
282 entries, _ := os.ReadDir(filepath.Join(instanceDataDir, "trash"))
283 var names []string
284 for _, e := range entries {
285 names = append(names, e.Name())
286 }
287 return names
288 }
289
290 if resp := controlCreate(t, srv, "test-control-token", "gone.example.com"); resp.StatusCode != http.StatusCreated {
291 t.Fatalf("create: HTTP %d", resp.StatusCode)
292 }
293 envPath := filepath.Join(instanceDataDir, "gone.example.com.env")
294 if _, err := os.Stat(envPath); err != nil {
295 t.Fatalf("env file missing after create: %v", err)
296 }
297 del("gone.example.com", "?discard=1")
298 if _, err := os.Stat(envPath); !os.IsNotExist(err) {
299 t.Fatalf("discarded env file still exists (err=%v)", err)
300 }
301 for _, name := range trashEntries() {
302 if strings.HasPrefix(name, "gone.example.com-") {
303 t.Fatalf("discard archived to trash anyway: %s", name)
304 }
305 }
306
307 if resp := controlCreate(t, srv, "test-control-token", "kept.example.com"); resp.StatusCode != http.StatusCreated {
308 t.Fatalf("create: HTTP %d", resp.StatusCode)
309 }
310 del("kept.example.com", "")
311 found := false
312 for _, name := range trashEntries() {
313 if strings.HasPrefix(name, "kept.example.com-") {
314 found = true
315 }
316 }
317 if !found {
318 t.Fatal("default delete did not archive to trash")
319 }
320}
321
322// ---- claiming (the happy exit) ----------------------------------------------
323
324func claimHost(t *testing.T, srv *httptest.Server, token, host, requester string) (*http.Response, map[string]any) {
325 t.Helper()
326 body, _ := json.Marshal(map[string]string{"requester": requester})
327 req, _ := http.NewRequest("POST", srv.URL+"/api/instances/"+host+"/claim", bytes.NewReader(body))
328 req.Header.Set("Authorization", "Bearer "+token)
329 resp, err := http.DefaultClient.Do(req)
330 if err != nil {
331 t.Fatal(err)
332 }
333 t.Cleanup(func() { resp.Body.Close() })
334 var m map[string]any
335 _ = json.NewDecoder(resp.Body).Decode(&m)
336 return resp, m
337}
338
339func TestClaimDemoSpace(t *testing.T) {
340 srv, s := newTestCloud(t, okControl(t))
341 s.demoTTL, s.demoMaxLive = 24*time.Hour, 10
342 token := mintCaller(t, srv, "home")
343
344 // A fresh demo, claimed: entitlement flips, ownership transfers.
345 resp := createKind(t, srv, token, "demo-oneshot", "demo")
346 if resp.StatusCode != http.StatusCreated {
347 t.Fatalf("demo create: HTTP %d", resp.StatusCode)
348 }
349 host, _ := decodeBody(t, resp)["host"].(string)
350 resp, body := claimHost(t, srv, token, host, "alice")
351 if resp.StatusCode != http.StatusOK || body["entitlement"] != "free" {
352 t.Fatalf("claim: HTTP %d %v", resp.StatusCode, body)
353 }
354 var ent, req string
355 if err := s.db.sql.QueryRow(`SELECT entitlement, requester FROM spaces WHERE host = ?`, host).Scan(&ent, &req); err != nil {
356 t.Fatal(err)
357 }
358 if ent != entFree || req != "alice" {
359 t.Fatalf("row after claim = %s/%s, want free/alice", ent, req)
360 }
361
362 // The claimed space left the demo lifecycle: the reaper never sees it…
363 cands, err := s.db.demosPastTTL(time.Now().Add(48 * time.Hour).Unix())
364 if err != nil || len(cands) != 0 {
365 t.Fatalf("claimed space still a reap candidate: %v %v", cands, err)
366 }
367 // …and it consumes the quota: at quota 1, alice can't create another free space.
368 s.quota = 1
369 if resp := createKind(t, srv, token, "alice", ""); resp.StatusCode != http.StatusConflict {
370 t.Fatalf("free create after claim at quota 1: HTTP %d, want 409", resp.StatusCode)
371 }
372
373 // A second claim of the same host: no longer a demo.
374 resp, body = claimHost(t, srv, token, host, "bob")
375 if resp.StatusCode != http.StatusConflict || body["error"] != "not_demo" {
376 t.Fatalf("re-claim: HTTP %d %v", resp.StatusCode, body)
377 }
378}
379
380func TestClaimRefusals(t *testing.T) {
381 srv, s := newTestCloud(t, okControl(t))
382 s.demoTTL, s.demoMaxLive = 24*time.Hour, 10
383 token := mintCaller(t, srv, "home")
384
385 // Unknown host.
386 if resp, body := claimHost(t, srv, token, "nope.example.com", "alice"); resp.StatusCode != http.StatusNotFound || body["error"] != "not_found" {
387 t.Fatalf("unknown host: HTTP %d %v", resp.StatusCode, body)
388 }
389
390 // An expired demo can't be claimed even before the sweep collects it.
391 seedDemo(t, s.db, "stale.example.com", time.Now().Add(-25*time.Hour).Unix())
392 if resp, body := claimHost(t, srv, token, "stale.example.com", "alice"); resp.StatusCode != http.StatusGone || body["error"] != "demo_expired" {
393 t.Fatalf("expired claim: HTTP %d %v", resp.StatusCode, body)
394 }
395
396 // A free space isn't claimable.
397 if resp := createKind(t, srv, token, "carol", ""); resp.StatusCode != http.StatusCreated {
398 t.Fatal("free create failed")
399 }
400 var freeHost string
401 if err := s.db.sql.QueryRow(`SELECT host FROM spaces WHERE requester = 'carol'`).Scan(&freeHost); err != nil {
402 t.Fatal(err)
403 }
404 if resp, body := claimHost(t, srv, token, freeHost, "alice"); resp.StatusCode != http.StatusConflict || body["error"] != "not_demo" {
405 t.Fatalf("claim a free space: HTTP %d %v", resp.StatusCode, body)
406 }
407
408 // A quota-full claimer is refused with the standard message.
409 s.quota = 1
410 if resp := createKind(t, srv, token, "dave", ""); resp.StatusCode != http.StatusCreated {
411 t.Fatal("dave's free create failed")
412 }
413 seedDemo(t, s.db, "fresh.example.com", time.Now().Unix())
414 if resp, body := claimHost(t, srv, token, "fresh.example.com", "dave"); resp.StatusCode != http.StatusConflict || body["error"] != "quota_exceeded" {
415 t.Fatalf("quota-full claim: HTTP %d %v", resp.StatusCode, body)
416 }
417}
418
419// ---- the countdown banner's live status read ---------------------------------
420
421func demoStatus(t *testing.T, srv *httptest.Server, token, host string) (*http.Response, map[string]any) {
422 t.Helper()
423 req, _ := http.NewRequest("GET", srv.URL+"/api/instances/"+host+"/demo-status", nil)
424 req.Header.Set("Authorization", "Bearer "+token)
425 resp, err := http.DefaultClient.Do(req)
426 if err != nil {
427 t.Fatal(err)
428 }
429 t.Cleanup(func() { resp.Body.Close() })
430 var m map[string]any
431 _ = json.NewDecoder(resp.Body).Decode(&m)
432 return resp, m
433}
434
435func TestDemoStatus(t *testing.T) {
436 srv, s := newTestCloud(t, okControl(t))
437 s.demoTTL, s.demoMaxLive = 24*time.Hour, 10
438 token := mintCaller(t, srv, "home")
439
440 // A live demo reports demo:true with its death-date.
441 resp := createKind(t, srv, token, "demo-status", "demo")
442 if resp.StatusCode != http.StatusCreated {
443 t.Fatalf("demo create: HTTP %d", resp.StatusCode)
444 }
445 host, _ := decodeBody(t, resp)["host"].(string)
446 resp, body := demoStatus(t, srv, token, host)
447 if resp.StatusCode != http.StatusOK || body["demo"] != true {
448 t.Fatalf("live demo status: HTTP %d %v", resp.StatusCode, body)
449 }
450 exp, ok := body["expires_at"].(float64)
451 if !ok || int64(exp) < time.Now().Add(23*time.Hour).Unix() {
452 t.Fatalf("expires_at = %v, want ~now+24h", body["expires_at"])
453 }
454
455 // Claimed → one indistinguishable {demo:false}, same as unknown and free
456 // hosts: the read discloses demo-ness only, never existence.
457 if resp, _ := claimHost(t, srv, token, host, "alice"); resp.StatusCode != http.StatusOK {
458 t.Fatalf("claim: HTTP %d", resp.StatusCode)
459 }
460 for _, h := range []string{host, "nope.example.com"} {
461 resp, body := demoStatus(t, srv, token, h)
462 if resp.StatusCode != http.StatusOK || body["demo"] != false {
463 t.Fatalf("status for %s: HTTP %d %v, want 200 demo:false", h, resp.StatusCode, body)
464 }
465 if _, present := body["expires_at"]; present {
466 t.Fatalf("status for %s leaked expires_at: %v", h, body)
467 }
468 }
469
470 // An expired-but-unswept demo still reads demo:true, expiry in the past —
471 // the banner's "this demo has ended" state.
472 seedDemo(t, s.db, "stale.example.com", time.Now().Add(-25*time.Hour).Unix())
473 resp, body = demoStatus(t, srv, token, "stale.example.com")
474 if resp.StatusCode != http.StatusOK || body["demo"] != true {
475 t.Fatalf("expired status: HTTP %d %v", resp.StatusCode, body)
476 }
477 if exp, _ := body["expires_at"].(float64); int64(exp) >= time.Now().Unix() {
478 t.Fatalf("expired demo's expires_at not in the past: %v", body["expires_at"])
479 }
480
481 // TTL since unset (demos switched off, row survives): demo:true with the
482 // expiry left unknown rather than invented.
483 s.demoTTL = 0
484 resp, body = demoStatus(t, srv, token, "stale.example.com")
485 if resp.StatusCode != http.StatusOK || body["demo"] != true {
486 t.Fatalf("no-TTL status: HTTP %d %v", resp.StatusCode, body)
487 }
488 if _, present := body["expires_at"]; present {
489 t.Fatalf("no-TTL status invented an expiry: %v", body)
490 }
491}
492