package main // Demo spaces, slice 1 (demo.go): the kind=demo create branch, the // unconditional TTL reap with discard, and the structural no-backups // guarantees (never hibernated → never replicated; discarded → never // archived). The main reaper's guards are also pinned to NOT apply to demos — // content protects a free space, never a demo. import ( "bytes" "encoding/json" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "sync" "testing" "time" ) // createKind posts a create with an explicit kind. func createKind(t *testing.T, srv *httptest.Server, token, requester, kind string) *http.Response { t.Helper() body, _ := json.Marshal(map[string]string{"requester": requester, "kind": kind}) req, _ := http.NewRequest("POST", srv.URL+"/api/instances", bytes.NewReader(body)) req.Header.Set("Authorization", "Bearer "+token) resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } t.Cleanup(func() { resp.Body.Close() }) return resp } func TestCreateDemoSpace(t *testing.T) { srv, s := newTestCloud(t, okControl(t)) s.demoTTL, s.demoMaxLive = 24*time.Hour, 10 token := mintCaller(t, srv, "home") resp := createKind(t, srv, token, "alice", "demo") if resp.StatusCode != http.StatusCreated { t.Fatalf("demo create: HTTP %d", resp.StatusCode) } body := decodeBody(t, resp) if body["kind"] != "demo" { t.Fatalf("kind = %v", body["kind"]) } exp, ok := body["expires_at"].(float64) if !ok || int64(exp) < time.Now().Add(23*time.Hour).Unix() { t.Fatalf("expires_at = %v, want ~now+24h", body["expires_at"]) } var ent string if err := s.db.sql.QueryRow(`SELECT entitlement FROM spaces WHERE host = ?`, body["host"]).Scan(&ent); err != nil || ent != entDemo { t.Fatalf("ledger entitlement = %q err=%v, want demo", ent, err) } } // Demos neither consume nor are blocked by the free-space quota. func TestDemoSkipsFreeQuota(t *testing.T) { srv, s := newTestCloud(t, okControl(t)) s.demoTTL, s.demoMaxLive = 24*time.Hour, 10 token := mintCaller(t, srv, "home") // Fill the free quota (harness default 3)... for i := 0; i < 3; i++ { if resp := createKind(t, srv, token, "bob", ""); resp.StatusCode != http.StatusCreated { t.Fatalf("free create %d: HTTP %d", i, resp.StatusCode) } } if resp := createKind(t, srv, token, "bob", ""); resp.StatusCode != http.StatusConflict { t.Fatalf("4th free create: HTTP %d, want 409", resp.StatusCode) } // ...a demo still goes through past a full quota... if resp := createKind(t, srv, token, "bob", "demo"); resp.StatusCode != http.StatusCreated { t.Fatalf("demo past full quota: HTTP %d", resp.StatusCode) } // ...and doesn't tighten it further. if resp := createKind(t, srv, token, "bob", ""); resp.StatusCode != http.StatusConflict { t.Fatalf("free create after demo: HTTP %d, want 409 (quota unchanged)", resp.StatusCode) } } func TestDemoDisabledAndBadKind(t *testing.T) { srv, _ := newTestCloud(t, okControl(t)) // demoTTL zero: the shipped default token := mintCaller(t, srv, "home") resp := createKind(t, srv, token, "carol", "demo") if resp.StatusCode != http.StatusNotImplemented { t.Fatalf("demo while disabled: HTTP %d, want 501", resp.StatusCode) } if body := decodeBody(t, resp); body["error"] != "demo_unavailable" { t.Fatalf("error = %v", body["error"]) } if resp := createKind(t, srv, token, "carol", "banana"); resp.StatusCode != http.StatusBadRequest { t.Fatalf("bad kind: HTTP %d, want 400", resp.StatusCode) } } func TestDemoLiveCap(t *testing.T) { srv, s := newTestCloud(t, okControl(t)) s.demoTTL, s.demoMaxLive = 24*time.Hour, 1 token := mintCaller(t, srv, "home") if resp := createKind(t, srv, token, "dave", "demo"); resp.StatusCode != http.StatusCreated { t.Fatalf("first demo: HTTP %d", resp.StatusCode) } resp := createKind(t, srv, token, "erin", "demo") if resp.StatusCode != http.StatusServiceUnavailable { t.Fatalf("capped demo: HTTP %d, want 503", resp.StatusCode) } if body := decodeBody(t, resp); body["error"] != "demo_busy" { t.Fatalf("error = %v", body["error"]) } } // demoControl fakes controld's DELETE endpoint, recording host → discard flag. // Deliberately NO stats route: a demo reap that consulted stats would fail // loudly here — pinning that the reap is unconditional. func demoControl(t *testing.T, deleted map[string]string, mu *sync.Mutex) string { t.Helper() mux := http.NewServeMux() mux.HandleFunc("DELETE /instances/{host}", func(w http.ResponseWriter, r *http.Request) { mu.Lock() deleted[r.PathValue("host")] = r.URL.Query().Get("discard") mu.Unlock() writeJSON(w, http.StatusOK, map[string]any{"ok": true}) }) return fakeControl(t, mux.ServeHTTP) } // seedDemo inserts a demo row directly (finalized unless host == ""). func seedDemo(t *testing.T, db *cloudDB, host string, createdAt int64) int64 { t.Helper() var h any if host != "" { h = host } res, err := db.sql.Exec( `INSERT INTO spaces(host, caller_id, requester, created_at, entitlement) VALUES(?, 'c', 'r', ?, 'demo')`, h, createdAt) if err != nil { t.Fatal(err) } id, _ := res.LastInsertId() return id } func TestDemoReapUnconditionalWithDiscard(t *testing.T) { db := openTestCloudDB(t) now := time.Unix(2_000_000_000, 0) expired := seedDemo(t, db, "old.example.com", now.Add(-25*time.Hour).Unix()) fresh := seedDemo(t, db, "new.example.com", now.Add(-1*time.Hour).Unix()) pending := seedDemo(t, db, "", now.Add(-30*time.Hour).Unix()) deleted := map[string]string{} var mu sync.Mutex s := &cloudServer{db: db, control: controlClient(demoControl(t, deleted, &mu)), controlToken: "t", demoTTL: 24 * time.Hour} if reaped := s.demoReapOnce(now); reaped != 1 { t.Fatalf("reaped = %d, want 1", reaped) } if deleted["old.example.com"] != "1" { t.Fatalf("expired demo not deleted with discard=1: %v", deleted) } if _, ok := deleted["new.example.com"]; ok { t.Fatal("fresh demo was deleted") } for id, want := range map[int64]bool{expired: false, fresh: true, pending: false} { var n int if err := db.sql.QueryRow(`SELECT COUNT(*) FROM spaces WHERE id = ?`, id).Scan(&n); err != nil { t.Fatal(err) } if (n == 1) != want { t.Fatalf("row %d present=%v, want %v", id, n == 1, want) } } } func TestDemoReapDryRun(t *testing.T) { db := openTestCloudDB(t) now := time.Unix(2_000_000_000, 0) seedDemo(t, db, "old.example.com", now.Add(-48*time.Hour).Unix()) deleted := map[string]string{} var mu sync.Mutex s := &cloudServer{db: db, control: controlClient(demoControl(t, deleted, &mu)), controlToken: "t", demoTTL: 24 * time.Hour, reapDryRun: true} if reaped := s.demoReapOnce(now); reaped != 0 { t.Fatalf("dry-run reaped %d", reaped) } if len(deleted) != 0 { t.Fatalf("dry-run called control delete: %v", deleted) } var n int if err := db.sql.QueryRow(`SELECT COUNT(*) FROM spaces`).Scan(&n); err != nil || n != 1 { t.Fatalf("rows = %d err=%v, want 1 untouched", n, err) } } // The MAIN reaper must never see demos: its candidate query is free-only, so // even an ancient demo full of content is not its business (the demo reaper // owns that lifecycle, on its own clock). func TestMainReaperIgnoresDemos(t *testing.T) { db := openTestCloudDB(t) now := time.Unix(2_000_000_000, 0) seedDemo(t, db, "olddemo.example.com", now.Add(-40*24*time.Hour).Unix()) cands, err := db.spacesOlderThan(now.Add(-14 * 24 * time.Hour).Unix()) if err != nil { t.Fatal(err) } if len(cands) != 0 { t.Fatalf("main reaper candidates include a demo: %+v", cands) } } func TestCapForDemo(t *testing.T) { s := &cloudServer{storageCaps: map[string]int64{"free": 2e9, "demo": 25e6}} if got := s.capFor(entDemo, "", 0); got != 25e6 { t.Fatalf("demo cap = %d, want its own 25MB entry", got) } s.storageCaps = map[string]int64{"free": 2e9} if got := s.capFor(entDemo, "", 0); got != 2e9 { t.Fatalf("demo cap without a demo entry = %d, want the free fallback", got) } } // A demo create is never activator-managed, even on a hibernate-by-default // box — the structural half of "no backups": no lease, no replica, no prefix. // (That a FREE create on the same box is born asleep is pinned by the // existing hibernate-creates tests; no wakeOnProbe here because the demo // path is materialized — the fixture's fake systemctl serves its probe.) func TestControldDemoCreateNeverHibernates(t *testing.T) { reg := controlTestEnv(t) c := &controld{reg: reg, tokenHash: hashToken("test-control-token"), maxInstances: 10, minFreeGB: 5, hibernateCreates: true} srv := httptest.NewServer(c.handler()) defer srv.Close() body, _ := json.Marshal(map[string]string{"host": "demo1.example.com", "entitlement": "demo"}) req, _ := http.NewRequest("POST", srv.URL+"/instances", bytes.NewReader(body)) req.Header.Set("Authorization", "Bearer test-control-token") resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } defer resp.Body.Close() if resp.StatusCode != http.StatusCreated { t.Fatalf("demo create: HTTP %d", resp.StatusCode) } rt, err := reg.get("demo1.example.com") if err != nil || rt == nil { t.Fatalf("registry row missing: %v", err) } if rt.Hibernate { t.Fatal("demo create was registered activator-managed — it must never touch S3") } } // discard=1 removes an instance's files outright; the default still archives. func TestControldDeleteDiscard(t *testing.T) { reg := controlTestEnv(t) c := &controld{reg: reg, tokenHash: hashToken("test-control-token"), maxInstances: 10, minFreeGB: 5} srv := httptest.NewServer(c.handler()) defer srv.Close() del := func(host, query string) { t.Helper() req, _ := http.NewRequest("DELETE", srv.URL+"/instances/"+host+query, nil) req.Header.Set("Authorization", "Bearer test-control-token") resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("delete %s%s: HTTP %d", host, query, resp.StatusCode) } } trashEntries := func() []string { entries, _ := os.ReadDir(filepath.Join(instanceDataDir, "trash")) var names []string for _, e := range entries { names = append(names, e.Name()) } return names } if resp := controlCreate(t, srv, "test-control-token", "gone.example.com"); resp.StatusCode != http.StatusCreated { t.Fatalf("create: HTTP %d", resp.StatusCode) } envPath := filepath.Join(instanceDataDir, "gone.example.com.env") if _, err := os.Stat(envPath); err != nil { t.Fatalf("env file missing after create: %v", err) } del("gone.example.com", "?discard=1") if _, err := os.Stat(envPath); !os.IsNotExist(err) { t.Fatalf("discarded env file still exists (err=%v)", err) } for _, name := range trashEntries() { if strings.HasPrefix(name, "gone.example.com-") { t.Fatalf("discard archived to trash anyway: %s", name) } } if resp := controlCreate(t, srv, "test-control-token", "kept.example.com"); resp.StatusCode != http.StatusCreated { t.Fatalf("create: HTTP %d", resp.StatusCode) } del("kept.example.com", "") found := false for _, name := range trashEntries() { if strings.HasPrefix(name, "kept.example.com-") { found = true } } if !found { t.Fatal("default delete did not archive to trash") } } // ---- claiming (the happy exit) ---------------------------------------------- func claimHost(t *testing.T, srv *httptest.Server, token, host, requester string) (*http.Response, map[string]any) { t.Helper() body, _ := json.Marshal(map[string]string{"requester": requester}) req, _ := http.NewRequest("POST", srv.URL+"/api/instances/"+host+"/claim", bytes.NewReader(body)) req.Header.Set("Authorization", "Bearer "+token) resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } t.Cleanup(func() { resp.Body.Close() }) var m map[string]any _ = json.NewDecoder(resp.Body).Decode(&m) return resp, m } func TestClaimDemoSpace(t *testing.T) { srv, s := newTestCloud(t, okControl(t)) s.demoTTL, s.demoMaxLive = 24*time.Hour, 10 token := mintCaller(t, srv, "home") // A fresh demo, claimed: entitlement flips, ownership transfers. resp := createKind(t, srv, token, "demo-oneshot", "demo") if resp.StatusCode != http.StatusCreated { t.Fatalf("demo create: HTTP %d", resp.StatusCode) } host, _ := decodeBody(t, resp)["host"].(string) resp, body := claimHost(t, srv, token, host, "alice") if resp.StatusCode != http.StatusOK || body["entitlement"] != "free" { t.Fatalf("claim: HTTP %d %v", resp.StatusCode, body) } var ent, req string if err := s.db.sql.QueryRow(`SELECT entitlement, requester FROM spaces WHERE host = ?`, host).Scan(&ent, &req); err != nil { t.Fatal(err) } if ent != entFree || req != "alice" { t.Fatalf("row after claim = %s/%s, want free/alice", ent, req) } // The claimed space left the demo lifecycle: the reaper never sees it… cands, err := s.db.demosPastTTL(time.Now().Add(48 * time.Hour).Unix()) if err != nil || len(cands) != 0 { t.Fatalf("claimed space still a reap candidate: %v %v", cands, err) } // …and it consumes the quota: at quota 1, alice can't create another free space. s.quota = 1 if resp := createKind(t, srv, token, "alice", ""); resp.StatusCode != http.StatusConflict { t.Fatalf("free create after claim at quota 1: HTTP %d, want 409", resp.StatusCode) } // A second claim of the same host: no longer a demo. resp, body = claimHost(t, srv, token, host, "bob") if resp.StatusCode != http.StatusConflict || body["error"] != "not_demo" { t.Fatalf("re-claim: HTTP %d %v", resp.StatusCode, body) } } func TestClaimRefusals(t *testing.T) { srv, s := newTestCloud(t, okControl(t)) s.demoTTL, s.demoMaxLive = 24*time.Hour, 10 token := mintCaller(t, srv, "home") // Unknown host. if resp, body := claimHost(t, srv, token, "nope.example.com", "alice"); resp.StatusCode != http.StatusNotFound || body["error"] != "not_found" { t.Fatalf("unknown host: HTTP %d %v", resp.StatusCode, body) } // An expired demo can't be claimed even before the sweep collects it. seedDemo(t, s.db, "stale.example.com", time.Now().Add(-25*time.Hour).Unix()) if resp, body := claimHost(t, srv, token, "stale.example.com", "alice"); resp.StatusCode != http.StatusGone || body["error"] != "demo_expired" { t.Fatalf("expired claim: HTTP %d %v", resp.StatusCode, body) } // A free space isn't claimable. if resp := createKind(t, srv, token, "carol", ""); resp.StatusCode != http.StatusCreated { t.Fatal("free create failed") } var freeHost string if err := s.db.sql.QueryRow(`SELECT host FROM spaces WHERE requester = 'carol'`).Scan(&freeHost); err != nil { t.Fatal(err) } if resp, body := claimHost(t, srv, token, freeHost, "alice"); resp.StatusCode != http.StatusConflict || body["error"] != "not_demo" { t.Fatalf("claim a free space: HTTP %d %v", resp.StatusCode, body) } // A quota-full claimer is refused with the standard message. s.quota = 1 if resp := createKind(t, srv, token, "dave", ""); resp.StatusCode != http.StatusCreated { t.Fatal("dave's free create failed") } seedDemo(t, s.db, "fresh.example.com", time.Now().Unix()) if resp, body := claimHost(t, srv, token, "fresh.example.com", "dave"); resp.StatusCode != http.StatusConflict || body["error"] != "quota_exceeded" { t.Fatalf("quota-full claim: HTTP %d %v", resp.StatusCode, body) } } // ---- the countdown banner's live status read --------------------------------- func demoStatus(t *testing.T, srv *httptest.Server, token, host string) (*http.Response, map[string]any) { t.Helper() req, _ := http.NewRequest("GET", srv.URL+"/api/instances/"+host+"/demo-status", nil) req.Header.Set("Authorization", "Bearer "+token) resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } t.Cleanup(func() { resp.Body.Close() }) var m map[string]any _ = json.NewDecoder(resp.Body).Decode(&m) return resp, m } func TestDemoStatus(t *testing.T) { srv, s := newTestCloud(t, okControl(t)) s.demoTTL, s.demoMaxLive = 24*time.Hour, 10 token := mintCaller(t, srv, "home") // A live demo reports demo:true with its death-date. resp := createKind(t, srv, token, "demo-status", "demo") if resp.StatusCode != http.StatusCreated { t.Fatalf("demo create: HTTP %d", resp.StatusCode) } host, _ := decodeBody(t, resp)["host"].(string) resp, body := demoStatus(t, srv, token, host) if resp.StatusCode != http.StatusOK || body["demo"] != true { t.Fatalf("live demo status: HTTP %d %v", resp.StatusCode, body) } exp, ok := body["expires_at"].(float64) if !ok || int64(exp) < time.Now().Add(23*time.Hour).Unix() { t.Fatalf("expires_at = %v, want ~now+24h", body["expires_at"]) } // Claimed → one indistinguishable {demo:false}, same as unknown and free // hosts: the read discloses demo-ness only, never existence. if resp, _ := claimHost(t, srv, token, host, "alice"); resp.StatusCode != http.StatusOK { t.Fatalf("claim: HTTP %d", resp.StatusCode) } for _, h := range []string{host, "nope.example.com"} { resp, body := demoStatus(t, srv, token, h) if resp.StatusCode != http.StatusOK || body["demo"] != false { t.Fatalf("status for %s: HTTP %d %v, want 200 demo:false", h, resp.StatusCode, body) } if _, present := body["expires_at"]; present { t.Fatalf("status for %s leaked expires_at: %v", h, body) } } // An expired-but-unswept demo still reads demo:true, expiry in the past — // the banner's "this demo has ended" state. seedDemo(t, s.db, "stale.example.com", time.Now().Add(-25*time.Hour).Unix()) resp, body = demoStatus(t, srv, token, "stale.example.com") if resp.StatusCode != http.StatusOK || body["demo"] != true { t.Fatalf("expired status: HTTP %d %v", resp.StatusCode, body) } if exp, _ := body["expires_at"].(float64); int64(exp) >= time.Now().Unix() { t.Fatalf("expired demo's expires_at not in the past: %v", body["expires_at"]) } // TTL since unset (demos switched off, row survives): demo:true with the // expiry left unknown rather than invented. s.demoTTL = 0 resp, body = demoStatus(t, srv, token, "stale.example.com") if resp.StatusCode != http.StatusOK || body["demo"] != true { t.Fatalf("no-TTL status: HTTP %d %v", resp.StatusCode, body) } if _, present := body["expires_at"]; present { t.Fatalf("no-TTL status invented an expiry: %v", body) } }