|
|
@@ -0,0 +1,202 @@ |
| 1 |
|
| 2 |
|
| 3 |
|
| 4 |
|
| 5 |
|
| 6 |
import { afterAll, afterEach, beforeAll, describe, expect, setSystemTime, test } from "bun:test"; |
| 7 |
import { mkdtemp, rm } from "node:fs/promises"; |
| 8 |
import { tmpdir } from "node:os"; |
| 9 |
import { join } from "node:path"; |
| 10 |
import { createApp, type App } from "../src/app.ts"; |
| 11 |
import { |
| 12 |
UNDO_WINDOW_MS, |
| 13 |
applyFleetOp, |
| 14 |
previewFleetOp, |
| 15 |
undoFleetOp, |
| 16 |
} from "../src/actions/fleet.ts"; |
| 17 |
import { createRepo, updateRepoSettings } from "../src/actions/repos.ts"; |
| 18 |
import { getRepoById, type Repo } from "../src/core/repos.ts"; |
| 19 |
import { createToken } from "../src/core/tokens.ts"; |
| 20 |
import { createUser, type User } from "../src/core/users.ts"; |
| 21 |
import { serve, type Servers } from "../src/http/server.ts"; |
| 22 |
import { promote } from "./helpers/members.ts"; |
| 23 |
|
| 24 |
describe("fleet archive", () => { |
| 25 |
let dataDir: string; |
| 26 |
let app: App; |
| 27 |
let servers: Servers; |
| 28 |
let token: string; |
| 29 |
let alice: User; |
| 30 |
let bob: User; |
| 31 |
let repos: Repo[]; |
| 32 |
let bobs: Repo; |
| 33 |
|
| 34 |
const person = async (username: string) => { |
| 35 |
const made = await createUser(app.db, { |
| 36 |
origin: "seed", |
| 37 |
trusted: true, |
| 38 |
username, |
| 39 |
allowReserved: true, |
| 40 |
adminIfFirst: username === "root", |
| 41 |
email: `${username}@forge.local`, |
| 42 |
password: "a-good-passphrase", |
| 43 |
}); |
| 44 |
if (!made.ok) throw new Error(made.error); |
| 45 |
promote(app.db, made.value); |
| 46 |
return made.value; |
| 47 |
}; |
| 48 |
const repoOf = async (owner: User, name: string) => { |
| 49 |
const made = await createRepo(app, owner, { name, visibility: "public" }); |
| 50 |
if (!made.ok) throw new Error(String(made.error)); |
| 51 |
return made.value; |
| 52 |
}; |
| 53 |
const archived = (repo: Repo) => getRepoById(app.db, repo.id)!.archivedAt !== null; |
| 54 |
const wake = async (repo: Repo) => { |
| 55 |
if (archived(repo)) await updateRepoSettings(app, alice, repo, { archived: false }); |
| 56 |
}; |
| 57 |
|
| 58 |
beforeAll(async () => { |
| 59 |
dataDir = await mkdtemp(join(tmpdir(), "forge-fleet-archive-")); |
| 60 |
app = await createApp({ dataDir }); |
| 61 |
await person("root"); |
| 62 |
alice = await person("alice"); |
| 63 |
bob = await person("bob"); |
| 64 |
repos = []; |
| 65 |
for (const name of ["one", "two", "three", "four"]) repos.push(await repoOf(alice, name)); |
| 66 |
bobs = await repoOf(bob, "garden"); |
| 67 |
servers = await serve(app); |
| 68 |
token = createToken(app.db, alice.id, "admin", "admin").token; |
| 69 |
}, 30_000); |
| 70 |
|
| 71 |
afterEach(async () => { |
| 72 |
setSystemTime(); |
| 73 |
for (const repo of repos) await wake(repo); |
| 74 |
}); |
| 75 |
|
| 76 |
afterAll(async () => { |
| 77 |
servers.stop(); |
| 78 |
app.close(); |
| 79 |
await rm(dataDir, { recursive: true, force: true }); |
| 80 |
}); |
| 81 |
|
| 82 |
const Q = "repo owner:alice"; |
| 83 |
const action = { verb: "archive", value: true }; |
| 84 |
|
| 85 |
test("a preview changes nothing", () => { |
| 86 |
const plan = previewFleetOp(app, alice, Q, action); |
| 87 |
expect(plan.ok).toBe(true); |
| 88 |
if (plan.ok) expect(plan.value.counts.change).toBe(4); |
| 89 |
expect(repos.some(archived)).toBe(false); |
| 90 |
}); |
| 91 |
|
| 92 |
test("archive three, undo, all three awake", async () => { |
| 93 |
const ids = repos.slice(0, 3).map((r) => r.id); |
| 94 |
const applied = await applyFleetOp(app, alice, "repo owner:alice", action); |
| 95 |
if (!applied.ok) throw new Error(applied.error.message); |
| 96 |
const mine = applied.value.rows.filter((r) => ids.includes(r.repoId)); |
| 97 |
expect(mine.every((r) => r.outcome === "ok")).toBe(true); |
| 98 |
expect(repos.every(archived)).toBe(true); |
| 99 |
const undone = await undoFleetOp(app, alice, applied.value.id); |
| 100 |
expect(undone.ok).toBe(true); |
| 101 |
expect(repos.some(archived)).toBe(false); |
| 102 |
}); |
| 103 |
|
| 104 |
test("one already archived is unchanged and stays archived after undo", async () => { |
| 105 |
await updateRepoSettings(app, alice, repos[0]!, { archived: true }); |
| 106 |
const applied = await applyFleetOp(app, alice, "repo owner:alice", action); |
| 107 |
if (!applied.ok) throw new Error(applied.error.message); |
| 108 |
const first = applied.value.rows.find((r) => r.repoId === repos[0]!.id)!; |
| 109 |
expect(first.outcome).toBe("unchanged"); |
| 110 |
const undone = await undoFleetOp(app, alice, applied.value.id); |
| 111 |
expect(undone.ok).toBe(true); |
| 112 |
expect(archived(repos[0]!)).toBe(true); |
| 113 |
expect(repos.slice(1).some(archived)).toBe(false); |
| 114 |
}); |
| 115 |
|
| 116 |
test("a row the actor does not administer is refused and stays awake", async () => { |
| 117 |
const applied = await applyFleetOp(app, alice, "repo", action); |
| 118 |
if (!applied.ok) throw new Error(applied.error.message); |
| 119 |
const row = applied.value.rows.find((r) => r.repoId === bobs.id); |
| 120 |
expect(row?.outcome).toBe("refused"); |
| 121 |
expect(archived(bobs)).toBe(false); |
| 122 |
for (const repo of repos) await wake(repo); |
| 123 |
const own = await applyFleetOp(app, bob, "repo owner:alice", action); |
| 124 |
if (!own.ok) throw new Error(own.error.message); |
| 125 |
expect(own.value.rows.every((r) => r.outcome === "refused")).toBe(true); |
| 126 |
expect(repos.some(archived)).toBe(false); |
| 127 |
}); |
| 128 |
|
| 129 |
test("a value that is not a boolean is refused", () => { |
| 130 |
for (const value of ["yes", 1, null]) { |
| 131 |
const plan = previewFleetOp(app, alice, Q, { verb: "archive", value }); |
| 132 |
expect(plan.ok).toBe(false); |
| 133 |
} |
| 134 |
}); |
| 135 |
|
| 136 |
test("an undo after 24 hours is refused", async () => { |
| 137 |
const applied = await applyFleetOp(app, alice, Q, action); |
| 138 |
if (!applied.ok) throw new Error(applied.error.message); |
| 139 |
setSystemTime(new Date(Date.now() + UNDO_WINDOW_MS + 60_000)); |
| 140 |
const undone = await undoFleetOp(app, alice, applied.value.id); |
| 141 |
expect(undone.ok).toBe(false); |
| 142 |
if (!undone.ok) expect(undone.error.code).toBe("expired"); |
| 143 |
expect(repos.every(archived)).toBe(true); |
| 144 |
}); |
| 145 |
|
| 146 |
const api = (path: string, body: unknown) => |
| 147 |
fetch(`${servers.url}${path}`, { |
| 148 |
method: "POST", |
| 149 |
headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, |
| 150 |
body: JSON.stringify(body), |
| 151 |
}); |
| 152 |
|
| 153 |
test("the API door archives, and its undo is refused after 24 hours", async () => { |
| 154 |
const dry = await api("/api/v1/fleet/ops", { query: Q, action }); |
| 155 |
expect(dry.status).toBe(200); |
| 156 |
expect(repos.some(archived)).toBe(false); |
| 157 |
const done = await api("/api/v1/fleet/ops", { query: Q, action, dryRun: false }); |
| 158 |
expect(done.status).toBe(201); |
| 159 |
const op = (await done.json()) as { id: string }; |
| 160 |
expect(repos.every(archived)).toBe(true); |
| 161 |
setSystemTime(new Date(Date.now() + UNDO_WINDOW_MS + 60_000)); |
| 162 |
const late = await api(`/api/v1/fleet/ops/${op.id}/undo`, {}); |
| 163 |
expect(late.status).toBe(409); |
| 164 |
expect(JSON.stringify(await late.json())).toContain("expired"); |
| 165 |
expect(repos.every(archived)).toBe(true); |
| 166 |
}); |
| 167 |
|
| 168 |
test("the MCP door undoes an archive inside the day and not after it", async () => { |
| 169 |
const mcp = async (operation: string) => { |
| 170 |
const res = await fetch(`${servers.url}/mcp`, { |
| 171 |
method: "POST", |
| 172 |
headers: { |
| 173 |
"content-type": "application/json", |
| 174 |
accept: "application/json, text/event-stream", |
| 175 |
authorization: `Bearer ${token}`, |
| 176 |
}, |
| 177 |
body: JSON.stringify({ |
| 178 |
jsonrpc: "2.0", |
| 179 |
id: 1, |
| 180 |
method: "tools/call", |
| 181 |
params: { |
| 182 |
name: "limb", |
| 183 |
arguments: { app: "alice/one", action: "undo", operation, dryRun: false }, |
| 184 |
}, |
| 185 |
}), |
| 186 |
}); |
| 187 |
const body = (await res.json()) as any; |
| 188 |
return { error: body.result.isError === true, text: body.result.content[0].text as string }; |
| 189 |
}; |
| 190 |
const first = await applyFleetOp(app, alice, Q, action); |
| 191 |
if (!first.ok) throw new Error(first.error.message); |
| 192 |
expect((await mcp(first.value.id)).error).toBe(false); |
| 193 |
expect(repos.some(archived)).toBe(false); |
| 194 |
const second = await applyFleetOp(app, alice, Q, action); |
| 195 |
if (!second.ok) throw new Error(second.error.message); |
| 196 |
setSystemTime(new Date(Date.now() + UNDO_WINDOW_MS + 60_000)); |
| 197 |
const late = await mcp(second.value.id); |
| 198 |
expect(late.error).toBe(true); |
| 199 |
expect(late.text).toContain("expired"); |
| 200 |
expect(repos.every(archived)).toBe(true); |
| 201 |
}); |
| 202 |
}); |
Add
archiveto the actions table in docs/FLEET.md too; this one line is the only mention.