package main import ( "encoding/json" "fmt" "net/http" "net/http/httptest" "os" "path/filepath" "strconv" "strings" "testing" "time" ) // ---- trash purge -------------------------------------------------------------- func TestTrashDirTime(t *testing.T) { // The "-" suffix wins when present. got := trashDirTime("cnz.11ch.at-1700000000", nil) if want := time.Unix(1700000000, 0); !got.Equal(want) { t.Errorf("suffix parse = %v, want %v", got, want) } // No parseable suffix → mtime fallback (a zero time when info is nil). if !trashDirTime("weird-name-no-unix-xyz", nil).IsZero() { t.Error("unparseable name with nil info should be zero time") } } func TestPurgeTrash(t *testing.T) { dir := t.TempDir() now := time.Unix(1_800_000_000, 0) mk := func(name string) { //nolint if err := os.Mkdir(filepath.Join(dir, name), 0o700); err != nil { t.Fatal(err) } } old := now.Add(-40 * 24 * time.Hour).Unix() fresh := now.Add(-2 * 24 * time.Hour).Unix() mk("old.11ch.at-" + itoa(old)) // 40 days → purged mk("fresh.11ch.at-" + itoa(fresh)) // 2 days → kept // a stray file (not a dir) must be ignored if err := os.WriteFile(filepath.Join(dir, "note.txt"), []byte("x"), 0o600); err != nil { t.Fatal(err) } removed, err := purgeTrash(dir, 30*24*time.Hour, now) if err != nil { t.Fatal(err) } if removed != 1 { t.Errorf("removed = %d, want 1", removed) } if _, err := os.Stat(filepath.Join(dir, "old.11ch.at-"+itoa(old))); !os.IsNotExist(err) { t.Error("old trash should be gone") } if _, err := os.Stat(filepath.Join(dir, "fresh.11ch.at-"+itoa(fresh))); err != nil { t.Error("fresh trash should remain") } // A missing trash dir is fine (nothing to purge). if n, err := purgeTrash(filepath.Join(dir, "does-not-exist"), time.Hour, now); err != nil || n != 0 { t.Errorf("missing dir: n=%d err=%v", n, err) } } // ---- ledger query ------------------------------------------------------------- func TestSpacesOlderThan(t *testing.T) { db := openTestCloudDB(t) insertSpaceRow(t, db, "old.11ch.at", 1000) insertSpaceRow(t, db, "mid.11ch.at", 2000) insertSpaceRow(t, db, "new.11ch.at", 5000) // a pending create (NULL host) is never a candidate if _, err := db.sql.Exec(`INSERT INTO spaces(host, caller_id, requester, created_at) VALUES(NULL,'c','r',1)`); err != nil { t.Fatal(err) } got, err := db.spacesOlderThan(2000) if err != nil { t.Fatal(err) } if len(got) != 2 { t.Fatalf("candidates = %d, want 2 (old, mid)", len(got)) } if got[0].Host != "old.11ch.at" || got[1].Host != "mid.11ch.at" { t.Errorf("order = %s, %s (want old, mid, oldest first)", got[0].Host, got[1].Host) } } // ---- the reaper --------------------------------------------------------------- func TestReapOnceReclaimsOnlyOldEmpties(t *testing.T) { db := openTestCloudDB(t) now := time.Unix(2_000_000_000, 0) old := now.Add(-20 * 24 * time.Hour).Unix() recent := now.Add(-1 * 24 * time.Hour).Unix() // Both have the 1 pre-seeded admin (realistic); setup_complete is the signal. unclaimedID := insertSpaceRow(t, db, "unclaimed.11ch.at", old) // old + never set up → reaped claimedID := insertSpaceRow(t, db, "used.11ch.at", old) // old + set up → kept insertSpaceRow(t, db, "young.11ch.at", recent) // too new → not a candidate deleted := map[string]bool{} sock := reapControl(t, map[string]int{"unclaimed.11ch.at": 1, "used.11ch.at": 1}, map[string]bool{"used.11ch.at": true}, // only used.* completed setup nil, // neither has content deleted, nil) s := &cloudServer{db: db, control: controlClient(sock), controlToken: "t", reapAfter: 14 * 24 * time.Hour} reaped, kept := s.reapOnce(now) if reaped != 1 || kept != 1 { t.Fatalf("reaped=%d kept=%d, want 1/1", reaped, kept) } if !deleted["unclaimed.11ch.at"] { t.Error("the old never-set-up space should have been deleted") } if deleted["used.11ch.at"] { t.Error("a space that completed setup must NEVER be reaped") } // ledger: unclaimed gone, used + young remain if _, ok, _ := db.spaceOwnedByID(t, unclaimedID); ok { t.Error("reaped space should be cleared from the ledger") } if _, ok, _ := db.spaceOwnedByID(t, claimedID); !ok { t.Error("kept space should remain in the ledger") } } func TestReapOnceKeepsSpaceWithContent(t *testing.T) { db := openTestCloudDB(t) now := time.Unix(2_000_000_000, 0) old := now.Add(-20 * 24 * time.Hour).Unix() id := insertSpaceRow(t, db, "hascontent.11ch.at", old) // old + never set up + 1 member, BUT has content deleted := map[string]bool{} sock := reapControl(t, map[string]int{"hascontent.11ch.at": 1}, nil, // never set up map[string]bool{"hascontent.11ch.at": true}, // ...but has a message/thread deleted, nil) s := &cloudServer{db: db, control: controlClient(sock), controlToken: "t", reapAfter: 14 * 24 * time.Hour} reaped, kept := s.reapOnce(now) if reaped != 0 || kept != 1 { t.Fatalf("reaped=%d kept=%d, want 0/1 (content protects it)", reaped, kept) } if deleted["hascontent.11ch.at"] { t.Error("a space with content must NEVER be reaped") } if _, ok, _ := db.spaceOwnedByID(t, id); !ok { t.Error("a space with content should remain in the ledger") } } func TestReapOnceCapsPerSweep(t *testing.T) { db := openTestCloudDB(t) now := time.Unix(2_000_000_000, 0) old := now.Add(-20 * 24 * time.Hour).Unix() members := map[string]int{} for i := 0; i < 5; i++ { // 5 reapable candidates h := "dead" + strconv.Itoa(i) + ".11ch.at" insertSpaceRow(t, db, h, old) members[h] = 1 // all never-set-up, 1 member, no content } deleted := map[string]bool{} sock := reapControl(t, members, nil, nil, deleted, nil) s := &cloudServer{db: db, control: controlClient(sock), controlToken: "t", reapAfter: 14 * 24 * time.Hour, reapMax: 2} reaped, _ := s.reapOnce(now) if reaped != 2 || len(deleted) != 2 { t.Fatalf("reaped=%d deleted=%d, want 2/2 (per-sweep cap)", reaped, len(deleted)) } // the other 3 survive this sweep, to be handled by the next one if remaining, _ := db.spacesOlderThan(now.Unix()); len(remaining) != 3 { t.Errorf("remaining candidates = %d, want 3 (deferred, not lost)", len(remaining)) } } func TestReapOnceDryRunDeletesNothing(t *testing.T) { db := openTestCloudDB(t) now := time.Unix(2_000_000_000, 0) old := now.Add(-20 * 24 * time.Hour).Unix() insertSpaceRow(t, db, "unclaimed.11ch.at", old) deleted := map[string]bool{} sock := reapControl(t, map[string]int{"unclaimed.11ch.at": 1}, nil, nil, deleted, nil) s := &cloudServer{db: db, control: controlClient(sock), controlToken: "t", reapAfter: 14 * 24 * time.Hour, reapDryRun: true} reaped, _ := s.reapOnce(now) if reaped != 0 { t.Errorf("dry-run reaped = %d, want 0", reaped) } if len(deleted) != 0 { t.Errorf("dry-run must not delete anything, got %v", deleted) } } func TestReapOnceSkipsWhenStatsUnavailable(t *testing.T) { db := openTestCloudDB(t) now := time.Unix(2_000_000_000, 0) old := now.Add(-20 * 24 * time.Hour).Unix() insertSpaceRow(t, db, "flaky.11ch.at", old) deleted := map[string]bool{} // stats endpoint errors for this host → the reaper must skip, never delete. sock := reapControl(t, map[string]int{}, nil, nil, deleted, map[string]bool{"flaky.11ch.at": true}) s := &cloudServer{db: db, control: controlClient(sock), controlToken: "t", reapAfter: 14 * 24 * time.Hour} reaped, kept := s.reapOnce(now) if reaped != 0 || kept != 0 { t.Errorf("reaped=%d kept=%d, want 0/0 (skipped)", reaped, kept) } if deleted["flaky.11ch.at"] { t.Error("a space whose emptiness is unknown must NOT be reaped") } } // ---- reaping a hibernating space ----------------------------------------------- // End-to-end through the REAL control daemon: a hibernate-created space that // never woke (route + env, no DB, no unit, no process) must be reapable — its // stats answer definitionally from the registry, and the delete must not depend // on a systemd unit existing. func TestReapNeverWokenHibernatingSpace(t *testing.T) { reg := controlTestEnv(t) const host = "dead.11ch.at" // Systemd stand-in: stopping the (nonexistent) instance unit FAILS, as a // systemd that errors on unknown units would — the hibernating delete path // must shrug (best-effort), not abort the reap. var calls []string systemctl = func(args ...string) error { call := strings.Join(args, " ") calls = append(calls, call) if call == "disable --now lchat@"+host { return fmt.Errorf("unit lchat@%s not loaded", host) } return nil } // The register-only footprint a hibernate-create leaves when its first wake // never happened: a routed row + env file. No DB, no unit, no process. if err := reg.upsert(Route{Host: host, Kind: "instance", Socket: filepath.Join(instanceSocketDir, host+".sock"), Status: "ready", Hibernate: true}); err != nil { t.Fatal(err) } if err := writeEnvFile(filepath.Join(instanceDataDir, host+".env"), map[string]string{"LCHAT_ADMIN_TOKEN": "tok"}); err != nil { t.Fatal(err) } token := "ctl" c := &controld{reg: reg, tokenHash: hashToken(token), maxInstances: 40, minFreeGB: 5} sock := fakeControl(t, c.handler().ServeHTTP) db := openTestCloudDB(t) now := time.Unix(2_000_000_000, 0) id := insertSpaceRow(t, db, host, now.Add(-20*24*time.Hour).Unix()) s := &cloudServer{db: db, control: controlClient(sock), controlToken: token, reapAfter: 14 * 24 * time.Hour} reaped, kept := s.reapOnce(now) if reaped != 1 || kept != 0 { t.Fatalf("reaped=%d kept=%d, want 1/0 (calls: %v)", reaped, kept, calls) } // Route gone, env archived (recoverable), ledger row cleared. if rt, _ := reg.get(host); rt != nil { t.Errorf("route still present: %+v", rt) } matches, err := filepath.Glob(filepath.Join(instanceDataDir, "trash", host+"-*", host+".env")) if err != nil || len(matches) != 1 { t.Errorf("archived env not in trash (matches=%v err=%v)", matches, err) } if _, ok, _ := db.spaceOwnedByID(t, id); ok { t.Error("ledger row should be cleared") } } // ---- instanceStatus + the controld stats endpoint ----------------------------- func TestInstanceStatusAndStatsHandler(t *testing.T) { dir := t.TempDir() prev := instanceDataDir instanceDataDir = dir defer func() { instanceDataDir = prev }() // A set-up space: the pre-seeded admin + setup_complete='1'. used, err := openDB(filepath.Join(dir, "used.11ch.at.db")) if err != nil { t.Fatal(err) } defer used.sql.Close() if _, err := used.sql.Exec(`INSERT INTO users(id, created_at) VALUES('admin',1)`); err != nil { t.Fatal(err) } if err := used.setSetupComplete(true); err != nil { t.Fatal(err) } // A never-set-up space: the pre-seeded admin, no setup_complete row. fresh, err := openDB(filepath.Join(dir, "fresh.11ch.at.db")) if err != nil { t.Fatal(err) } defer fresh.sql.Close() if _, err := fresh.sql.Exec(`INSERT INTO users(id, created_at) VALUES('admin',1)`); err != nil { t.Fatal(err) } // A never-set-up space that nonetheless HAS content (a stray thread) — the // content guard must protect it even though setup_complete is unset. withMsg, err := openDB(filepath.Join(dir, "hascontent.11ch.at.db")) if err != nil { t.Fatal(err) } defer withMsg.sql.Close() if _, err := withMsg.sql.Exec(`INSERT INTO users(id, created_at) VALUES('admin',1)`); err != nil { t.Fatal(err) } if _, err := withMsg.sql.Exec(`INSERT INTO threads(id, kind, created_at) VALUES('t1','group',1)`); err != nil { t.Fatal(err) } if m, _, sc, hc, err := instanceStatus("used.11ch.at"); err != nil || m != 1 || !sc || hc { t.Errorf("instanceStatus(used) = (%d, %v, %v, %v); want (1, true, false, nil)", m, sc, hc, err) } if m, _, sc, hc, err := instanceStatus("fresh.11ch.at"); err != nil || m != 1 || sc || hc { t.Errorf("instanceStatus(fresh) = (%d, %v, %v, %v); want (1, false, false, nil)", m, sc, hc, err) } if m, _, sc, hc, err := instanceStatus("hascontent.11ch.at"); err != nil || m != 1 || sc || !hc { t.Errorf("instanceStatus(hascontent) = (%d, %v, %v, %v); want (1, false, true, nil)", m, sc, hc, err) } if _, _, _, _, err := instanceStatus("missing.11ch.at"); !os.IsNotExist(err) { t.Errorf("missing instance should be not-exist, got %v", err) } // The stats endpoint, routed + auth-gated, reports the signals. c := &controld{tokenHash: hashToken("secret")} srv := httptest.NewServer(c.handler()) defer srv.Close() req, _ := http.NewRequest("GET", srv.URL+"/instances/fresh.11ch.at/stats", nil) req.Header.Set("Authorization", "Bearer secret") resp, err := srv.Client().Do(req) if err != nil { t.Fatal(err) } defer resp.Body.Close() if resp.StatusCode != 200 { t.Fatalf("stats HTTP %d", resp.StatusCode) } var body struct { Members int `json:"members"` SetupComplete bool `json:"setup_complete"` HasContent bool `json:"has_content"` } _ = json.NewDecoder(resp.Body).Decode(&body) if body.Members != 1 || body.SetupComplete || body.HasContent { t.Errorf("stats = %+v, want members 1, setup_complete false, has_content false", body) } // No token → 401. req2, _ := http.NewRequest("GET", srv.URL+"/instances/fresh.11ch.at/stats", nil) resp2, _ := srv.Client().Do(req2) if resp2.StatusCode != 401 { t.Errorf("unauth stats = %d, want 401", resp2.StatusCode) } resp2.Body.Close() } // ---- helpers ------------------------------------------------------------------ func openTestCloudDB(t *testing.T) *cloudDB { t.Helper() db, err := openCloudDB(filepath.Join(t.TempDir(), "cloud.db")) if err != nil { t.Fatal(err) } return db } func insertSpaceRow(t *testing.T, db *cloudDB, host string, createdAt int64) int64 { t.Helper() res, err := db.sql.Exec( `INSERT INTO spaces(host, caller_id, requester, created_at) VALUES(?, 'c', 'r', ?)`, host, createdAt) if err != nil { t.Fatal(err) } id, err := res.LastInsertId() if err != nil { t.Fatal(err) } return id } // spaceOwnedByID reports whether a ledger row still exists (test-only lookup). func (db *cloudDB) spaceOwnedByID(t *testing.T, id int64) (string, bool, error) { t.Helper() var host string err := db.sql.QueryRow(`SELECT COALESCE(host,'') FROM spaces WHERE id = ?`, id).Scan(&host) if err != nil { return "", false, nil } return host, true, nil } // reapControl serves a stand-in control daemon (on a socket, via the existing // fakeControl helper) answering the reaper's stats + delete calls: GET stats // returns member count + setup_complete (or 500 when statsErr[host]); DELETE // records the host. func reapControl(t *testing.T, members map[string]int, setup, content, deleted, statsErr map[string]bool) string { t.Helper() mux := http.NewServeMux() mux.HandleFunc("GET /instances/{host}/stats", func(w http.ResponseWriter, r *http.Request) { h := r.PathValue("host") if statsErr[h] { w.WriteHeader(http.StatusInternalServerError) return } writeJSON(w, http.StatusOK, map[string]any{"members": members[h], "setup_complete": setup[h], "has_content": content[h]}) }) mux.HandleFunc("DELETE /instances/{host}", func(w http.ResponseWriter, r *http.Request) { deleted[r.PathValue("host")] = true writeJSON(w, http.StatusOK, map[string]any{"ok": true}) }) return fakeControl(t, mux.ServeHTTP) } func itoa(n int64) string { return strconv.FormatInt(n, 10) }