diff --git a/package.json b/package.json index bba0eff..05a4b12 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,9 @@ "test": "node --import tsx/esm --import ./src/meta/dev/register.ts --test $(find test -name '*.test.ts' | sort)", "test:backends": "node --import tsx/esm --import ./src/meta/dev/register.ts --test $(find test/backends -name '*.test.ts' | sort)", "test:shared": "node --import tsx/esm --import ./src/meta/dev/register.ts --test $(find test/backends -name 'index.test.ts' | sort)", - "test:tools": "node --import tsx/esm --import ./src/meta/dev/register.ts --test $(find test/tools -name '*.test.ts' | sort)" + "test:tools": "node --import tsx/esm --import ./src/meta/dev/register.ts --test $(find test/tools -name '*.test.ts' | sort)", + "typecheck": "tsc --noEmit", + "typecheck:test": "tsc --noEmit -p tsconfig.test.json" }, "dependencies": { "@koa/bodyparser": "^6.0.0", diff --git a/test/backends/api/auth-http.test.ts b/test/backends/api/auth-http.test.ts new file mode 100644 index 0000000..9d76b2b --- /dev/null +++ b/test/backends/api/auth-http.test.ts @@ -0,0 +1,134 @@ +import { describe, it, before, after } from "node:test"; +import assert from "node:assert/strict"; +import { createTestDb } from "../../helpers/sqlite.js"; +import { createTestServer, type TestServer } from "../../helpers/koa.js"; +import { hashPassword } from "../../../src/util/hash.js"; +import type { SqliteInterface } from "../../../src/db/sqlite/SqliteInterface.js"; + +const EMAIL = "auth-http@test.example"; +const PASSWORD = "auth-http-password"; + +function getCookie(res: Response, name: string): string | undefined { + const raw = res.headers.getSetCookie?.() ?? []; + for (const entry of raw) { + const [pair] = entry.split(";"); + const [key, value] = pair.split("="); + if (key === name) return value; + } + return undefined; +} + +describe("api backend: auth over HTTP", async () => { + let db: SqliteInterface; + let closeDb: () => void; + let server: TestServer; + let uid: string; + + before(async () => { + ({ db, close: closeDb } = await createTestDb()); + server = await createTestServer(db); + const pw = await hashPassword(PASSWORD); + const user = await db.createUser({ + email: EMAIL, + name: "Auth HTTP User", + password: pw, + flags: {}, + }); + uid = user.uid; + }); + + after(async () => { + await server.close(); + closeDb(); + }); + + it("POST /auth/login sets a session cookie", async () => { + const res = await fetch(`${server.url}/auth/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email: EMAIL, password: PASSWORD }), + }); + assert.equal(res.status, 200); + const cookie = getCookie(res, "abode_session"); + assert.ok(cookie, "session cookie set"); + }); + + it("session cookie authenticates GET /auth/self", async () => { + const login = await fetch(`${server.url}/auth/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email: EMAIL, password: PASSWORD }), + }); + const cookie = getCookie(login, "abode_session"); + + const self = await fetch(`${server.url}/auth/self`, { + headers: { Cookie: `abode_session=${cookie}` }, + }); + assert.equal(self.status, 200); + const body = (await self.json()) as { uid: string }; + assert.equal(body.uid, uid); + }); + + it("GET /auth/self without credentials returns 401", async () => { + const res = await fetch(`${server.url}/auth/self`); + assert.equal(res.status, 401); + }); + + it("POST /auth/logout clears the session cookie", async () => { + const login = await fetch(`${server.url}/auth/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email: EMAIL, password: PASSWORD }), + }); + const cookie = getCookie(login, "abode_session"); + + const logout = await fetch(`${server.url}/auth/logout`, { + method: "POST", + headers: { Cookie: `abode_session=${cookie}` }, + }); + assert.equal(logout.status, 204); + assert.equal(getCookie(logout, "abode_session"), ""); + }); + + it("POST /auth/clear-sessions invalidates outstanding session cookies", async () => { + const login = await fetch(`${server.url}/auth/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email: EMAIL, password: PASSWORD }), + }); + const cookie = getCookie(login, "abode_session"); + + const clear = await fetch(`${server.url}/auth/clear-sessions`, { + method: "POST", + headers: { Cookie: `abode_session=${cookie}` }, + }); + assert.equal(clear.status, 204); + + const self = await fetch(`${server.url}/auth/self`, { + headers: { Cookie: `abode_session=${cookie}` }, + }); + assert.equal(self.status, 401); + }); + + it("Bearer apikey token authenticates protected routes", async () => { + const [, token] = await db.createApikey({ + uid, + name: "HTTP Test Key", + permissions: { all: true }, + }); + + const res = await fetch(`${server.url}/users`, { + headers: { Authorization: `Bearer ${token}` }, + }); + assert.equal(res.status, 200); + }); + + it("invalid Bearer apikey token returns 401 invalid_apikey", async () => { + const res = await fetch(`${server.url}/users`, { + headers: { Authorization: `Bearer at_${"0".repeat(32)}` }, + }); + assert.equal(res.status, 401); + const body = (await res.json()) as { error: string }; + assert.equal(body.error, "invalid_apikey"); + }); +}); diff --git a/test/backends/api/index.test.ts b/test/backends/api/index.test.ts index e98efe1..c05ee64 100644 --- a/test/backends/api/index.test.ts +++ b/test/backends/api/index.test.ts @@ -33,7 +33,20 @@ async function getApiDb() { }; } -runUserTests("api", getApiDb); +async function getReadonlyApiDb() { + const { db: sqliteDb, close: closeSqlite } = await createTestDb(); + const server = await createTestServer(sqliteDb); + const api = new ApiInterface(server.url, { readonly: true }); + return { + db: api, + close: async () => { + await server.close(); + closeSqlite(); + }, + }; +} + +runUserTests("api", getApiDb, getReadonlyApiDb); runAbodeTests("api", getApiDb); runResidentTests("api", getApiDb); runApikeyTests("api", getApiDb); diff --git a/test/backends/sqlite/index.test.ts b/test/backends/sqlite/index.test.ts index 103a494..459be89 100644 --- a/test/backends/sqlite/index.test.ts +++ b/test/backends/sqlite/index.test.ts @@ -1,6 +1,6 @@ import type { BackendDbInterface } from "../../../src/db/types/DbInterface.js"; import { SqliteInterface } from "../../../src/db/sqlite/SqliteInterface.js"; -import { createTestDb } from "../../helpers/sqlite.js"; +import { createTestDb, createReadonlyTestDb } from "../../helpers/sqlite.js"; import { runUserTests } from "../../shared/users.js"; import { runAbodeTests } from "../../shared/abodes.js"; import { runResidentTests } from "../../shared/residents.js"; @@ -30,7 +30,7 @@ async function createExpiredApikey( return token; } -runUserTests("sqlite", createTestDb); +runUserTests("sqlite", createTestDb, createReadonlyTestDb); runAbodeTests("sqlite", createTestDb); runResidentTests("sqlite", createTestDb); runApikeyTests("sqlite", createTestDb); diff --git a/test/helpers/sqlite.ts b/test/helpers/sqlite.ts index 94d2a2a..1ebfd86 100644 --- a/test/helpers/sqlite.ts +++ b/test/helpers/sqlite.ts @@ -16,3 +16,9 @@ export async function createTestDb(): Promise { const db = new SqliteInterface(wrapped); return { db, wrapped, close: () => wrapped.destroy() }; } + +export async function createReadonlyTestDb(): Promise { + const wrapped = getWrappedDb("node", ":memory:", { readonly: true }); + const db = new SqliteInterface(wrapped); + return { db, wrapped, close: () => wrapped.destroy() }; +} diff --git a/test/shared/users.ts b/test/shared/users.ts index 18c6813..f20fd2c 100644 --- a/test/shared/users.ts +++ b/test/shared/users.ts @@ -10,12 +10,13 @@ import { hashPassword } from "../../src/util/hash.js"; export function runUserTests( name: string, - getDb: () => Promise<{ db: DbInterface; close(): void }> + getDb: () => Promise<{ db: DbInterface; close(): void }>, + getReadonlyDb?: () => Promise<{ db: DbInterface; close(): void }> ): void { describe(`${name}: users`, async () => { let db: DbInterface; let close: () => void; - let hashedPw: string; + let hashedPw: Awaited>; before(async () => { ({ db, close } = await getDb()); @@ -64,6 +65,7 @@ export function runUserTests( const email = `user-byemail-${Date.now()}@test.example`; await db.createUser({ email, name: "ByEmail User", password: hashedPw, flags: {} }); const found = await db.getUserByEmail(email); + assert.ok("email" in found, "result includes email"); assert.equal(found.email, email); }); @@ -150,17 +152,21 @@ export function runUserTests( describe(`${name}: users readonly`, async () => { let db: DbInterface; let close: () => void; - let hashedPw: string; + let hashedPw: Awaited>; before(async () => { - ({ db, close } = await getDb()); + if (getReadonlyDb) ({ db, close } = await getReadonlyDb()); hashedPw = await hashPassword("test-password"); }); - after(() => close()); + after(() => close?.()); - it("createUser on readonly db throws ReadonlyAbodeError", async () => { - if (!db.readonly) return; + it("createUser on readonly db throws ReadonlyAbodeError", async (t) => { + if (!getReadonlyDb) { + t.skip("getReadonlyDb helper not provided for this backend"); + return; + } + assert.ok(db.readonly, "test db is readonly"); await assert.rejects( () => db.createUser({ diff --git a/tsconfig.test.json b/tsconfig.test.json new file mode 100644 index 0000000..670bb02 --- /dev/null +++ b/tsconfig.test.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": "." + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "test/**/*.ts"] +}