202 lines
11 KiB
JavaScript
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;
|
|
}
|