Files
stack/packages/bus/tests/fake-gitea-api.mjs

202 lines
11 KiB
JavaScript

// Row 236 (#1742, #436b r5 section 9): a fake Gitea API for the grant executor, on 127.0.0.1 over TLS
// with row 199's scratch CA. It keeps users, their tokens and repositories in memory and answers the
// routes gitea-grant.mjs calls, with Gitea's auth rules as section 1 found them: the token routes take
// Basic auth as the account itself or a site admin, the rest the `token` header. Every token is a fake
// made here. The request log holds the method, the path, the auth kind and the login, never a token.
// `rule(match, answer)` makes one route answer otherwise: a status and body, `hang` (no answer, so the
// caller's timeout runs) or `drop` (the socket closes). Nothing here leaves 127.0.0.1.
import { randomBytes } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { createServer } from 'node:https';
import { scratchCa } from '../../seat/tests/fake-gitea.mjs';
const fakeToken = () => randomBytes(20).toString('hex');
export async function fakeGiteaApi(t, dir, { version = '1.27.1', admin = 'mosaic-grant', adminScopes = ['write:admin', 'write:repository', 'write:user'] } = {}) {
const tls = scratchCa(dir);
let nextId = 1;
const users = new Map();
const repos = [];
const rules = [];
const requests = [];
const state = { version };
const lower = (x) => String(x).toLowerCase();
const userJson = (u) => ({ id: u.id, login: u.login, email: u.email, full_name: u.full_name, is_admin: u.is_admin, prohibit_login: u.prohibit_login });
const repoJson = (r, user = null) => ({
id: r.id, name: r.name, full_name: `${r.owner}/${r.name}`, owner: { login: r.owner }, fork: Boolean(r.parent),
parent: r.parent ? { full_name: r.parent } : null,
permissions: { admin: lower(user) === lower(r.owner), push: lower(user) === lower(r.owner) || (r.permissions[lower(user)] ?? 'write') === 'write', pull: true },
});
const api = {
url: null,
ca: readFileSync(tls.ca, 'utf8'),
state,
requests,
users,
repos,
// Every token value this fake made, for the tests' leak checks. Never printed.
issued: [],
// A user and, with `scopes`, one token named `tokenName`. Returns the user and that token or null.
addUser(login, { is_admin = false, prohibit_login = false, full_name = login, email = `${login}@example.test`, scopes = null, tokenName = 'test' } = {}) {
const u = { id: nextId++, login, email, full_name, is_admin, prohibit_login, tokens: [] };
users.set(lower(login), u);
return { user: u, token: scopes ? api.addToken(login, tokenName, scopes) : null };
},
addToken(login, name, scopes) {
const u = users.get(lower(login));
const tok = { id: nextId++, name, sha1: fakeToken(), scopes: [...scopes].sort() };
u.tokens.push(tok);
api.issued.push(tok.sha1);
return tok;
},
removeUser(login) {
users.delete(lower(login));
},
addRepo(owner, name, { parent = null, collaborators = [] } = {}) {
const r = { id: nextId++, owner, name, parent, collaborators: collaborators.map(lower), permissions: {} };
repos.push(r);
return r;
},
tokens: (login) => users.get(lower(login))?.tokens.map((x) => ({ id: x.id, name: x.name, scopes: x.scopes })) ?? null,
rule(match, answer) {
const r = { match, answer };
rules.push(r);
return () => rules.splice(rules.indexOf(r), 1);
},
// The ops of the log, as "METHOD path" lines without the query.
calls: () => requests.map((r) => `${r.method} ${r.path.split('?')[0]}`),
};
const adminUser = api.addUser(admin, { is_admin: true, scopes: adminScopes, tokenName: 'mosaic-grant-bootstrap' });
api.admin = adminUser.user;
api.adminToken = adminUser.token.sha1;
const byToken = (sha1) => {
for (const u of users.values()) {
const tok = u.tokens.find((x) => x.sha1 === sha1);
if (tok) return { user: u, token: tok };
}
return null;
};
const authOf = (req) => {
const h = req.headers.authorization ?? '';
if (h.startsWith('token ')) return { kind: 'token', who: byToken(h.slice(6)) };
if (h.startsWith('Basic ')) {
const [login, ...rest] = Buffer.from(h.slice(6), 'base64').toString('utf8').split(':');
const who = byToken(rest.join(':'));
return { kind: 'basic', who: who && lower(who.user.login) === lower(login) ? who : null };
}
return { kind: 'none', who: null };
};
const reachable = (u) => repos.filter((r) => lower(r.owner) === lower(u.login) || r.collaborators.includes(lower(u.login)));
function route(method, path, query, auth, body) {
const who = auth.who;
const p = path.split('/').slice(3).map(decodeURIComponent);
if (method === 'GET' && path === '/api/v1/version') return [200, { version: state.version }];
if (!who) return [401, { message: 'unauthorized' }];
const me = who.user;
if (method === 'GET' && path === '/api/v1/user') return [200, userJson(me)];
if (method === 'GET' && path === '/api/v1/token') return [200, { id: who.token.id, name: who.token.name, scopes: who.token.scopes }];
if (method === 'GET' && path === '/api/v1/user/repos') {
const all = reachable(me);
const limit = Number(query.get('limit') ?? 30);
const page = Number(query.get('page') ?? 1);
return [200, all.slice((page - 1) * limit, page * limit).map((r) => repoJson(r, me.login)), all.length];
}
if (p[0] === 'users' && p.length === 2 && method === 'GET') {
const u = users.get(lower(p[1]));
return u ? [200, userJson(u)] : [404, { message: 'user does not exist' }];
}
if (p[0] === 'admin' && p[1] === 'users') {
if (!me.is_admin || !who.token.scopes.includes('write:admin')) return [403, { message: 'forbidden' }];
if (method === 'POST' && p.length === 2) {
if (users.has(lower(body.username))) return [422, { message: 'user already exists' }];
const u = { id: nextId++, login: body.username, email: body.email, full_name: body.full_name, is_admin: false, prohibit_login: false, tokens: [] };
users.set(lower(u.login), u);
return [201, userJson(u)];
}
if (method === 'PATCH' && p.length === 3) {
const u = users.get(lower(p[2]));
if (!u) return [404, { message: 'user does not exist' }];
if (typeof body.prohibit_login === 'boolean') u.prohibit_login = body.prohibit_login;
return [200, userJson(u)];
}
}
if (p[0] === 'users' && p[2] === 'tokens') {
if (auth.kind !== 'basic') return [401, { message: 'basic auth required' }];
const u = users.get(lower(p[1]));
if (lower(me.login) !== lower(p[1]) && !me.is_admin) return [403, { message: 'forbidden' }];
if (!u) return [404, { message: 'user does not exist' }];
if (method === 'GET' && p.length === 3) {
const limit = Number(query.get('limit') ?? 30);
const page = Number(query.get('page') ?? 1);
const list = u.tokens.map((x) => ({ id: x.id, name: x.name, scopes: x.scopes }));
return [200, list.slice((page - 1) * limit, page * limit), list.length];
}
if (method === 'POST' && p.length === 3) {
if (u.tokens.some((x) => x.name === body.name)) return [400, { message: 'access token name has been used already' }];
const tok = api.addToken(u.login, body.name, body.scopes);
return [201, { id: tok.id, name: tok.name, sha1: tok.sha1, scopes: tok.scopes }];
}
if (method === 'DELETE' && p.length === 4) {
const i = u.tokens.findIndex((x) => String(x.id) === p[3] || x.name === p[3]);
if (i < 0) return [404, { message: 'token not found' }];
u.tokens.splice(i, 1);
return [204, null];
}
}
if (p[0] === 'repos' && p.length === 5 && p[3] === 'collaborators' && method === 'PUT') {
if (auth.kind !== 'token' || !me.is_admin || !who.token.scopes.includes('write:repository')) return [403, { message: 'forbidden' }];
const r = repos.find((x) => lower(x.owner) === lower(p[1]) && lower(x.name) === lower(p[2]));
if (!r || !users.has(lower(p[4]))) return [404, { message: 'not found' }];
if (!['read', 'write'].includes(body.permission)) return [422, { message: 'invalid permission' }];
if (!r.collaborators.includes(lower(p[4]))) r.collaborators.push(lower(p[4]));
r.permissions[lower(p[4])] = body.permission;
return [204, null];
}
if (p[0] === 'repos' && p.length === 3 && method === 'GET') {
const r = repos.find((x) => lower(x.owner) === lower(p[1]) && lower(x.name) === lower(p[2]));
return r ? [200, repoJson(r)] : [404, { message: 'not found' }];
}
if (p[0] === 'repos' && p.length === 4 && p[3] === 'forks' && method === 'POST') {
const up = repos.find((x) => lower(x.owner) === lower(p[1]) && lower(x.name) === lower(p[2]));
if (!up) return [404, { message: 'not found' }];
if (repos.some((x) => lower(x.owner) === lower(me.login) && lower(x.name) === lower(up.name))) return [409, { message: 'exists' }];
return [202, repoJson(api.addRepo(me.login, up.name, { parent: `${up.owner}/${up.name}` }))];
}
return [404, { message: 'no route' }];
}
const server = createServer({ key: tls.key, cert: tls.cert }, (req, res) => {
const chunks = [];
req.on('data', (c) => chunks.push(c));
req.on('end', async () => {
const u = new URL(req.url, 'https://127.0.0.1');
const auth = authOf(req);
let body = null;
try {
body = chunks.length ? JSON.parse(Buffer.concat(chunks).toString('utf8')) : null;
} catch {}
const entry = { method: req.method, path: u.pathname + u.search, auth: auth.kind, as: auth.who?.user.login ?? null };
requests.push(entry);
const rule = rules.find((r) => r.match(entry, body));
// An answer function may be async, to hold a call open until the test lets it go.
const answer = rule ? (typeof rule.answer === 'function' ? await rule.answer(entry, body) : rule.answer) : null;
if (answer?.hang) return;
if (answer?.drop) return req.socket.destroy();
const [status, json, total] = answer && !answer.pass ? [answer.status, answer.json ?? null, answer.total] : route(req.method, u.pathname, u.searchParams, auth, body ?? {});
const headers = { 'content-type': 'application/json' };
if (total !== undefined && total !== null) headers['x-total-count'] = String(total);
res.writeHead(status, headers);
res.end(json === null || status === 204 ? undefined : JSON.stringify(json));
});
});
await new Promise((r) => server.listen(0, '127.0.0.1', r));
t.after(() => new Promise((r) => {
server.closeAllConnections?.();
server.close(() => r());
}));
api.url = `https://127.0.0.1:${server.address().port}`;
return api;
}