| 1 | package main |
| 2 | |
| 3 | import ( |
| 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 | |
| 18 | func 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 | |
| 30 | func 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 | |
| 69 | func 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 | |
| 93 | func 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 | |
| 130 | func 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 | |
| 156 | func 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 | |
| 181 | func 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 | |
| 200 | func 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. |
| 226 | func 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 | |
| 281 | func 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 | |
| 370 | func 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 | |
| 379 | func 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). |
| 394 | func (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. |
| 408 | func 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 | |
| 426 | func itoa(n int64) string { return strconv.FormatInt(n, 10) } |
| 427 | |