Ctrl K
ComunidadGuías…

test/account-cleanup.test.js

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import { test } from "node:test";
import assert from "node:assert/strict";
import { selectDeletableAccounts, selectionToken, confirmPhrase, MIN_AGE_DAYS } from "../src/cleanup.js";

/**
 * Who a bulk cleanup may remove.
 *
 * This is the most destructive thing the admin panel can do, and the failure
 * mode is not a wrong number — it is a filter that matched more than the
 * operator read. Every rule below is a veto that the caller cannot switch off.
 */

const DAY = 24 * 60 * 60 * 1000;
const NOW = Date.parse("2026-09-04T00:00:00Z");
const ago = (days) => new Date(NOW - days * DAY).toISOString();

const base = () => ({
  users: [
    { email: "stale@x.test", name: "Stale", createdAt: ago(60) },
    { email: "boss@x.test", name: "Boss", createdAt: ago(60), admin: true },
    { email: "author@x.test", name: "Author", createdAt: ago(60) },
    { email: "spender@x.test", name: "Spender", createdAt: ago(60) },
    { email: "fresh@x.test", name: "Fresh", createdAt: ago(2) },
    { email: "me@x.test", name: "Operator", createdAt: ago(60) },
  ],
  deckOwners: new Set(["author@x.test"]),
  usedUserIds: new Set([42]),
  idFor: (e) => (e === "spender@x.test" ? 42 : null),
  protect: ["me@x.test"],
  now: NOW,
});

const reasonFor = (skipped, email) => skipped.find((s) => s.email === email)?.reason;

test("only the account that owns nothing and did nothing is selected", () => {
  const { selected, skipped } = selectDeletableAccounts(base());
  assert.deepEqual(selected.map((s) => s.email), ["stale@x.test"]);
  assert.equal(skipped.length, 5, "and every exclusion is reported, not dropped");
});

test("an admin is never selectable", () => {
  const { selected, skipped } = selectDeletableAccounts(base());
  assert.ok(!selected.some((s) => s.email === "boss@x.test"));
  assert.equal(reasonFor(skipped, "boss@x.test"), "is an admin");
});

test("the operator running the cleanup is never selectable", () => {
  const { skipped } = selectDeletableAccounts(base());
  assert.equal(reasonFor(skipped, "me@x.test"), "is you");
});

test("an account that owns decks is never selectable — deletion would orphan them", () => {
  // deleteUserAccount removes the user and everything that cascades, and
  // nothing removes their decks: meta.owner would name an account that no
  // longer exists and the folder would sit on the volume forever.
  const { skipped } = selectDeletableAccounts(base());
  assert.equal(reasonFor(skipped, "author@x.test"), "owns decks");
});

test("an account that has generated is never selectable", () => {
  const { skipped } = selectDeletableAccounts(base());
  assert.equal(reasonFor(skipped, "spender@x.test"), "has generated");
});

test("a brand-new account is spared whatever else is true of it", () => {
  const { skipped } = selectDeletableAccounts(base());
  assert.match(reasonFor(skipped, "fresh@x.test"), /newer than/);
});

test("the age floor may be raised but never lowered", () => {
  // A caller asking to delete accounts "older than 0 days" gets the floor.
  const wide = selectDeletableAccounts({ ...base(), olderThanDays: 0 });
  assert.equal(wide.criteria.olderThanDays, MIN_AGE_DAYS);
  assert.ok(!wide.selected.some((s) => s.email === "fresh@x.test"), "2 days old is still spared");

  const strict = selectDeletableAccounts({ ...base(), olderThanDays: 90 });
  assert.deepEqual(strict.selected, [], "60 days is not older than 90");
});

test("an unparseable creation date is not evidence of age", () => {
  const o = base();
  o.users = [{ email: "undated@x.test", name: "?", createdAt: "not a date" }];
  const { selected, skipped } = selectDeletableAccounts(o);
  assert.deepEqual(selected, []);
  assert.equal(reasonFor(skipped, "undated@x.test"), "unknown age");
});

test("a pattern narrows the selection and never widens it", () => {
  const o = base();
  o.users.push({ email: "stale2@probe.test", name: "S2", createdAt: ago(60) });
  const all = selectDeletableAccounts(o);
  assert.equal(all.selected.length, 2);
  const narrowed = selectDeletableAccounts({ ...o, emailPattern: "probe" });
  assert.deepEqual(narrowed.selected.map((s) => s.email), ["stale2@probe.test"]);
  // A pattern cannot rescue an account a rule vetoed.
  const cannotWiden = selectDeletableAccounts({ ...o, emailPattern: "boss" });
  assert.deepEqual(cannotWiden.selected, []);
});

/* ------------------------------------------------------------------ token */

test("the token stands for the set, not the filter", () => {
  const a = [{ email: "a@x.test" }, { email: "b@x.test" }];
  const b = [{ email: "b@x.test" }, { email: "a@x.test" }];
  assert.equal(selectionToken(a), selectionToken(b), "order is not part of the identity");
  assert.notEqual(selectionToken(a), selectionToken([{ email: "a@x.test" }]));
  assert.notEqual(selectionToken(a), selectionToken([...a, { email: "c@x.test" }]));
});

test("an account appearing between preview and confirm invalidates the token", () => {
  // This is the property the whole dry run rests on: the confirmation
  // authorises the list that was read, not the filter that produced it.
  const before = selectDeletableAccounts(base());
  const o = base();
  o.users.push({ email: "arrived@x.test", name: "New", createdAt: ago(60) });
  const after = selectDeletableAccounts(o);
  assert.notEqual(selectionToken(before.selected), selectionToken(after.selected));
});

test("the confirmation phrase carries the count, so a stale one cannot approve more", () => {
  assert.equal(confirmPhrase(110), "delete 110 accounts");
  assert.equal(confirmPhrase(1), "delete 1 account");
  assert.notEqual(confirmPhrase(110), confirmPhrase(111));
});

/* --------------------------------------------------- what deletion removes */

test("deleting an account removes its files, not just its rows", async () => {
  const { mkdtemp, mkdir, writeFile, rm, access } = await import("node:fs/promises");
  const { tmpdir } = await import("node:os");
  const pathMod = await import("node:path");
  const scratch = await mkdtemp(pathMod.join(tmpdir(), "forge-tenantfiles-"));
  const prevConfig = process.env.FORGE_CONFIG_DIR;
  const prevBrand = process.env.FORGE_BRAND_DIR;
  const prevRef = process.env.FORGE_REFERENCE_DIR;
  process.env.FORGE_CONFIG_DIR = pathMod.join(scratch, "config");
  process.env.FORGE_BRAND_DIR = pathMod.join(scratch, "brand");
  process.env.FORGE_REFERENCE_DIR = pathMod.join(scratch, "reference");

  try {
    const auth = await import("../src/auth.js");
    const { userIdentityFile, userBrandDirs, userReferenceDir } = await import("../src/tenant.js");
    const store = pathMod.join(scratch, "forge-auth-store");
    await mkdir(store, { recursive: true });
    await writeFile(pathMod.join(store, "users.json"), "[]");
    await writeFile(pathMod.join(store, "sessions.json"), "{}");
    auth.setStoreDir(store);

    const email = "filed@example.test";
    await auth.register({ name: "Filed", email, password: "test-password-123" });

    // The three things that do NOT cascade, because they are files.
    const idFile = userIdentityFile(email);
    const brand = userBrandDirs(email);
    const refDir = userReferenceDir(email);
    await mkdir(pathMod.dirname(idFile), { recursive: true });
    await writeFile(idFile, "institution:\n  short: X\n");
    await mkdir(brand.generated, { recursive: true });
    await writeFile(pathMod.join(brand.generated, "crest.png"), "not really a png");
    await mkdir(refDir, { recursive: true });
    await writeFile(pathMod.join(refDir, "donor.docx"), "not really a docx");

    await auth.deleteUserAccount(email);

    for (const [what, p] of [["identity", idFile], ["brand", brand.generated], ["donor", refDir]]) {
      await assert.rejects(() => access(p), `${what} should have been removed with the account`);
    }
  } finally {
    if (prevConfig === undefined) delete process.env.FORGE_CONFIG_DIR; else process.env.FORGE_CONFIG_DIR = prevConfig;
    if (prevBrand === undefined) delete process.env.FORGE_BRAND_DIR; else process.env.FORGE_BRAND_DIR = prevBrand;
    if (prevRef === undefined) delete process.env.FORGE_REFERENCE_DIR; else process.env.FORGE_REFERENCE_DIR = prevRef;
    await rm(scratch, { recursive: true, force: true });
  }
});