|
|
@@ -0,0 +1,269 @@ |
| 1 |
|
| 2 |
|
| 3 |
|
| 4 |
|
| 5 |
|
| 6 |
|
| 7 |
|
| 8 |
|
| 9 |
|
| 10 |
|
| 11 |
import { afterAll, beforeAll, describe, expect, test } from "bun:test"; |
| 12 |
import { mkdtemp, rm } from "node:fs/promises"; |
| 13 |
import { tmpdir } from "node:os"; |
| 14 |
import { join } from "node:path"; |
| 15 |
import { enableApp } from "../src/actions/apps.ts"; |
| 16 |
import { openPull } from "../src/actions/pulls.ts"; |
| 17 |
import { commitToBranch, createRepo } from "../src/actions/repos.ts"; |
| 18 |
import { transferRepository } from "../src/actions/transfer.ts"; |
| 19 |
import { createApp, type App } from "../src/app.ts"; |
| 20 |
import { AppsRuntime } from "../src/apps/runtime.ts"; |
| 21 |
import type { AppRecord } from "../src/core/apps.ts"; |
| 22 |
import { forkRepo, getRepo, getRepoById, type Repo } from "../src/core/repos.ts"; |
| 23 |
import { mintRunToken } from "../src/core/run_token.ts"; |
| 24 |
import { listRuns, type Run } from "../src/core/runs.ts"; |
| 25 |
import { verifyToken } from "../src/core/tokens.ts"; |
| 26 |
import { createUser, type User } from "../src/core/users.ts"; |
| 27 |
import { repoDir } from "../src/git/repos.ts"; |
| 28 |
import { serve, type Servers } from "../src/http/server.ts"; |
| 29 |
import type { SshSession } from "../src/ssh/channels.ts"; |
| 30 |
import { serveGitCommand } from "../src/tui/ssh.ts"; |
| 31 |
import { quietLogs } from "./helpers/globals.ts"; |
| 32 |
|
| 33 |
quietLogs(); |
| 34 |
|
| 35 |
const DOMAIN = "apps.test"; |
| 36 |
|
| 37 |
const SERVER = `Bun.serve({ port: Number(process.env.PORT), hostname: "127.0.0.1", |
| 38 |
fetch: () => new Response(process.env.FORGE_TOKEN ?? "none") }); |
| 39 |
`; |
| 40 |
const MANIFEST = `${JSON.stringify({ app: { run: "bun run server.ts" } })}\n`; |
| 41 |
|
| 42 |
const git = async (cwd: string, ...args: string[]) => { |
| 43 |
const p = Bun.spawn(["git", ...args], { |
| 44 |
cwd, |
| 45 |
stdout: "pipe", |
| 46 |
stderr: "pipe", |
| 47 |
env: { ...process.env, GIT_TERMINAL_PROMPT: "0", GIT_CONFIG_GLOBAL: "/dev/null", GIT_CONFIG_SYSTEM: "/dev/null" }, |
| 48 |
}); |
| 49 |
const [out, err] = await Promise.all([new Response(p.stdout).text(), new Response(p.stderr).text()]); |
| 50 |
return { code: await p.exited, out: out.trim(), err }; |
| 51 |
}; |
| 52 |
|
| 53 |
|
| 54 |
function sshSession(command: string) { |
| 55 |
const out: string[] = []; |
| 56 |
const err: string[] = []; |
| 57 |
let code: number | undefined; |
| 58 |
const text = (d: string | Uint8Array) => (typeof d === "string" ? d : new TextDecoder().decode(d)); |
| 59 |
const session = { |
| 60 |
command, |
| 61 |
username: "git", |
| 62 |
env: {}, |
| 63 |
remoteAddress: "127.0.0.1", |
| 64 |
write: (d: string | Uint8Array) => void out.push(text(d)), |
| 65 |
writeStderr: (d: string | Uint8Array) => void err.push(text(d)), |
| 66 |
exit: (c: number) => void (code = c), |
| 67 |
onData: (cb: (d: Uint8Array) => void) => setTimeout(() => cb(new TextEncoder().encode("0000")), 50), |
| 68 |
onEof: (cb: () => void) => setTimeout(cb, 100), |
| 69 |
onClose: () => {}, |
| 70 |
drain: async () => {}, |
| 71 |
} as unknown as SshSession; |
| 72 |
return { session, out, err, code: () => code }; |
| 73 |
} |
| 74 |
|
| 75 |
describe("a moved repository's fork, preview, queued deploy and remotes follow it", () => { |
| 76 |
let dataDir = ""; |
| 77 |
let work = ""; |
| 78 |
let app: App; |
| 79 |
let servers: Servers; |
| 80 |
let runtime: AppsRuntime; |
| 81 |
let alice: User; |
| 82 |
let carol: User; |
| 83 |
let before: Repo; |
| 84 |
let record: AppRecord; |
| 85 |
let fork: Repo; |
| 86 |
let pullNumber = 0; |
| 87 |
let queued: Run; |
| 88 |
let tip = ""; |
| 89 |
|
| 90 |
async function until<T>(read: () => T | undefined, what: string, ms = 60_000): Promise<T> { |
| 91 |
const deadline = Date.now() + ms; |
| 92 |
while (Date.now() < deadline) { |
| 93 |
const value = read(); |
| 94 |
if (value !== undefined) return value; |
| 95 |
await Bun.sleep(25); |
| 96 |
} |
| 97 |
throw new Error(`${what} did not happen in time`); |
| 98 |
} |
| 99 |
const runs = () => listRuns(app.db, before.id, { kind: "deploy", limit: 50 }); |
| 100 |
const settled = (where: (r: Run) => boolean, what: string) => |
| 101 |
until(() => runs().find((r) => where(r) && r.state !== "queued" && r.state !== "running"), what); |
| 102 |
const viaHost = async (host: string) => |
| 103 |
(await fetch(`${servers.url}/`, { headers: { host }, redirect: "manual" })).text(); |
| 104 |
const ownerOfServed = async (host: string) => { |
| 105 |
const held = verifyToken(app.db, await viaHost(host)); |
| 106 |
return held.ok ? held.value.user.username : "none"; |
| 107 |
}; |
| 108 |
|
| 109 |
beforeAll(async () => { |
| 110 |
dataDir = await mkdtemp(join(tmpdir(), "forge-transfer-follow-")); |
| 111 |
work = await mkdtemp(join(tmpdir(), "forge-transfer-follow-wc-")); |
| 112 |
app = createApp({ dataDir, port: 0 }); |
| 113 |
servers = await serve(app); |
| 114 |
runtime = new AppsRuntime(app, { |
| 115 |
domain: DOMAIN, |
| 116 |
scheme: "http", |
| 117 |
publicPort: servers.port, |
| 118 |
forgeUrl: servers.url, |
| 119 |
concurrency: 1, |
| 120 |
}); |
| 121 |
await runtime.start(); |
| 122 |
const mk = async (username: string): Promise<User> => { |
| 123 |
const u = await createUser(app.db, { |
| 124 |
origin: "seed", |
| 125 |
trusted: true, |
| 126 |
username, |
| 127 |
email: `${username}@forge.local`, |
| 128 |
password: "a-long-follow-passphrase", |
| 129 |
adminIfFirst: username === "alice", |
| 130 |
}); |
| 131 |
if (!u.ok) throw new Error(JSON.stringify(u.error)); |
| 132 |
return u.value; |
| 133 |
}; |
| 134 |
alice = await mk("alice"); |
| 135 |
await mk("bob"); |
| 136 |
carol = await mk("carol"); |
| 137 |
const made = await createRepo(app, alice, { name: "lab", visibility: "public" }); |
| 138 |
if (!made.ok) throw new Error(String(made.error)); |
| 139 |
before = made.value; |
| 140 |
const first = await commitToBranch(app, alice, before, { |
| 141 |
message: "first", |
| 142 |
files: [ |
| 143 |
{ path: "forge.json", content: MANIFEST }, |
| 144 |
{ path: "server.ts", content: SERVER }, |
| 145 |
], |
| 146 |
}); |
| 147 |
if (!first.ok) throw new Error(JSON.stringify(first.error)); |
| 148 |
const enabled = enableApp(app, alice, before, { slug: "lab" }); |
| 149 |
if (!enabled.ok) throw new Error(String(enabled.error)); |
| 150 |
record = enabled.value; |
| 151 |
const prod = await settled((r) => r.env === "production", "production's first deploy"); |
| 152 |
if (prod.state !== "ok") throw new Error(`production: ${prod.summary}`); |
| 153 |
|
| 154 |
const branch = await commitToBranch(app, alice, before, { |
| 155 |
branch: "feat", |
| 156 |
base: "main", |
| 157 |
message: "feat", |
| 158 |
files: [{ path: "feat.txt", content: "feat\n" }], |
| 159 |
}); |
| 160 |
if (!branch.ok) throw new Error(JSON.stringify(branch.error)); |
| 161 |
const opened = await openPull(app, alice, before, { title: "feat", baseBranch: "main", headBranch: "feat" }); |
| 162 |
if (!opened.ok) throw new Error(JSON.stringify(opened.error)); |
| 163 |
pullNumber = opened.value.number; |
| 164 |
const preview = await settled((r) => r.env === `pull-${pullNumber}`, "the preview"); |
| 165 |
if (preview.state !== "ok") throw new Error(`preview: ${preview.summary}`); |
| 166 |
expect(await ownerOfServed(`lab--${pullNumber}.${DOMAIN}`)).toBe("alice"); |
| 167 |
|
| 168 |
const forked = await forkRepo(app.db, app.reposRoot, before, carol.id); |
| 169 |
if (!forked.ok) throw new Error(String(forked.error)); |
| 170 |
fork = forked.value; |
| 171 |
|
| 172 |
|
| 173 |
|
| 174 |
const release = await runtime.lane.acquire(); |
| 175 |
const second = await commitToBranch(app, alice, before, { |
| 176 |
message: "second", |
| 177 |
files: [{ path: "second.txt", content: "second\n" }], |
| 178 |
}); |
| 179 |
if (!second.ok) throw new Error(JSON.stringify(second.error)); |
| 180 |
tip = second.value.commitOid; |
| 181 |
queued = await until( |
| 182 |
() => runs().find((r) => r.env === "production" && r.commitOid === tip && r.state === "running"), |
| 183 |
"the waiting deploy", |
| 184 |
); |
| 185 |
const moved = await transferRepository(app, alice, before, "bob"); |
| 186 |
if (!moved.ok) throw new Error(moved.error); |
| 187 |
release(); |
| 188 |
}, 120_000); |
| 189 |
|
| 190 |
afterAll(async () => { |
| 191 |
await runtime.stop(); |
| 192 |
servers.stop(); |
| 193 |
app.close(); |
| 194 |
await rm(dataDir, { recursive: true, force: true }); |
| 195 |
await rm(work, { recursive: true, force: true }); |
| 196 |
}); |
| 197 |
|
| 198 |
test("the deploy that waited runs against the new place and serves", async () => { |
| 199 |
const done = await settled((r) => r.id === queued.id, "the waiting deploy"); |
| 200 |
expect(done.summary).not.toContain("not a git repository"); |
| 201 |
expect(done.state).toBe("ok"); |
| 202 |
}, 60_000); |
| 203 |
|
| 204 |
test("production and the running preview start again as the new owner's", async () => { |
| 205 |
for (const env of ["production", `pull-${pullNumber}`]) { |
| 206 |
const again = await settled((r) => r.env === env && r.reason === "moved", `${env} starting again`); |
| 207 |
expect(again.state).toBe("ok"); |
| 208 |
} |
| 209 |
await until( |
| 210 |
() => runtime.status(record).find((e) => e.env === "production" && e.commitOid === tip && e.state === "running"), |
| 211 |
"production serving the new tip", |
| 212 |
); |
| 213 |
expect(await ownerOfServed(`lab.${DOMAIN}`)).toBe("bob"); |
| 214 |
expect(await ownerOfServed(`lab--${pullNumber}.${DOMAIN}`)).toBe("bob"); |
| 215 |
}, 60_000); |
| 216 |
|
| 217 |
test("the fork keeps its parent and still reads the objects it shares", async () => { |
| 218 |
const now = getRepoById(app.db, fork.id)!; |
| 219 |
expect(now.forkParentId).toBe(before.id); |
| 220 |
const read = await git(repoDir(app.reposRoot, "carol", "lab"), "cat-file", "-t", `${tip}~1`); |
| 221 |
expect(read.out).toBe("commit"); |
| 222 |
}); |
| 223 |
|
| 224 |
test("a run token bound to the repository follows the old API path to the new one", async () => { |
| 225 |
const token = mintRunToken(app.db, carol.id, "r1", { repoId: before.id, forkId: fork.id }, "2999-01-01T00:00:00Z").token; |
| 226 |
const headers = { authorization: `Bearer ${token}` }; |
| 227 |
const old = await fetch(`${servers.url}/api/v1/repos/alice/lab`, { headers, redirect: "manual" }); |
| 228 |
expect(old.status).toBe(308); |
| 229 |
expect(old.headers.get("location")).toBe("/api/v1/repos/bob/lab"); |
| 230 |
const there = await fetch(`${servers.url}/api/v1/repos/bob/lab`, { headers }); |
| 231 |
expect(there.status).toBe(200); |
| 232 |
}); |
| 233 |
|
| 234 |
test("git fetch against the old HTTPS remote works", async () => { |
| 235 |
expect((await git(work, "clone", `${servers.url}/alice/lab.git`, "wc")).code).toBe(0); |
| 236 |
expect((await git(join(work, "wc"), "fetch", "origin")).code).toBe(0); |
| 237 |
expect((await git(join(work, "wc"), "rev-parse", "origin/main")).out).toBe(tip); |
| 238 |
}); |
| 239 |
|
| 240 |
test("ssh under the old path serves the repository and names the new remote once", async () => { |
| 241 |
const s = sshSession("git-upload-pack '/alice/lab.git'"); |
| 242 |
await serveGitCommand(app, s.session, carol); |
| 243 |
expect(s.code()).toBe(0); |
| 244 |
expect(s.out.join("")).toContain(`${tip} HEAD`); |
| 245 |
const said = s.err.join("").split("\n").filter((l) => l.includes("moved to bob/lab")); |
| 246 |
expect(said).toHaveLength(1); |
| 247 |
expect(said[0]).toContain("git remote set-url origin"); |
| 248 |
}); |
| 249 |
|
| 250 |
test("an ssh push to the old path is refused, and does not take the path back", async () => { |
| 251 |
const s = sshSession("git-receive-pack '/alice/lab.git'"); |
| 252 |
await serveGitCommand(app, s.session, alice); |
| 253 |
expect(s.code()).toBe(1); |
| 254 |
expect(s.err.join("")).toContain("Nothing was pushed"); |
| 255 |
expect(getRepo(app.db, "alice", "lab")).toBeUndefined(); |
| 256 |
}); |
| 257 |
|
| 258 |
test("someone who cannot read it gets what a missing path answers", async () => { |
| 259 |
const hidden = await createRepo(app, alice, { name: "vault", visibility: "private" }); |
| 260 |
if (!hidden.ok) throw new Error(String(hidden.error)); |
| 261 |
const moved = await transferRepository(app, alice, hidden.value, "bob"); |
| 262 |
expect(moved.ok).toBe(true); |
| 263 |
const s = sshSession("git-upload-pack '/alice/vault.git'"); |
| 264 |
await serveGitCommand(app, s.session, carol); |
| 265 |
expect(s.code()).toBe(1); |
| 266 |
expect(s.err.join("")).toContain("repository not found: alice/vault"); |
| 267 |
expect(s.err.join("")).not.toContain("bob"); |
| 268 |
}); |
| 269 |
}); |