import { describe, it } from "node:test"; import assert from "node:assert/strict"; import { authenticate } from "../../src/webapi/middleware/authenticate.js"; import type { BackendDbInterface } from "../../src/db/types/DbInterface.js"; import type { ClientUser } from "../../src/db/types/User.js"; import type { ClientApikey } from "../../src/db/types/Apikey.js"; import { ConflictAbodeError, NotAuthorizedAbodeError, NotFoundAbodeError, } from "../../src/db/types/errors.js"; const MOCK_USER: ClientUser = { uid: "11111111-1111-1111-1111-111111111111", email: "test@example.com", name: "Test User", flags: {}, created_at: "2024-01-01T00:00:00Z", updated_at: "2024-01-01T00:00:00Z", }; const MOCK_APIKEY: ClientApikey = { kid: "22222222-2222-2222-2222-222222222222", uid: MOCK_USER.uid, name: "Test Key", permissions: {}, created_at: "2024-01-01T00:00:00Z", expires_at: null, }; function makeMockDb( overrides: Partial = {}, ): BackendDbInterface { return { readonly: false, backend: true, name: "mock", close: async () => {}, listUsers: async () => [], getUserById: async () => { throw new NotFoundAbodeError(); }, deleteUserById: async () => {}, createUser: async () => MOCK_USER, updateUser: async () => MOCK_USER, getUserByEmail: async () => { throw new NotFoundAbodeError(); }, listAbodes: async () => [], 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, }), listResidents: async () => [], 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, }), listResidentsByUserId: async () => [], listResidentsByAbodeId: async () => [], listUsersByAbodeId: async () => [], listAbodesByUserId: async () => [], listNotes: async () => [], getNoteById: async () => { throw new NotFoundAbodeError(); }, deleteNoteById: async () => {}, 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}`, ], deleteApikeyById: async () => {}, getUserByLogin: async () => { throw new NotFoundAbodeError(); }, getUserBySession: async () => { throw new NotFoundAbodeError(); }, createSession: async () => `as_${"0".repeat(32)}`, deleteSession: async () => {}, getUserByApikey: async () => { throw new NotFoundAbodeError(); }, ...overrides, }; } type MockCtx = { headers: Record; cookieJar: Record; clearedCookies: Set; status: number; body: unknown; user?: ClientUser; session?: unknown; get(header: string): string; cookies: { get(name: string): string | undefined; set(name: string, value: string, opts?: unknown): void; }; }; function makeMockCtx( headerOverrides: Record = {}, cookieOverrides: Record = {}, ): MockCtx { const clearedCookies = new Set(); const ctx: MockCtx = { headers: headerOverrides, cookieJar: cookieOverrides, clearedCookies, status: 200, body: null, user: undefined, session: undefined, get(header: string) { return this.headers[header] ?? this.headers[header.toLowerCase()] ?? ""; }, cookies: { get(name: string) { return cookieOverrides[name]; }, set(name: string, value: string, opts?: unknown) { if ( value === "" || (opts && (opts as { expires?: Date }).expires?.getFullYear()! < 2000) ) { clearedCookies.add(name); } }, }, }; return ctx; } async function runMiddleware( db: BackendDbInterface, ctx: MockCtx, ): Promise { let nextCalled = false; const mw = authenticate(db); await mw(ctx as any, async () => { nextCalled = true; }); return nextCalled; } describe("authenticate middleware", () => { describe("Basic auth", () => { it("valid credentials → sets user and session, calls next", async () => { const db = makeMockDb({ getUserByLogin: async () => MOCK_USER }); const encoded = btoa("test@example.com:password"); const ctx = makeMockCtx({ Authorization: `Basic ${encoded}` }); const next = await runMiddleware(db, ctx); assert.equal(next, true); assert.deepEqual(ctx.user, MOCK_USER); assert.deepEqual(ctx.session, { source: "basic" }); }); it("malformed base64 → 400", async () => { const db = makeMockDb(); const ctx = makeMockCtx({ Authorization: "Basic !!not-base64!!" }); await runMiddleware(db, ctx); assert.equal(ctx.status, 400); }); it("missing colon in decoded value → 400", async () => { const db = makeMockDb(); const ctx = makeMockCtx({ Authorization: "Basic " + btoa("nocolon") }); await runMiddleware(db, ctx); assert.equal(ctx.status, 400); }); it("wrong password (NotAuthorizedAbodeError) → 401 invalid_password", async () => { const db = makeMockDb({ getUserByLogin: async () => { throw new NotAuthorizedAbodeError(); }, }); const ctx = makeMockCtx({ Authorization: "Basic " + btoa("user:wrong") }); await runMiddleware(db, ctx); assert.equal(ctx.status, 401); assert.deepEqual((ctx.body as any)?.error, "invalid_password"); }); it("unknown user (NotFoundAbodeError) → 401 unknown_user", async () => { const db = makeMockDb({ getUserByLogin: async () => { throw new NotFoundAbodeError(); }, }); 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"); }); it("ConflictAbodeError (#unset password) → 401 user_not_loggable", async () => { const db = makeMockDb({ getUserByLogin: async () => { throw new ConflictAbodeError(); }, }); const ctx = makeMockCtx({ Authorization: "Basic " + btoa("user:pass") }); await runMiddleware(db, ctx); assert.equal(ctx.status, 401); assert.deepEqual((ctx.body as any)?.error, "user_not_loggable"); }); it("uses X-Abode-Authorization header when Authorization is absent", async () => { const db = makeMockDb({ getUserByLogin: async () => MOCK_USER }); const encoded = btoa("test@example.com:password"); const ctx = makeMockCtx({ "X-Abode-Authorization": `Basic ${encoded}` }); const next = await runMiddleware(db, ctx); assert.equal(next, true); assert.ok(ctx.user); }); }); describe("Bearer (API key)", () => { const validToken = `at_${"a".repeat(32)}` as `at_${string}`; it("valid at_ token → sets user and apikey session, calls next", async () => { const db = makeMockDb({ getUserByApikey: async () => [MOCK_USER, MOCK_APIKEY], }); const ctx = makeMockCtx({ Authorization: `Bearer ${validToken}` }); const next = await runMiddleware(db, ctx); assert.equal(next, true); assert.deepEqual(ctx.user, MOCK_USER); assert.deepEqual(ctx.session, { source: "apikey", key: MOCK_APIKEY }); }); it("invalid/expired at_ token → 401 invalid_apikey", async () => { const db = makeMockDb({ getUserByApikey: async () => { throw new NotFoundAbodeError(); }, }); const ctx = makeMockCtx({ Authorization: `Bearer ${validToken}` }); await runMiddleware(db, ctx); assert.equal(ctx.status, 401); assert.deepEqual((ctx.body as any)?.error, "invalid_apikey"); }); it("bearer token without at_ prefix → 401 unrecognized_bearer", async () => { const db = makeMockDb(); const ctx = makeMockCtx({ Authorization: "Bearer not-an-apikey-token" }); await runMiddleware(db, ctx); assert.equal(ctx.status, 401); assert.deepEqual((ctx.body as any)?.error, "unrecognized_bearer"); }); }); describe("session cookie", () => { const validToken = `as_${"b".repeat(32)}`; it("valid session cookie → sets user and session, calls next", async () => { const db = makeMockDb({ getUserBySession: async () => MOCK_USER, }); const ctx = makeMockCtx({}, { abode_session: validToken }); const next = await runMiddleware(db, ctx); assert.equal(next, true); assert.deepEqual(ctx.user, MOCK_USER); assert.deepEqual(ctx.session, { source: "session" }); }); it("invalid session token format clears cookie and falls through to 401", async () => { 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.equal(ctx.status, 401); }); it("expired/unknown session token clears cookie and returns 401", async () => { const db = makeMockDb({ 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.equal(ctx.status, 401); }); }); describe("no auth", () => { it("no credentials → 401 not_authenticated", async () => { const db = makeMockDb(); const ctx = makeMockCtx(); const next = await runMiddleware(db, ctx); assert.equal(next, false); assert.equal(ctx.status, 401); assert.deepEqual((ctx.body as any)?.error, "not_authenticated"); }); }); });