// Covers deleting a repo from the console (ยง24): the double confirmation, every key of that repo // gone from the bucket with no other repo touched, and Kepr's own rows and mirror dropped with it. import { beforeAll, describe, expect, test } from "bun:test"; import { existsSync } from "node:fs"; import { join } from "node:path"; import { isRepoKey, openBucket } from "../src/bucket/reader.ts"; import { removalRun } from "../src/server/remove.ts"; import { Browser, type Instance, instance } from "./browser.ts"; import { type FixtureRepos, makeRepos } from "./fixture.ts"; describe("deleting a repo", () => { let inst: Instance; let browser: Browser; let repos: FixtureRepos; const keys = (prefix: string) => [...new Bun.Glob(`${prefix}/**`).scanSync(repos.bucketDir)]; const count = (sql: string) => (inst.kepr.query<{ n: number }, []>(sql).get() ?? { n: 0 }).n; const remove = (fields: Record) => browser.post("/admin/repos/app/delete", fields); // The delete answers before it is done; startRemoval leaves the run to wait on. const settled = () => removalRun() ?? Promise.resolve(); beforeAll(async () => { repos = await makeRepos(["app", "other"]); inst = await instance(repos.bucketDir); browser = new Browser(inst.app); await browser.signIn(); await browser.post("/admin/repos/app", { public: "1", category: "tools", hidden_paths: "" }); inst.kepr.run( "INSERT INTO downloads (repo, version, platform, count) VALUES ('app', '1.0.0', 'linux-x64', 7)", ); inst.kepr.run( `INSERT INTO issues (repo, number, title, body, type, filed_at) VALUES ('app', 1, 'Crash', 'It broke.', 'bug', 0)`, ); const issue = inst.kepr.query<{ id: number }, []>("SELECT id FROM issues").get()?.id ?? 0; inst.kepr.run( "INSERT INTO issue_entries (issue_id, author, body, created_at) VALUES (?, 'reporter', 'x', 0)", [issue], ); inst.kepr.run( "INSERT INTO issue_followers (issue_id, email, created_at) VALUES (?, 'a@example.com', 0)", [issue], ); // Kepr's own files for the repo, which a delete has to take with it. await Bun.write(join(repos.bucketDir, ".kepr/hidden/app"), "notes.md\n"); await Bun.write(join(repos.bucketDir, ".kepr/media/app/abc.png"), "png"); await Bun.write(join(repos.bucketDir, ".kepr/tarballs/app/1/abcdef.tar.gz"), "tar"); }); test("takes both the tick and the name before anything is deleted", async () => { expect((await remove({})).status).toBe(400); expect((await remove({ delete: "on" })).status).toBe(400); expect((await remove({ delete: "on", name: "other" })).status).toBe(400); expect((await remove({ name: "app" })).status).toBe(400); expect(keys("app").length).toBeGreaterThan(0); expect(count("SELECT COUNT(*) AS n FROM repos WHERE name = 'app'")).toBe(1); }); test("deletes only that repo's keys, its mirror and its rows", async () => { const other = keys("other").length; expect((await remove({ delete: "on", name: "app" })).status).toBe(303); await settled(); expect(keys("app")).toEqual([]); // Kepr's own files for the repo go too; a folder remote may leave the empty folders behind, // which an S3 bucket never has and which no listing shows. expect(existsSync(join(repos.bucketDir, ".kepr/hidden/app"))).toBe(false); expect(keys(".kepr/media/app")).toEqual([]); expect(keys(".kepr/tarballs/app")).toEqual([]); // The other repo is untouched, keys and rows alike. expect(keys("other").length).toBe(other); expect(count("SELECT COUNT(*) AS n FROM repos WHERE name = 'other'")).toBe(1); // Kepr's own copy: the mirror, and every row that pointed at the repo. expect(existsSync(join(inst.dataDir, "mirror", "app.db"))).toBe(false); for (const sql of [ "SELECT COUNT(*) AS n FROM repos WHERE name = 'app'", "SELECT COUNT(*) AS n FROM downloads WHERE repo = 'app'", "SELECT COUNT(*) AS n FROM issues WHERE repo = 'app'", "SELECT COUNT(*) AS n FROM issue_entries", "SELECT COUNT(*) AS n FROM issue_followers", ]) { expect(`${sql} ${count(sql)}`).toBe(`${sql} 0`); } }); test("the delete path refuses any key that is not of that repo", async () => { const bucket = openBucket(inst.config.bucket); for (const key of [ "other/head", "other/saves/abcdef", ".kepr/media/other/x.png", "../app/head", ]) { expect(`${key} ${isRepoKey("app", key)}`).toBe(`${key} false`); await expect(bucket.removeRepoKey("app", key)).rejects.toThrow(); } expect(isRepoKey("app", "app/head")).toBe(true); expect(isRepoKey("app", ".kepr/tarballs/app/1/abcdef.tar.gz")).toBe(true); expect(isRepoKey("app", ".kepr/hidden/app")).toBe(true); expect(isRepoKey("", "head")).toBe(false); }); test("the console offers the delete with both confirmations", async () => { const page = await (await browser.get("/admin/repos?repo=other")).text(); expect(page).toContain('action="/admin/repos/other/delete"'); expect(page).toContain("type other to confirm"); expect(page).toContain("nothing here can undo it"); }); });