ci: add pull request quality gates
CI / install-and-build (pull_request) Successful in 1m29s
CI / format (pull_request) Successful in 51s
CI / typecheck-source (pull_request) Successful in 41s
CI / typecheck-tests (pull_request) Successful in 39s
CI / test (pull_request) Successful in 51s
CI / lint (pull_request) Successful in 21s

Co-Authored-By: gpt-5.6-terra <noreply@openai.com>
This commit was merged in pull request #12.
This commit is contained in:
2026-07-22 21:50:29 +00:00
co-authored by Codex
parent b9fa79ff1f
commit 31d4636dde
80 changed files with 2007 additions and 398 deletions
+110 -29
View File
@@ -29,7 +29,7 @@ const MOCK_APIKEY: ClientApikey = {
};
function makeMockDb(
overrides: Partial<BackendDbInterface> = {}
overrides: Partial<BackendDbInterface> = {},
): BackendDbInterface {
return {
readonly: false,
@@ -37,42 +37,97 @@ function makeMockDb(
name: "mock",
close: async () => {},
listUsers: async () => [],
getUserById: async () => { throw new NotFoundAbodeError(); },
getUserById: async () => {
throw new NotFoundAbodeError();
},
deleteUserById: async () => {},
createUser: async () => MOCK_USER,
updateUser: async () => MOCK_USER,
getUserByEmail: async () => { throw new NotFoundAbodeError(); },
getUserByEmail: async () => {
throw new NotFoundAbodeError();
},
listAbodes: async () => [],
getAbodeById: async () => { throw new NotFoundAbodeError(); },
getAbodeById: async () => {
throw new NotFoundAbodeError();
},
deleteAbodeById: async () => {},
createAbode: async () => ({ aid: "a", name: "A", created_at: "", created_by: null, updated_at: "", updated_by: null }),
updateAbode: async () => ({ aid: "a", name: "A", created_at: "", created_by: null, updated_at: "", updated_by: null }),
createAbode: async () => ({
aid: "a",
name: "A",
created_at: "",
created_by: null,
updated_at: "",
updated_by: null,
}),
updateAbode: async () => ({
aid: "a",
name: "A",
created_at: "",
created_by: null,
updated_at: "",
updated_by: null,
}),
listResidents: async () => [],
getResidentById: async () => { throw new NotFoundAbodeError(); },
getResidentById: async () => {
throw new NotFoundAbodeError();
},
deleteResidentById: async () => {},
createResident: async () => ({ uid: "", aid: "", flags: {}, created_at: "", created_by: null, updated_at: "", updated_by: null }),
updateResident: async () => ({ uid: "", aid: "", flags: {}, created_at: "", created_by: null, updated_at: "", updated_by: null }),
createResident: async () => ({
uid: "",
aid: "",
flags: {},
created_at: "",
created_by: null,
updated_at: "",
updated_by: null,
}),
updateResident: async () => ({
uid: "",
aid: "",
flags: {},
created_at: "",
created_by: null,
updated_at: "",
updated_by: null,
}),
listResidentsByUserId: async () => [],
listResidentsByAbodeId: async () => [],
listUsersByAbodeId: async () => [],
listAbodesByUserId: async () => [],
listNotes: async () => [],
getNoteById: async () => { throw new NotFoundAbodeError(); },
getNoteById: async () => {
throw new NotFoundAbodeError();
},
deleteNoteById: async () => {},
createNote: async () => { throw new Error("unimplemented"); },
updateNote: async () => { throw new Error("unimplemented"); },
createNote: async () => {
throw new Error("unimplemented");
},
updateNote: async () => {
throw new Error("unimplemented");
},
listNotesByAbodeId: async () => [],
listNotesByUserId: async () => [],
deleteSessionsByUser: async () => {},
listApikeysByUser: async () => [],
getApikeyById: async () => { throw new NotFoundAbodeError(); },
createApikey: async () => [MOCK_APIKEY, "at_" + "0".repeat(32) as `at_${string}`],
getApikeyById: async () => {
throw new NotFoundAbodeError();
},
createApikey: async () => [
MOCK_APIKEY,
("at_" + "0".repeat(32)) as `at_${string}`,
],
deleteApikeyById: async () => {},
getUserByLogin: async () => { throw new NotFoundAbodeError(); },
getUserBySession: async () => { throw new NotFoundAbodeError(); },
getUserByLogin: async () => {
throw new NotFoundAbodeError();
},
getUserBySession: async () => {
throw new NotFoundAbodeError();
},
createSession: async () => `as_${"0".repeat(32)}`,
deleteSession: async () => {},
getUserByApikey: async () => { throw new NotFoundAbodeError(); },
getUserByApikey: async () => {
throw new NotFoundAbodeError();
},
...overrides,
};
}
@@ -92,7 +147,10 @@ type MockCtx = {
};
};
function makeMockCtx(headerOverrides: Record<string, string> = {}, cookieOverrides: Record<string, string> = {}): MockCtx {
function makeMockCtx(
headerOverrides: Record<string, string> = {},
cookieOverrides: Record<string, string> = {},
): MockCtx {
const clearedCookies = new Set<string>();
const ctx: MockCtx = {
headers: headerOverrides,
@@ -110,7 +168,10 @@ function makeMockCtx(headerOverrides: Record<string, string> = {}, cookieOverrid
return cookieOverrides[name];
},
set(name: string, value: string, opts?: unknown) {
if (value === "" || (opts && (opts as { expires?: Date }).expires?.getFullYear()! < 2000)) {
if (
value === "" ||
(opts && (opts as { expires?: Date }).expires?.getFullYear()! < 2000)
) {
clearedCookies.add(name);
}
},
@@ -121,11 +182,13 @@ function makeMockCtx(headerOverrides: Record<string, string> = {}, cookieOverrid
async function runMiddleware(
db: BackendDbInterface,
ctx: MockCtx
ctx: MockCtx,
): Promise<boolean> {
let nextCalled = false;
const mw = authenticate(db);
await mw(ctx as any, async () => { nextCalled = true; });
await mw(ctx as any, async () => {
nextCalled = true;
});
return nextCalled;
}
@@ -157,7 +220,9 @@ describe("authenticate middleware", () => {
it("wrong password (NotAuthorizedAbodeError) → 401 invalid_password", async () => {
const db = makeMockDb({
getUserByLogin: async () => { throw new NotAuthorizedAbodeError(); },
getUserByLogin: async () => {
throw new NotAuthorizedAbodeError();
},
});
const ctx = makeMockCtx({ Authorization: "Basic " + btoa("user:wrong") });
await runMiddleware(db, ctx);
@@ -167,9 +232,13 @@ describe("authenticate middleware", () => {
it("unknown user (NotFoundAbodeError) → 401 unknown_user", async () => {
const db = makeMockDb({
getUserByLogin: async () => { throw new NotFoundAbodeError(); },
getUserByLogin: async () => {
throw new NotFoundAbodeError();
},
});
const ctx = makeMockCtx({
Authorization: "Basic " + btoa("nobody:pass"),
});
const ctx = makeMockCtx({ Authorization: "Basic " + btoa("nobody:pass") });
await runMiddleware(db, ctx);
assert.equal(ctx.status, 401);
assert.deepEqual((ctx.body as any)?.error, "unknown_user");
@@ -177,7 +246,9 @@ describe("authenticate middleware", () => {
it("ConflictAbodeError (#unset password) → 401 user_not_loggable", async () => {
const db = makeMockDb({
getUserByLogin: async () => { throw new ConflictAbodeError(); },
getUserByLogin: async () => {
throw new ConflictAbodeError();
},
});
const ctx = makeMockCtx({ Authorization: "Basic " + btoa("user:pass") });
await runMiddleware(db, ctx);
@@ -211,7 +282,9 @@ describe("authenticate middleware", () => {
it("invalid/expired at_ token → 401 invalid_apikey", async () => {
const db = makeMockDb({
getUserByApikey: async () => { throw new NotFoundAbodeError(); },
getUserByApikey: async () => {
throw new NotFoundAbodeError();
},
});
const ctx = makeMockCtx({ Authorization: `Bearer ${validToken}` });
await runMiddleware(db, ctx);
@@ -246,17 +319,25 @@ describe("authenticate middleware", () => {
const db = makeMockDb();
const ctx = makeMockCtx({}, { abode_session: "not-a-session-token" });
await runMiddleware(db, ctx);
assert.ok(ctx.clearedCookies.has("abode_session"), "cookie should be cleared");
assert.ok(
ctx.clearedCookies.has("abode_session"),
"cookie should be cleared",
);
assert.equal(ctx.status, 401);
});
it("expired/unknown session token clears cookie and returns 401", async () => {
const db = makeMockDb({
getUserBySession: async () => { throw new NotFoundAbodeError(); },
getUserBySession: async () => {
throw new NotFoundAbodeError();
},
});
const ctx = makeMockCtx({}, { abode_session: validToken });
await runMiddleware(db, ctx);
assert.ok(ctx.clearedCookies.has("abode_session"), "cookie should be cleared");
assert.ok(
ctx.clearedCookies.has("abode_session"),
"cookie should be cleared",
);
assert.equal(ctx.status, 401);
});
});
+3 -1
View File
@@ -25,7 +25,9 @@ describe("convertError middleware", () => {
it("does not interfere when next succeeds", async () => {
const ctx = makeCtx();
let nextCalled = false;
await convertError(ctx as any, async () => { nextCalled = true; });
await convertError(ctx as any, async () => {
nextCalled = true;
});
assert.equal(nextCalled, true);
assert.equal(ctx.status, 200);
});
+20 -14
View File
@@ -8,7 +8,7 @@ import { jsonBody } from "../../src/webapi/middleware/jsonBody.js";
async function request(
url: string,
opts: { method?: string; body?: unknown; contentType?: string } = {}
opts: { method?: string; body?: unknown; contentType?: string } = {},
): Promise<{ status: number; body: unknown }> {
const method = opts.method ?? "POST";
const bodyStr =
@@ -21,18 +21,19 @@ async function request(
const res = await fetch(url, { method, headers, body: bodyStr });
const text = await res.text();
let parsed: unknown;
try { parsed = JSON.parse(text); } catch { parsed = text; }
try {
parsed = JSON.parse(text);
} catch {
parsed = text;
}
return { status: res.status, body: parsed };
}
async function makeTestServer() {
const failValidator = Object.assign(
(_obj: unknown): _obj is never => false,
{
errors: [{ message: "required" }] as unknown[],
schema: { $id: "test-schema", title: "Test", description: "" },
}
);
const failValidator = Object.assign((_obj: unknown): _obj is never => false, {
errors: [{ message: "required" }] as unknown[],
schema: { $id: "test-schema", title: "Test", description: "" },
});
const app = new Koa();
const router = new KoaRouter();
@@ -48,7 +49,7 @@ async function makeTestServer() {
async (ctx) => {
ctx.status = 200;
ctx.body = { ok: true };
}
},
);
router.post(
@@ -57,7 +58,7 @@ async function makeTestServer() {
async (ctx) => {
ctx.status = 200;
ctx.body = { ok: true, body: ctx.request.body };
}
},
);
app.use(router.routes());
@@ -69,7 +70,7 @@ async function makeTestServer() {
const url = `http://127.0.0.1:${port}`;
const close = () =>
new Promise<void>((resolve, reject) =>
server.close((err) => (err ? reject(err) : resolve()))
server.close((err) => (err ? reject(err) : resolve())),
);
return { url, close };
}
@@ -107,11 +108,16 @@ describe("jsonBody middleware", () => {
});
it("failing validator → 400 jsonchema_validation_failed with schema and errors", async () => {
const res = await request(url + "/fail-validate", { body: { any: "thing" } });
const res = await request(url + "/fail-validate", {
body: { any: "thing" },
});
assert.equal(res.status, 400);
assert.equal((res.body as any).error, "jsonchema_validation_failed");
assert.ok((res.body as any).schema, "response includes schema");
assert.ok(Array.isArray((res.body as any).errors), "response includes errors");
assert.ok(
Array.isArray((res.body as any).errors),
"response includes errors",
);
});
it("includeParams: param absent from body → merged in", async () => {