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
3import (
4 "encoding/json"
5 "fmt"
6 "net/http"
7 "net/http/httptest"
8 "os"
9 "path/filepath"
10 "strconv"
11 "strings"
12 "testing"
13 "time"
14)
15
16// ---- trash purge --------------------------------------------------------------
17
18func TestTrashDirTime(t *testing.T) {
19 // The "<host>-<unix>" suffix wins when present.
20 got := trashDirTime("cnz.11ch.at-1700000000", nil)
21 if want := time.Unix(1700000000, 0); !got.Equal(want) {
22 t.Errorf("suffix parse = %v, want %v", got, want)
23 }
24 // No parseable suffix → mtime fallback (a zero time when info is nil).
25 if !trashDirTime("weird-name-no-unix-xyz", nil).IsZero() {
26 t.Error("unparseable name with nil info should be zero time")
27 }
28}
29
30func TestPurgeTrash(t *testing.T) {
31 dir := t.TempDir()
32 now := time.Unix(1_800_000_000, 0)
33 mk := func(name string) { //nolint
34 if err := os.Mkdir(filepath.Join(dir, name), 0o700); err != nil {
35 t.Fatal(err)
36 }
37 }
38 old := now.Add(-40 * 24 * time.Hour).Unix()
39 fresh := now.Add(-2 * 24 * time.Hour).Unix()
40 mk("old.11ch.at-" + itoa(old)) // 40 days → purged
41 mk("fresh.11ch.at-" + itoa(fresh)) // 2 days → kept
42 // a stray file (not a dir) must be ignored
43 if err := os.WriteFile(filepath.Join(dir, "note.txt"), []byte("x"), 0o600); err != nil {
44 t.Fatal(err)
45 }
46
47 removed, err := purgeTrash(dir, 30*24*time.Hour, now)
48 if err != nil {
49 t.Fatal(err)
50 }
51 if removed != 1 {
52 t.Errorf("removed = %d, want 1", removed)
53 }
54 if _, err := os.Stat(filepath.Join(dir, "old.11ch.at-"+itoa(old))); !os.IsNotExist(err) {
55 t.Error("old trash should be gone")
56 }
57 if _, err := os.Stat(filepath.Join(dir, "fresh.11ch.at-"+itoa(fresh))); err != nil {
58 t.Error("fresh trash should remain")
59 }
60
61 // A missing trash dir is fine (nothing to purge).
62 if n, err := purgeTrash(filepath.Join(dir, "does-not-exist"), time.Hour, now); err != nil || n != 0 {
63 t.Errorf("missing dir: n=%d err=%v", n, err)
64 }
65}
66
67// ---- ledger query -------------------------------------------------------------
68
69func TestSpacesOlderThan(t *testing.T) {
70 db := openTestCloudDB(t)
71 insertSpaceRow(t, db, "old.11ch.at", 1000)
72 insertSpaceRow(t, db, "mid.11ch.at", 2000)
73 insertSpaceRow(t, db, "new.11ch.at", 5000)
74 // a pending create (NULL host) is never a candidate
75 if _, err := db.sql.Exec(`INSERT INTO spaces(host, caller_id, requester, created_at) VALUES(NULL,'c','r',1)`); err != nil {
76 t.Fatal(err)
77 }
78
79 got, err := db.spacesOlderThan(2000)
80 if err != nil {
81 t.Fatal(err)
82 }
83 if len(got) != 2 {
84 t.Fatalf("candidates = %d, want 2 (old, mid)", len(got))
85 }
86 if got[0].Host != "old.11ch.at" || got[1].Host != "mid.11ch.at" {
87 t.Errorf("order = %s, %s (want old, mid, oldest first)", got[0].Host, got[1].Host)
88 }
89}
90
91// ---- the reaper ---------------------------------------------------------------
92
93func TestReapOnceReclaimsOnlyOldEmpties(t *testing.T) {
94 db := openTestCloudDB(t)
95 now := time.Unix(2_000_000_000, 0)
96 old := now.Add(-20 * 24 * time.Hour).Unix()
97 recent := now.Add(-1 * 24 * time.Hour).Unix()
98 // Both have the 1 pre-seeded admin (realistic); setup_complete is the signal.
99 unclaimedID := insertSpaceRow(t, db, "unclaimed.11ch.at", old) // old + never set up → reaped
100 claimedID := insertSpaceRow(t, db, "used.11ch.at", old) // old + set up → kept
101 insertSpaceRow(t, db, "young.11ch.at", recent) // too new → not a candidate
102
103 deleted := map[string]bool{}
104 sock := reapControl(t,
105 map[string]int{"unclaimed.11ch.at": 1, "used.11ch.at": 1},
106 map[string]bool{"used.11ch.at": true}, // only used.* completed setup
107 nil, // neither has content
108 deleted, nil)
109 s := &cloudServer{db: db, control: controlClient(sock), controlToken: "t", reapAfter: 14 * 24 * time.Hour}
110
111 reaped, kept := s.reapOnce(now)
112 if reaped != 1 || kept != 1 {
113 t.Fatalf("reaped=%d kept=%d, want 1/1", reaped, kept)
114 }
115 if !deleted["unclaimed.11ch.at"] {
116 t.Error("the old never-set-up space should have been deleted")
117 }
118 if deleted["used.11ch.at"] {
119 t.Error("a space that completed setup must NEVER be reaped")
120 }
121 // ledger: unclaimed gone, used + young remain
122 if _, ok, _ := db.spaceOwnedByID(t, unclaimedID); ok {
123 t.Error("reaped space should be cleared from the ledger")
124 }
125 if _, ok, _ := db.spaceOwnedByID(t, claimedID); !ok {
126 t.Error("kept space should remain in the ledger")
127 }
128}
129
130func TestReapOnceKeepsSpaceWithContent(t *testing.T) {
131 db := openTestCloudDB(t)
132 now := time.Unix(2_000_000_000, 0)
133 old := now.Add(-20 * 24 * time.Hour).Unix()
134 id := insertSpaceRow(t, db, "hascontent.11ch.at", old) // old + never set up + 1 member, BUT has content
135
136 deleted := map[string]bool{}
137 sock := reapControl(t,
138 map[string]int{"hascontent.11ch.at": 1},
139 nil, // never set up
140 map[string]bool{"hascontent.11ch.at": true}, // ...but has a message/thread
141 deleted, nil)
142 s := &cloudServer{db: db, control: controlClient(sock), controlToken: "t", reapAfter: 14 * 24 * time.Hour}
143
144 reaped, kept := s.reapOnce(now)
145 if reaped != 0 || kept != 1 {
146 t.Fatalf("reaped=%d kept=%d, want 0/1 (content protects it)", reaped, kept)
147 }
148 if deleted["hascontent.11ch.at"] {
149 t.Error("a space with content must NEVER be reaped")
150 }
151 if _, ok, _ := db.spaceOwnedByID(t, id); !ok {
152 t.Error("a space with content should remain in the ledger")
153 }
154}
155
156func TestReapOnceCapsPerSweep(t *testing.T) {
157 db := openTestCloudDB(t)
158 now := time.Unix(2_000_000_000, 0)
159 old := now.Add(-20 * 24 * time.Hour).Unix()
160 members := map[string]int{}
161 for i := 0; i < 5; i++ { // 5 reapable candidates
162 h := "dead" + strconv.Itoa(i) + ".11ch.at"
163 insertSpaceRow(t, db, h, old)
164 members[h] = 1 // all never-set-up, 1 member, no content
165 }
166
167 deleted := map[string]bool{}
168 sock := reapControl(t, members, nil, nil, deleted, nil)
169 s := &cloudServer{db: db, control: controlClient(sock), controlToken: "t", reapAfter: 14 * 24 * time.Hour, reapMax: 2}
170
171 reaped, _ := s.reapOnce(now)
172 if reaped != 2 || len(deleted) != 2 {
173 t.Fatalf("reaped=%d deleted=%d, want 2/2 (per-sweep cap)", reaped, len(deleted))
174 }
175 // the other 3 survive this sweep, to be handled by the next one
176 if remaining, _ := db.spacesOlderThan(now.Unix()); len(remaining) != 3 {
177 t.Errorf("remaining candidates = %d, want 3 (deferred, not lost)", len(remaining))
178 }
179}
180
181func TestReapOnceDryRunDeletesNothing(t *testing.T) {
182 db := openTestCloudDB(t)
183 now := time.Unix(2_000_000_000, 0)
184 old := now.Add(-20 * 24 * time.Hour).Unix()
185 insertSpaceRow(t, db, "unclaimed.11ch.at", old)
186
187 deleted := map[string]bool{}
188 sock := reapControl(t, map[string]int{"unclaimed.11ch.at": 1}, nil, nil, deleted, nil)
189 s := &cloudServer{db: db, control: controlClient(sock), controlToken: "t", reapAfter: 14 * 24 * time.Hour, reapDryRun: true}
190
191 reaped, _ := s.reapOnce(now)
192 if reaped != 0 {
193 t.Errorf("dry-run reaped = %d, want 0", reaped)
194 }
195 if len(deleted) != 0 {
196 t.Errorf("dry-run must not delete anything, got %v", deleted)
197 }
198}
199
200func TestReapOnceSkipsWhenStatsUnavailable(t *testing.T) {
201 db := openTestCloudDB(t)
202 now := time.Unix(2_000_000_000, 0)
203 old := now.Add(-20 * 24 * time.Hour).Unix()
204 insertSpaceRow(t, db, "flaky.11ch.at", old)
205
206 deleted := map[string]bool{}
207 // stats endpoint errors for this host → the reaper must skip, never delete.
208 sock := reapControl(t, map[string]int{}, nil, nil, deleted, map[string]bool{"flaky.11ch.at": true})
209 s := &cloudServer{db: db, control: controlClient(sock), controlToken: "t", reapAfter: 14 * 24 * time.Hour}
210
211 reaped, kept := s.reapOnce(now)
212 if reaped != 0 || kept != 0 {
213 t.Errorf("reaped=%d kept=%d, want 0/0 (skipped)", reaped, kept)
214 }
215 if deleted["flaky.11ch.at"] {
216 t.Error("a space whose emptiness is unknown must NOT be reaped")
217 }
218}
219
220// ---- reaping a hibernating space -----------------------------------------------
221
222// End-to-end through the REAL control daemon: a hibernate-created space that
223// never woke (route + env, no DB, no unit, no process) must be reapable — its
224// stats answer definitionally from the registry, and the delete must not depend
225// on a systemd unit existing.
226func TestReapNeverWokenHibernatingSpace(t *testing.T) {
227 reg := controlTestEnv(t)
228 const host = "dead.11ch.at"
229
230 // Systemd stand-in: stopping the (nonexistent) instance unit FAILS, as a
231 // systemd that errors on unknown units would — the hibernating delete path
232 // must shrug (best-effort), not abort the reap.
233 var calls []string
234 systemctl = func(args ...string) error {
235 call := strings.Join(args, " ")
236 calls = append(calls, call)
237 if call == "disable --now lchat@"+host {
238 return fmt.Errorf("unit lchat@%s not loaded", host)
239 }
240 return nil
241 }
242
243 // The register-only footprint a hibernate-create leaves when its first wake
244 // never happened: a routed row + env file. No DB, no unit, no process.
245 if err := reg.upsert(Route{Host: host, Kind: "instance",
246 Socket: filepath.Join(instanceSocketDir, host+".sock"), Status: "ready", Hibernate: true}); err != nil {
247 t.Fatal(err)
248 }
249 if err := writeEnvFile(filepath.Join(instanceDataDir, host+".env"), map[string]string{"LCHAT_ADMIN_TOKEN": "tok"}); err != nil {
250 t.Fatal(err)
251 }
252
253 token := "ctl"
254 c := &controld{reg: reg, tokenHash: hashToken(token), maxInstances: 40, minFreeGB: 5}
255 sock := fakeControl(t, c.handler().ServeHTTP)
256
257 db := openTestCloudDB(t)
258 now := time.Unix(2_000_000_000, 0)
259 id := insertSpaceRow(t, db, host, now.Add(-20*24*time.Hour).Unix())
260 s := &cloudServer{db: db, control: controlClient(sock), controlToken: token, reapAfter: 14 * 24 * time.Hour}
261
262 reaped, kept := s.reapOnce(now)
263 if reaped != 1 || kept != 0 {
264 t.Fatalf("reaped=%d kept=%d, want 1/0 (calls: %v)", reaped, kept, calls)
265 }
266 // Route gone, env archived (recoverable), ledger row cleared.
267 if rt, _ := reg.get(host); rt != nil {
268 t.Errorf("route still present: %+v", rt)
269 }
270 matches, err := filepath.Glob(filepath.Join(instanceDataDir, "trash", host+"-*", host+".env"))
271 if err != nil || len(matches) != 1 {
272 t.Errorf("archived env not in trash (matches=%v err=%v)", matches, err)
273 }
274 if _, ok, _ := db.spaceOwnedByID(t, id); ok {
275 t.Error("ledger row should be cleared")
276 }
277}
278
279// ---- instanceStatus + the controld stats endpoint -----------------------------
280
281func TestInstanceStatusAndStatsHandler(t *testing.T) {
282 dir := t.TempDir()
283 prev := instanceDataDir
284 instanceDataDir = dir
285 defer func() { instanceDataDir = prev }()
286
287 // A set-up space: the pre-seeded admin + setup_complete='1'.
288 used, err := openDB(filepath.Join(dir, "used.11ch.at.db"))
289 if err != nil {
290 t.Fatal(err)
291 }
292 defer used.sql.Close()
293 if _, err := used.sql.Exec(`INSERT INTO users(id, created_at) VALUES('admin',1)`); err != nil {
294 t.Fatal(err)
295 }
296 if err := used.setSetupComplete(true); err != nil {
297 t.Fatal(err)
298 }
299 // A never-set-up space: the pre-seeded admin, no setup_complete row.
300 fresh, err := openDB(filepath.Join(dir, "fresh.11ch.at.db"))
301 if err != nil {
302 t.Fatal(err)
303 }
304 defer fresh.sql.Close()
305 if _, err := fresh.sql.Exec(`INSERT INTO users(id, created_at) VALUES('admin',1)`); err != nil {
306 t.Fatal(err)
307 }
308 // A never-set-up space that nonetheless HAS content (a stray thread) — the
309 // content guard must protect it even though setup_complete is unset.
310 withMsg, err := openDB(filepath.Join(dir, "hascontent.11ch.at.db"))
311 if err != nil {
312 t.Fatal(err)
313 }
314 defer withMsg.sql.Close()
315 if _, err := withMsg.sql.Exec(`INSERT INTO users(id, created_at) VALUES('admin',1)`); err != nil {
316 t.Fatal(err)
317 }
318 if _, err := withMsg.sql.Exec(`INSERT INTO threads(id, kind, created_at) VALUES('t1','group',1)`); err != nil {
319 t.Fatal(err)
320 }
321
322 if m, _, sc, hc, err := instanceStatus("used.11ch.at"); err != nil || m != 1 || !sc || hc {
323 t.Errorf("instanceStatus(used) = (%d, %v, %v, %v); want (1, true, false, nil)", m, sc, hc, err)
324 }
325 if m, _, sc, hc, err := instanceStatus("fresh.11ch.at"); err != nil || m != 1 || sc || hc {
326 t.Errorf("instanceStatus(fresh) = (%d, %v, %v, %v); want (1, false, false, nil)", m, sc, hc, err)
327 }
328 if m, _, sc, hc, err := instanceStatus("hascontent.11ch.at"); err != nil || m != 1 || sc || !hc {
329 t.Errorf("instanceStatus(hascontent) = (%d, %v, %v, %v); want (1, false, true, nil)", m, sc, hc, err)
330 }
331 if _, _, _, _, err := instanceStatus("missing.11ch.at"); !os.IsNotExist(err) {
332 t.Errorf("missing instance should be not-exist, got %v", err)
333 }
334
335 // The stats endpoint, routed + auth-gated, reports the signals.
336 c := &controld{tokenHash: hashToken("secret")}
337 srv := httptest.NewServer(c.handler())
338 defer srv.Close()
339 req, _ := http.NewRequest("GET", srv.URL+"/instances/fresh.11ch.at/stats", nil)
340 req.Header.Set("Authorization", "Bearer secret")
341 resp, err := srv.Client().Do(req)
342 if err != nil {
343 t.Fatal(err)
344 }
345 defer resp.Body.Close()
346 if resp.StatusCode != 200 {
347 t.Fatalf("stats HTTP %d", resp.StatusCode)
348 }
349 var body struct {
350 Members int `json:"members"`
351 SetupComplete bool `json:"setup_complete"`
352 HasContent bool `json:"has_content"`
353 }
354 _ = json.NewDecoder(resp.Body).Decode(&body)
355 if body.Members != 1 || body.SetupComplete || body.HasContent {
356 t.Errorf("stats = %+v, want members 1, setup_complete false, has_content false", body)
357 }
358
359 // No token → 401.
360 req2, _ := http.NewRequest("GET", srv.URL+"/instances/fresh.11ch.at/stats", nil)
361 resp2, _ := srv.Client().Do(req2)
362 if resp2.StatusCode != 401 {
363 t.Errorf("unauth stats = %d, want 401", resp2.StatusCode)
364 }
365 resp2.Body.Close()
366}
367
368// ---- helpers ------------------------------------------------------------------
369
370func openTestCloudDB(t *testing.T) *cloudDB {
371 t.Helper()
372 db, err := openCloudDB(filepath.Join(t.TempDir(), "cloud.db"))
373 if err != nil {
374 t.Fatal(err)
375 }
376 return db
377}
378
379func insertSpaceRow(t *testing.T, db *cloudDB, host string, createdAt int64) int64 {
380 t.Helper()
381 res, err := db.sql.Exec(
382 `INSERT INTO spaces(host, caller_id, requester, created_at) VALUES(?, 'c', 'r', ?)`, host, createdAt)
383 if err != nil {
384 t.Fatal(err)
385 }
386 id, err := res.LastInsertId()
387 if err != nil {
388 t.Fatal(err)
389 }
390 return id
391}
392
393// spaceOwnedByID reports whether a ledger row still exists (test-only lookup).
394func (db *cloudDB) spaceOwnedByID(t *testing.T, id int64) (string, bool, error) {
395 t.Helper()
396 var host string
397 err := db.sql.QueryRow(`SELECT COALESCE(host,'') FROM spaces WHERE id = ?`, id).Scan(&host)
398 if err != nil {
399 return "", false, nil
400 }
401 return host, true, nil
402}
403
404// reapControl serves a stand-in control daemon (on a socket, via the existing
405// fakeControl helper) answering the reaper's stats + delete calls: GET stats
406// returns member count + setup_complete (or 500 when statsErr[host]); DELETE
407// records the host.
408func reapControl(t *testing.T, members map[string]int, setup, content, deleted, statsErr map[string]bool) string {
409 t.Helper()
410 mux := http.NewServeMux()
411 mux.HandleFunc("GET /instances/{host}/stats", func(w http.ResponseWriter, r *http.Request) {
412 h := r.PathValue("host")
413 if statsErr[h] {
414 w.WriteHeader(http.StatusInternalServerError)
415 return
416 }
417 writeJSON(w, http.StatusOK, map[string]any{"members": members[h], "setup_complete": setup[h], "has_content": content[h]})
418 })
419 mux.HandleFunc("DELETE /instances/{host}", func(w http.ResponseWriter, r *http.Request) {
420 deleted[r.PathValue("host")] = true
421 writeJSON(w, http.StatusOK, map[string]any{"ok": true})
422 })
423 return fakeControl(t, mux.ServeHTTP)
424}
425
426func itoa(n int64) string { return strconv.FormatInt(n, 10) }
427