diff --git a/modules/gitea/test/create-repo.test.ts b/modules/gitea/test/create-repo.test.ts new file mode 100644 index 00000000..f7a464c5 --- /dev/null +++ b/modules/gitea/test/create-repo.test.ts @@ -0,0 +1,117 @@ +// A repository is created where it is asked for (novox/hq issue 1124): gitea_create_repo created under the tools' +// own user alone, so a repository of the novox organisation could not be made through it. With `owner` it is created +// in that organisation (POST /orgs/{org}/repos), the forge's refusal said with its status and its reason; without +// it, under the tools' own user as before. + +import { test, after, beforeEach } from "node:test"; +import assert from "node:assert/strict"; +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; + +import { GiteaClient } from "../dist/client.js"; +import { ConfiguredToken } from "../dist/token.js"; +import { getGiteaTools } from "../dist/tools/index.js"; + +interface Sent { method: string; path: string; body: any } + +const state = { + sent: [] as Sent[], + // When set, what the forge answers a create in an organisation: its status and its message. + refuse: null as { status: number; message: string } | null, +}; + +function json(res: ServerResponse, status: number, body: unknown) { + res.statusCode = status; + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify(body)); +} + +const server = createServer(async (req: IncomingMessage, res: ServerResponse) => { + const chunks: Buffer[] = []; + for await (const c of req) chunks.push(c as Buffer); + const raw = Buffer.concat(chunks).toString(); + const path = new URL(req.url ?? "", "http://x").pathname.replace(/^\/api\/v1/, ""); + const method = req.method ?? "GET"; + state.sent.push({ method, path, body: raw ? JSON.parse(raw) : undefined }); + const created = (owner: string) => { + const body = raw ? JSON.parse(raw) : {}; + return json(res, 201, { full_name: `${owner}/${body.name}`, name: body.name, owner: { login: owner }, + private: body.private, description: body.description, html_url: `http://forge/${owner}/${body.name}`, + clone_url: `http://forge/${owner}/${body.name}.git`, default_branch: "main" }); + }; + if (method === "POST" && path === "/user/repos") return created("agent"); + const org = /^\/orgs\/([^/]+)\/repos$/.exec(path); + if (method === "POST" && org) { + if (state.refuse) return json(res, state.refuse.status, { message: state.refuse.message, url: "http://forge/api/swagger" }); + return created(decodeURIComponent(org[1])); + } + return json(res, 404, { message: "not found" }); +}); +await new Promise((r) => server.listen(0, "127.0.0.1", () => r())); +after(() => new Promise((r) => server.close(() => r()))); +const { port } = server.address() as { port: number }; +const client = new GiteaClient(`http://127.0.0.1:${port}`, new ConfiguredToken("t")); +const tool = (name: string) => getGiteaTools(client).find((t) => t.name === name)!; +const body = { name: "mesh-delivery", description: "the delivery's own repository", private: true, auto_init: true }; + +beforeEach(() => { + state.sent = []; + state.refuse = null; +}); + +test("with an organisation the repository is created there, with the same body, and answered (1124/AC1)", async () => { + // Fails before the fix: createRepo takes no owner and posts to /user/repos, so the repository is the tools' user's. + const repo = await client.createRepo({ ...body }, "novox"); + assert.deepEqual(state.sent, [{ method: "POST", path: "/orgs/novox/repos", body }]); + assert.equal(repo.full_name, "novox/mesh-delivery"); + assert.equal(repo.owner, "novox"); + + // The tool passes its owner through; before the fix it has no owner argument and posts to /user/repos. + state.sent = []; + const answered = await tool("gitea_create_repo").run({ owner: "novox", ...body }) as { repo: { full_name: string } }; + assert.deepEqual(state.sent, [{ method: "POST", path: "/orgs/novox/repos", body }]); + assert.equal(answered.repo.full_name, "novox/mesh-delivery"); + assert.match(tool("gitea_create_repo").description, /organisation/, "the description says where it creates"); +}); + +test("without an owner the repository is created under the tools' own user, as before (1124/AC2)", async () => { + // Holds before the fix: it is the behaviour the fix must leave unchanged. + const repo = await client.createRepo({ ...body }); + assert.deepEqual(state.sent, [{ method: "POST", path: "/user/repos", body }]); + assert.equal(repo.full_name, "agent/mesh-delivery"); + + state.sent = []; + await tool("gitea_create_repo").run({ name: "mesh-delivery" }); + assert.deepEqual(state.sent, [{ method: "POST", path: "/user/repos", body: { name: "mesh-delivery", private: true, auto_init: true } }]); +}); + +test("the forge's refusal to create in an organisation is said with its status, its reason and the organisation (1124/AC3)", async () => { + // Fails before the fix: the create goes to /user/repos, which the fake forge answers 201, so nothing is refused. + for (const [status, message] of [ + [403, "user should be an owner or a member of the organisation"], + [404, "GetOrgByName: org does not exist [name: novox]"], + [409, "The repository with the same name already exists."], + ] as const) { + state.refuse = { status, message }; + for (const run of [ + () => client.createRepo({ ...body }, "novox"), + () => tool("gitea_create_repo").run({ owner: "novox", ...body }), + ]) { + await assert.rejects(run(), (e: Error) => { + assert.match(e.message, new RegExp(`\\b${status}\\b`), `the status ${status} is said`); + assert.ok(e.message.includes(message), `the forge's reason is said: ${e.message}`); + assert.match(e.message, /organisation novox/, "the organisation is named"); + return true; + }); + } + } +}); + +test("an owner that is not a name is refused before anything is sent (1124/AC4)", async () => { + // Holds before the fix too: namesChecked (tools/index.ts) checks args.owner for every tool. It stays so the guard + // keeps covering this tool's new argument. + for (const owner of ["..", "novox/../agent", "", "no vox", "%2e%2e", "-x"]) { + await assert.rejects(tool("gitea_create_repo").run({ owner, name: "mesh-delivery" }), /not a repository owner/, + JSON.stringify(owner)); + } + assert.deepEqual(state.sent, [], "nothing reached the forge"); +});