From da4e597f73737a664408517fe3619178e3f8ce7b Mon Sep 17 00:00:00 2001 From: Codinget Date: Tue, 30 Jun 2026 11:33:32 +0000 Subject: [PATCH] feat(tests): add unit and integration test suite (node:test) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds 215 tests across three tiers using node:test + node:assert/strict (no new test framework dependencies): - test/tools/ — middleware and utility tests (token, hash, authenticate, jsonBody, convertError, validators) - test/shared/ — DbInterface/BackendDbInterface contract suites reusable across backends (users, abodes, residents, apikeys, sessions, auth) - test/backends/sqlite/ — SQLite-private tests (sql builder, WrappedDb, migrator) + shared suites via SqliteInterface - test/backends/api/ — ApiInterface unit tests + shared suites via a live Koa server backed by SQLite Also fixes four bugs uncovered by the tests: - ApiInterface: path params leaked into query string (slice(1) fix) - ApiInterface: calling res.json() on 204 No Content responses - SqliteInterface.updateResident: missing comma in SET clause - WrappedBetterSqlite3Db: readonly:undefined rejected by better-sqlite3 Co-Authored-By: Claude Sonnet 4.6 --- package.json | 6 +- src/db/api/ApiInterface.ts | 3 +- src/db/sqlite/SqliteInterface.ts | 2 +- src/db/sqlite/impl/better-sqlite3.ts | 1 + test/backends/api/api-interface.test.ts | 211 ++++++++++++++++++ test/backends/api/index.test.ts | 39 ++++ test/backends/sqlite/index.test.ts | 38 ++++ test/backends/sqlite/migrator.test.ts | 85 ++++++++ test/backends/sqlite/sql.test.ts | 134 ++++++++++++ test/backends/sqlite/wrapped-db.test.ts | 111 ++++++++++ test/helpers/koa.ts | 29 +++ test/helpers/sqlite.ts | 18 ++ test/shared/abodes.ts | 109 ++++++++++ test/shared/apikeys.ts | 102 +++++++++ test/shared/auth.ts | 136 ++++++++++++ test/shared/residents.ts | 154 +++++++++++++ test/shared/sessions.ts | 65 ++++++ test/shared/users.ts | 179 ++++++++++++++++ test/tools/authenticate.test.ts | 273 ++++++++++++++++++++++++ test/tools/convertError.test.ts | 74 +++++++ test/tools/hash.test.ts | 39 ++++ test/tools/jsonBody.test.ts | 140 ++++++++++++ test/tools/token.test.ts | 86 ++++++++ test/tools/validators.test.ts | 181 ++++++++++++++++ 24 files changed, 2212 insertions(+), 3 deletions(-) create mode 100644 test/backends/api/api-interface.test.ts create mode 100644 test/backends/api/index.test.ts create mode 100644 test/backends/sqlite/index.test.ts create mode 100644 test/backends/sqlite/migrator.test.ts create mode 100644 test/backends/sqlite/sql.test.ts create mode 100644 test/backends/sqlite/wrapped-db.test.ts create mode 100644 test/helpers/koa.ts create mode 100644 test/helpers/sqlite.ts create mode 100644 test/shared/abodes.ts create mode 100644 test/shared/apikeys.ts create mode 100644 test/shared/auth.ts create mode 100644 test/shared/residents.ts create mode 100644 test/shared/sessions.ts create mode 100644 test/shared/users.ts create mode 100644 test/tools/authenticate.test.ts create mode 100644 test/tools/convertError.test.ts create mode 100644 test/tools/hash.test.ts create mode 100644 test/tools/jsonBody.test.ts create mode 100644 test/tools/token.test.ts create mode 100644 test/tools/validators.test.ts diff --git a/package.json b/package.json index 958b9f7..bba0eff 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,11 @@ "abode-tui": "tsx --import ./src/meta/dev/register.ts --import ./src/meta/dev/silenthot.ts src/bin/abode-tui.ts", "abode-sources": "tsx --import ./src/meta/dev/register.ts src/bin/abode-sources.ts", "build": "NODE_ENV=production npm run build:impl", - "build:impl": "rm -rf dist && tsx node_modules/.bin/webpack && chmod +x dist/bin/* && chmod -x dist/bin/*.*" + "build:impl": "rm -rf dist && tsx node_modules/.bin/webpack && chmod +x dist/bin/* && chmod -x dist/bin/*.*", + "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)" }, "dependencies": { "@koa/bodyparser": "^6.0.0", diff --git a/src/db/api/ApiInterface.ts b/src/db/api/ApiInterface.ts index 4073a52..bb85948 100644 --- a/src/db/api/ApiInterface.ts +++ b/src/db/api/ApiInterface.ts @@ -55,7 +55,7 @@ export class ApiInterface implements DbInterface { .map((part) => { if (part.startsWith(":")) { const value = remaining.get(part.slice(1)); - remaining.delete(part); + remaining.delete(part.slice(1)); if (value === undefined) throw new Error(`Missing ${part} in params`); return encodeURIComponent(value); @@ -113,6 +113,7 @@ export class ApiInterface implements DbInterface { throw new Error(`${res.status} ${res.statusText} ${text}`); } } + if (res.status === 204) return undefined as T; return res.json(); } diff --git a/src/db/sqlite/SqliteInterface.ts b/src/db/sqlite/SqliteInterface.ts index 8e217b9..b61f9ba 100644 --- a/src/db/sqlite/SqliteInterface.ts +++ b/src/db/sqlite/SqliteInterface.ts @@ -319,7 +319,7 @@ export class SqliteInterface implements BackendDbInterface { UPDATE "residents" SET "updated_at" = datetime('now', 'localtime', 'subsec'), - "updated_by" = ${{ uuid: ctx.uid }} + "updated_by" = ${{ uuid: ctx.uid }}, ${joinSql(updates, sql`, `)} WHERE "uid" = ${{ uuid: resident.uid }} diff --git a/src/db/sqlite/impl/better-sqlite3.ts b/src/db/sqlite/impl/better-sqlite3.ts index 2e06876..d7994c0 100644 --- a/src/db/sqlite/impl/better-sqlite3.ts +++ b/src/db/sqlite/impl/better-sqlite3.ts @@ -10,6 +10,7 @@ function getDatabase( ): sqlite.Database { if (!natives.sqlite) throw new Error("No natives found for better-sqlite3"); options = { ...options }; + if (typeof options.readonly !== "boolean") delete options.readonly; if (typeof options.timeout !== "number") delete options.timeout; const db = new Sqlite(path, { ...options, nativeBinding: natives.sqlite }); db.exec(pragma); diff --git a/test/backends/api/api-interface.test.ts b/test/backends/api/api-interface.test.ts new file mode 100644 index 0000000..4813f54 --- /dev/null +++ b/test/backends/api/api-interface.test.ts @@ -0,0 +1,211 @@ +import { describe, it, before, after } from "node:test"; +import assert from "node:assert/strict"; +import { createServer } from "node:http"; +import type { AddressInfo } from "node:net"; +import { ApiInterface } from "../../../src/db/api/ApiInterface.js"; +import { apiProtocols, isApiUrl, parseApiUrl } from "../../../src/db/api/url.js"; +import { + NotFoundAbodeError, + NotAuthorizedAbodeError, + ReadonlyAbodeError, + InvalidAbodeError, + ConflictAbodeError, +} from "../../../src/db/types/errors.js"; + +describe("ApiInterface static properties", () => { + it("name is 'api'", () => { + const api = new ApiInterface("http://localhost:9999"); + assert.equal(api.name, "api"); + }); + + it("backend is false", () => { + const api = new ApiInterface("http://localhost:9999"); + assert.equal(api.backend, false); + }); + + it("readonly defaults to false", () => { + const api = new ApiInterface("http://localhost:9999"); + assert.equal(api.readonly, false); + }); + + it("readonly is set from options", () => { + const api = new ApiInterface("http://localhost:9999", { readonly: true }); + assert.equal(api.readonly, true); + }); +}); + +describe("apiProtocols and isApiUrl", () => { + it("apiProtocols includes expected protocols", () => { + assert.ok(apiProtocols.includes("https:")); + assert.ok(apiProtocols.includes("http:")); + assert.ok(apiProtocols.includes("abode+https:")); + assert.ok(apiProtocols.includes("abode+http:")); + }); + + it("isApiUrl returns true for http/https urls", () => { + assert.equal(isApiUrl("http://example.com"), true); + assert.equal(isApiUrl("https://example.com/api"), true); + assert.equal(isApiUrl("abode+http://example.com"), true); + assert.equal(isApiUrl("abode+https://example.com"), true); + }); + + it("isApiUrl returns false for non-http urls", () => { + assert.equal(isApiUrl("sqlite:///db.sqlite"), false); + assert.equal(isApiUrl("not-a-url"), false); + assert.equal(isApiUrl("ftp://example.com"), false); + }); +}); + +describe("parseApiUrl", () => { + it("strips abode+ prefix from protocol", () => { + const [root] = parseApiUrl("abode+http://example.com"); + assert.ok(root.startsWith("http://"), `expected http:// got ${root}`); + }); + + it("extracts Basic auth from URL credentials", () => { + const [, { headers }] = parseApiUrl("http://user:pass@example.com"); + assert.ok(headers["Authorization"]?.startsWith("Basic "), "has Basic auth"); + const decoded = atob(headers["Authorization"]!.slice("Basic ".length)); + assert.equal(decoded, "user:pass"); + }); + + it("strips credentials from root URL", () => { + const [root] = parseApiUrl("http://user:pass@example.com"); + assert.ok(!root.includes("user"), "credentials stripped from root"); + }); + + it("extracts readonly flag from query", () => { + const [, { readonly }] = parseApiUrl("http://example.com?readonly=1"); + assert.equal(readonly, true); + }); + + it("defaults readonly to false", () => { + const [, { readonly }] = parseApiUrl("http://example.com"); + assert.equal(readonly, false); + }); + + it("extra query params become headers", () => { + const [, { headers }] = parseApiUrl( + "http://example.com?X-Custom-Header=value" + ); + assert.equal(headers["X-Custom-Header"], "value"); + }); + + it("throws for non-api protocol", () => { + assert.throws( + () => parseApiUrl("sqlite:///db.sqlite"), + /Not an \{abode\+,\}http\{s,\}: protocol/ + ); + }); +}); + +describe("ApiInterface HTTP error mapping", () => { + let serverUrl: string; + let closeServer: () => Promise; + let respondWith: (status: number) => void; + + before(async () => { + let nextStatus = 500; + respondWith = (s) => { nextStatus = s; }; + + const server = createServer((req, res) => { + res.writeHead(nextStatus, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ ok: false, error: "test" })); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const { port } = server.address() as AddressInfo; + serverUrl = `http://127.0.0.1:${port}`; + closeServer = () => + new Promise((resolve, reject) => + server.close((err) => (err ? reject(err) : resolve())) + ); + }); + + after(() => closeServer()); + + it("404 response throws NotFoundAbodeError", async () => { + respondWith(404); + const api = new ApiInterface(serverUrl); + await assert.rejects( + () => api.listUsers(), + (err) => { + assert.ok(err instanceof NotFoundAbodeError); + return true; + } + ); + }); + + it("401 response throws NotAuthorizedAbodeError", async () => { + respondWith(401); + const api = new ApiInterface(serverUrl); + await assert.rejects( + () => api.listUsers(), + (err) => { + assert.ok(err instanceof NotAuthorizedAbodeError); + return true; + } + ); + }); + + it("403 response throws ReadonlyAbodeError", async () => { + respondWith(403); + const api = new ApiInterface(serverUrl); + await assert.rejects( + () => api.listUsers(), + (err) => { + assert.ok(err instanceof ReadonlyAbodeError); + return true; + } + ); + }); + + it("400 response throws InvalidAbodeError", async () => { + respondWith(400); + const api = new ApiInterface(serverUrl); + await assert.rejects( + () => api.listUsers(), + (err) => { + assert.ok(err instanceof InvalidAbodeError); + return true; + } + ); + }); + + it("409 response throws ConflictAbodeError", async () => { + respondWith(409); + const api = new ApiInterface(serverUrl); + await assert.rejects( + () => api.listUsers(), + (err) => { + assert.ok(err instanceof ConflictAbodeError); + return true; + } + ); + }); +}); + +describe("ApiInterface._ internal helpers", () => { + it("_.url builds correct URL for path params (no leftover query param)", () => { + const api = new ApiInterface("http://example.com"); + const url = api._.url("/users/:uid", { uid: "abc-123" }); + assert.equal(url, "http://example.com/users/abc-123"); + }); + + it("_.url puts remaining params as query string", () => { + const api = new ApiInterface("http://example.com"); + const url = api._.url("/users/by-email", { email: "a@b.com" }); + assert.ok(url.includes("email="), `expected query param in ${url}`); + }); + + it("_.root matches the constructor argument", () => { + const api = new ApiInterface("http://example.com/api"); + assert.equal(api._.root, "http://example.com/api"); + }); + + it("_.headers includes Authorization when set", () => { + const api = new ApiInterface("http://example.com", { + headers: { Authorization: "Basic dGVzdA==" }, + }); + assert.equal(api._.headers["Authorization"], "Basic dGVzdA=="); + }); +}); diff --git a/test/backends/api/index.test.ts b/test/backends/api/index.test.ts new file mode 100644 index 0000000..e98efe1 --- /dev/null +++ b/test/backends/api/index.test.ts @@ -0,0 +1,39 @@ +import { createTestDb } from "../../helpers/sqlite.js"; +import { createTestServer } from "../../helpers/koa.js"; +import { ApiInterface } from "../../../src/db/api/ApiInterface.js"; +import { hashPassword } from "../../../src/util/hash.js"; +import { runUserTests } from "../../shared/users.js"; +import { runAbodeTests } from "../../shared/abodes.js"; +import { runResidentTests } from "../../shared/residents.js"; +import { runApikeyTests } from "../../shared/apikeys.js"; + +const AUTH_EMAIL = "api-auth@test.example"; +const AUTH_PASSWORD = "api-auth-password"; + +async function getApiDb() { + const { db: sqliteDb, close: closeSqlite } = await createTestDb(); + const pw = await hashPassword(AUTH_PASSWORD); + await sqliteDb.createUser({ + email: AUTH_EMAIL, + name: "API Auth User", + password: pw, + flags: {}, + }); + const server = await createTestServer(sqliteDb); + const authHeader = "Basic " + btoa(`${AUTH_EMAIL}:${AUTH_PASSWORD}`); + const api = new ApiInterface(server.url, { + headers: { Authorization: authHeader }, + }); + return { + db: api, + close: async () => { + await server.close(); + closeSqlite(); + }, + }; +} + +runUserTests("api", getApiDb); +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 new file mode 100644 index 0000000..103a494 --- /dev/null +++ b/test/backends/sqlite/index.test.ts @@ -0,0 +1,38 @@ +import type { BackendDbInterface } from "../../../src/db/types/DbInterface.js"; +import { SqliteInterface } from "../../../src/db/sqlite/SqliteInterface.js"; +import { createTestDb } from "../../helpers/sqlite.js"; +import { runUserTests } from "../../shared/users.js"; +import { runAbodeTests } from "../../shared/abodes.js"; +import { runResidentTests } from "../../shared/residents.js"; +import { runApikeyTests } from "../../shared/apikeys.js"; +import { runSessionTests } from "../../shared/sessions.js"; +import { runAuthTests } from "../../shared/auth.js"; + +async function createExpiredApikey( + db: BackendDbInterface, + uid: string +): Promise<`at_${string}`> { + const si = db as SqliteInterface; + const token = (`at_${"e".repeat(32)}`) as `at_${string}`; + const kid = crypto.randomUUID(); + const { sql } = si._; + si._.db.run(sql` + INSERT INTO "apikeys"("uid", "kid", "token", "name", "permissions", "expires_at") + VALUES( + ${{ uuid: uid }}, + ${{ uuid: kid }}, + ${{ text: token }}, + ${{ text: "Expired Key" }}, + ${{ jsonb: {} }}, + ${{ date: new Date(Date.now() - 10000).toISOString() }} + ) + `); + return token; +} + +runUserTests("sqlite", createTestDb); +runAbodeTests("sqlite", createTestDb); +runResidentTests("sqlite", createTestDb); +runApikeyTests("sqlite", createTestDb); +runSessionTests("sqlite", createTestDb); +runAuthTests("sqlite", createTestDb, createExpiredApikey); diff --git a/test/backends/sqlite/migrator.test.ts b/test/backends/sqlite/migrator.test.ts new file mode 100644 index 0000000..ecd18d4 --- /dev/null +++ b/test/backends/sqlite/migrator.test.ts @@ -0,0 +1,85 @@ +import { describe, it, before, after } from "node:test"; +import assert from "node:assert/strict"; +import { WrappedNodeSqliteDb } from "../../../src/db/sqlite/impl/node-sqlite.js"; +import { SqliteMigrator } from "../../../src/db/sqlite/SqliteMigrator.js"; +import { migrations } from "../../../src/db/sqlite/migrations/index.js"; + +describe("SqliteMigrator", () => { + it("listAvailableMigrations returns all known migrations", () => { + const db = new WrappedNodeSqliteDb(":memory:"); + const migrator = new SqliteMigrator(db); + const available = migrator.listAvailableMigrations(); + assert.ok(Array.isArray(available)); + assert.ok(available.length >= 3, "at least 3 migrations"); + const ids = available.map((m) => m.id); + assert.ok(ids.includes(1), "migration 1 present"); + assert.ok(ids.includes(2), "migration 2 present"); + assert.ok(ids.includes(3), "migration 3 present"); + db.destroy(); + }); + + it("listAppliedMigrations returns empty array on fresh db", async () => { + const db = new WrappedNodeSqliteDb(":memory:"); + const migrator = new SqliteMigrator(db); + const applied = await migrator.listAppliedMigrations(); + assert.deepEqual(applied, []); + db.destroy(); + }); + + it("migrateTo(1) applies first migration", async () => { + const db = new WrappedNodeSqliteDb(":memory:"); + const migrator = new SqliteMigrator(db); + await migrator.migrateTo(1); + const applied = await migrator.listAppliedMigrations(); + assert.equal(applied.length, 1); + assert.equal(applied[0].id, 1); + assert.ok(applied[0].name, "migration has a name"); + assert.ok(applied[0].applied_at, "migration has applied_at"); + db.destroy(); + }); + + it("migrateTo(3) applies all three migrations in order", async () => { + const db = new WrappedNodeSqliteDb(":memory:"); + const migrator = new SqliteMigrator(db); + await migrator.migrateTo(3); + const applied = await migrator.listAppliedMigrations(); + assert.equal(applied.length, 3); + assert.deepEqual( + applied.map((m) => m.id), + [1, 2, 3] + ); + db.destroy(); + }); + + it("migrateTo(3) twice is idempotent (nothing to do)", async () => { + const db = new WrappedNodeSqliteDb(":memory:"); + const migrator = new SqliteMigrator(db); + await migrator.migrateTo(3); + await migrator.migrateTo(3); + const applied = await migrator.listAppliedMigrations(); + assert.equal(applied.length, 3); + db.destroy(); + }); + + it("migrateTo with unknown id throws", async () => { + const db = new WrappedNodeSqliteDb(":memory:"); + const migrator = new SqliteMigrator(db); + await assert.rejects( + () => migrator.migrateTo(9999), + /No known migration with id 9999/ + ); + db.destroy(); + }); + + it("listAvailableMigrations names match migration objects", () => { + const db = new WrappedNodeSqliteDb(":memory:"); + const migrator = new SqliteMigrator(db); + const available = migrator.listAvailableMigrations(); + for (const { id, name } of available) { + const migration = migrations.find((m) => m.id === id); + assert.ok(migration, `migration ${id} exists`); + assert.equal(migration.name, name); + } + db.destroy(); + }); +}); diff --git a/test/backends/sqlite/sql.test.ts b/test/backends/sqlite/sql.test.ts new file mode 100644 index 0000000..b0d6ee3 --- /dev/null +++ b/test/backends/sqlite/sql.test.ts @@ -0,0 +1,134 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { sql, catSql, joinSql, calcUpdates, unsafeSql } from "../../../src/db/sqlite/sql.js"; + +describe("sql template tag", () => { + it("produces correct sql and empty vars for plain text", () => { + const result = sql`SELECT 1`; + assert.equal(result._sql, "SELECT 1"); + assert.deepEqual(result._vars, []); + }); + + it("binds text args with ?", () => { + const result = sql`WHERE name = ${{ text: "alice" }}`; + assert.equal(result._sql, "WHERE name = ?"); + assert.deepEqual(result._vars, ["alice"]); + }); + + it("binds uuid args with ? and converts to Buffer", () => { + const uuid = "12345678-1234-1234-1234-123456789abc"; + const result = sql`WHERE uid = ${{ uuid }}`; + assert.equal(result._sql, "WHERE uid = ?"); + assert.equal(result._vars.length, 1); + assert.ok(result._vars[0] instanceof Buffer, "uuid is stored as Buffer"); + }); + + it("binds jsonb args with jsonb(?) wrapper", () => { + const result = sql`SET flags = ${{ jsonb: { admin: true } }}`; + assert.equal(result._sql, "SET flags = jsonb(?)"); + assert.deepEqual(result._vars, [JSON.stringify({ admin: true })]); + }); + + it("binds date args with datetime(?, ...) wrapper", () => { + const date = "2024-01-01T00:00:00.000Z"; + const result = sql`SET ts = ${{ date }}`; + assert.ok(result._sql.startsWith("SET ts = datetime("), result._sql); + assert.equal(result._vars.length, 1); + }); + + it("binds int args with ?", () => { + const result = sql`LIMIT ${{ int: 10 }}`; + assert.equal(result._sql, "LIMIT ?"); + assert.deepEqual(result._vars, [10]); + }); + + it("throws for non-integer int value", () => { + assert.throws(() => sql`LIMIT ${{ int: 10.5 }}`, /Not an integer/); + }); + + it("emits NULL for null args", () => { + const result = sql`= ${{ null: true }}`; + assert.equal(result._sql, "= NULL"); + assert.deepEqual(result._vars, []); + }); + + it("splices nested SqlCode", () => { + const inner = sql`AND x = ${{ text: "foo" }}`; + const outer = sql`WHERE 1=1 ${inner}`; + assert.equal(outer._sql, "WHERE 1=1 AND x = ?"); + assert.deepEqual(outer._vars, ["foo"]); + }); + + it("handles multiple args", () => { + const result = sql`INSERT INTO t(a,b) VALUES(${{ text: "x" }}, ${{ int: 42 }})`; + assert.equal(result._sql, "INSERT INTO t(a,b) VALUES(?, ?)"); + assert.deepEqual(result._vars, ["x", 42]); + }); +}); + +describe("catSql", () => { + it("concatenates sql and vars", () => { + const a = sql`SELECT * FROM t`; + const b = sql` WHERE x = ${{ text: "y" }}`; + const result = catSql(a, b); + assert.equal(result._sql, "SELECT * FROM t WHERE x = ?"); + assert.deepEqual(result._vars, ["y"]); + }); +}); + +describe("joinSql", () => { + it("joins multiple parts with separator", () => { + const parts = [ + sql`a = ${{ text: "1" }}`, + sql`b = ${{ text: "2" }}`, + sql`c = ${{ text: "3" }}`, + ]; + const result = joinSql(parts, sql`, `); + assert.equal(result._sql, "a = ?, b = ?, c = ?"); + assert.deepEqual(result._vars, ["1", "2", "3"]); + }); + + it("returns single part unchanged (no separator)", () => { + const result = joinSql([sql`x = ${{ int: 1 }}`], sql`, `); + assert.equal(result._sql, "x = ?"); + assert.deepEqual(result._vars, [1]); + }); +}); + +describe("calcUpdates", () => { + it("returns only keys present in the object", () => { + const calc = calcUpdates({ + name: (v: string) => sql`name = ${{ text: v }}`, + email: (v: string) => sql`email = ${{ text: v }}`, + }); + const updates = calc({ name: "alice" }); + assert.equal(updates.length, 1); + assert.equal(updates[0]._sql, "name = ?"); + assert.deepEqual(updates[0]._vars, ["alice"]); + }); + + it("returns all keys when all are present", () => { + const calc = calcUpdates({ + a: (v: string) => sql`a = ${{ text: v }}`, + b: (v: string) => sql`b = ${{ text: v }}`, + }); + const updates = calc({ a: "x", b: "y" }); + assert.equal(updates.length, 2); + }); + + it("returns empty array when no keys match", () => { + const calc = calcUpdates({ + name: (v: string) => sql`name = ${{ text: v }}`, + }); + const updates = calc({}); + assert.equal(updates.length, 0); + }); +}); + +describe("unsafeSql", () => { + it("wraps a raw sql string with no vars", () => { + const result = unsafeSql("CREATE TABLE t (id INTEGER)"); + assert.equal(result._sql, "CREATE TABLE t (id INTEGER)"); + assert.deepEqual(result._vars, []); + }); +}); diff --git a/test/backends/sqlite/wrapped-db.test.ts b/test/backends/sqlite/wrapped-db.test.ts new file mode 100644 index 0000000..2ad6fba --- /dev/null +++ b/test/backends/sqlite/wrapped-db.test.ts @@ -0,0 +1,111 @@ +import { describe, it, before, after } from "node:test"; +import assert from "node:assert/strict"; +import { WrappedNodeSqliteDb } from "../../../src/db/sqlite/impl/node-sqlite.js"; +import { sql, unsafeSql } from "../../../src/db/sqlite/sql.js"; +import type { WrappedDb } from "../../../src/db/sqlite/impl/types.js"; + +function runWrappedDbSuite(name: string, makeDb: () => WrappedDb) { + describe(`${name}: WrappedDb`, () => { + let db: WrappedDb; + + before(() => { + db = makeDb(); + db.run(unsafeSql("CREATE TABLE IF NOT EXISTS test (id INTEGER PRIMARY KEY, val TEXT NOT NULL)")); + }); + + after(() => db.destroy()); + + it("run INSERT returns changes count", () => { + const { changes } = db.run(sql`INSERT INTO test(val) VALUES(${{ text: "hello" }})`); + assert.equal(changes, 1); + }); + + it("all SELECT returns all rows", () => { + db.run(unsafeSql("DELETE FROM test")); + db.run(sql`INSERT INTO test(val) VALUES(${{ text: "a" }})`); + db.run(sql`INSERT INTO test(val) VALUES(${{ text: "b" }})`); + const rows = db.all<{ val: string }>(unsafeSql("SELECT val FROM test ORDER BY val")); + assert.equal(rows.length, 2); + assert.equal(rows[0].val, "a"); + assert.equal(rows[1].val, "b"); + }); + + it("get returns single row or null", () => { + db.run(unsafeSql("DELETE FROM test")); + db.run(sql`INSERT INTO test(val) VALUES(${{ text: "one" }})`); + const row = db.get<{ val: string }>(unsafeSql("SELECT val FROM test")); + assert.ok(row !== null); + assert.equal(row.val, "one"); + + const none = db.get<{ val: string }>( + sql`SELECT val FROM test WHERE val = ${{ text: "none" }}` + ); + assert.equal(none, null); + }); + + it("get throws when multiple rows match", () => { + db.run(unsafeSql("DELETE FROM test")); + db.run(sql`INSERT INTO test(val) VALUES(${{ text: "dup1" }})`); + db.run(sql`INSERT INTO test(val) VALUES(${{ text: "dup2" }})`); + assert.throws( + () => db.get<{ val: string }>(unsafeSql("SELECT val FROM test")), + /Multiple results/ + ); + }); + + it("multi commits on success", () => { + db.run(unsafeSql("DELETE FROM test")); + db.multi(() => { + db.run(sql`INSERT INTO test(val) VALUES(${{ text: "in-tx" }})`); + }); + const rows = db.all<{ val: string }>(unsafeSql("SELECT val FROM test")); + assert.equal(rows.length, 1); + assert.equal(rows[0].val, "in-tx"); + }); + + it("multi rolls back on error", () => { + db.run(unsafeSql("DELETE FROM test")); + assert.throws(() => + db.multi(() => { + db.run(sql`INSERT INTO test(val) VALUES(${{ text: "rollback" }})`); + throw new Error("abort!"); + }) + ); + const rows = db.all<{ val: string }>(unsafeSql("SELECT val FROM test")); + assert.equal(rows.length, 0); + }); + + it("rethrow passes through return value", () => { + const result = db.rethrow(() => 42); + assert.equal(result, 42); + }); + + it("rethrow propagates non-SQLite errors unchanged", () => { + const err = new Error("custom error"); + assert.throws(() => db.rethrow(() => { throw err; }), (e) => e === err); + }); + }); +} + +runWrappedDbSuite("node-sqlite", () => new WrappedNodeSqliteDb(":memory:")); + +describe("better-sqlite3 WrappedDb", async () => { + let bs3Ctor: (new (path: string) => WrappedDb) | null = null; + + try { + const mod = await import( + "../../../src/db/sqlite/impl/better-sqlite3.js" + ); + bs3Ctor = mod.WrappedBetterSqlite3Db; + } catch { + // better-sqlite3 not available, skip + } + + if (bs3Ctor) { + runWrappedDbSuite("better-sqlite3", () => new bs3Ctor!(":memory:")); + } else { + it("better-sqlite3 is not available - skipped", (t) => { + t.skip("better-sqlite3 optional dependency not found"); + }); + } +}); diff --git a/test/helpers/koa.ts b/test/helpers/koa.ts new file mode 100644 index 0000000..9e57bb2 --- /dev/null +++ b/test/helpers/koa.ts @@ -0,0 +1,29 @@ +import Koa from "koa"; +import { createServer } from "node:http"; +import type { AddressInfo } from "node:net"; +import { apirouter } from "../../src/webapi/apirouter.js"; +import type { BackendDbInterface } from "../../src/db/types/DbInterface.js"; + +export interface TestServer { + url: string; + close(): Promise; +} + +export async function createTestServer( + db: BackendDbInterface +): Promise { + const app = new Koa(); + const router = apirouter(db); + app.use(router.routes()); + app.use(router.allowedMethods()); + const server = createServer(app.callback()); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const { port } = server.address() as AddressInfo; + return { + url: `http://127.0.0.1:${port}`, + close: () => + new Promise((resolve, reject) => + server.close((err) => (err ? reject(err) : resolve())) + ), + }; +} diff --git a/test/helpers/sqlite.ts b/test/helpers/sqlite.ts new file mode 100644 index 0000000..94d2a2a --- /dev/null +++ b/test/helpers/sqlite.ts @@ -0,0 +1,18 @@ +import { getWrappedDb } from "../../src/db/sqlite/impl/index.js"; +import { SqliteMigrator } from "../../src/db/sqlite/SqliteMigrator.js"; +import { SqliteInterface } from "../../src/db/sqlite/SqliteInterface.js"; +import type { WrappedDb } from "../../src/db/sqlite/impl/types.js"; + +export interface TestDb { + db: SqliteInterface; + wrapped: WrappedDb; + close(): void; +} + +export async function createTestDb(): Promise { + const wrapped = getWrappedDb("node", ":memory:", {}); + const migrator = new SqliteMigrator(wrapped); + await migrator.migrateTo(3); + const db = new SqliteInterface(wrapped); + return { db, wrapped, close: () => wrapped.destroy() }; +} diff --git a/test/shared/abodes.ts b/test/shared/abodes.ts new file mode 100644 index 0000000..d535c29 --- /dev/null +++ b/test/shared/abodes.ts @@ -0,0 +1,109 @@ +import { describe, it, before, after } from "node:test"; +import assert from "node:assert/strict"; +import type { DbInterface } from "../../src/db/types/DbInterface.js"; +import { NotFoundAbodeError, InvalidAbodeError } from "../../src/db/types/errors.js"; +import { hashPassword } from "../../src/util/hash.js"; + +export function runAbodeTests( + name: string, + getDb: () => Promise<{ db: DbInterface; close(): void }> +): void { + describe(`${name}: abodes`, async () => { + let db: DbInterface; + let close: () => void; + let ctxUid: string; + + before(async () => { + ({ db, close } = await getDb()); + const pw = await hashPassword("abode-ctx"); + const user = await db.createUser({ + email: `abode-ctx-${Date.now()}@test.example`, + name: "Abode Ctx User", + password: pw, + flags: {}, + }); + ctxUid = user.uid; + }); + + after(() => close()); + + it("createAbode returns an Abode with expected fields", async () => { + const abode = await db.createAbode({ name: "Test Abode" }, { uid: ctxUid }); + assert.ok(abode.aid, "has aid"); + assert.equal(abode.name, "Test Abode"); + assert.ok(abode.created_at); + assert.ok(abode.updated_at); + }); + + it("getAbodeById returns the created abode", async () => { + const created = await db.createAbode({ name: "ById Abode" }, { uid: ctxUid }); + const found = await db.getAbodeById(created.aid); + assert.equal(found.aid, created.aid); + assert.equal(found.name, "ById Abode"); + }); + + it("getAbodeById throws NotFoundAbodeError for unknown aid", async () => { + await assert.rejects( + () => db.getAbodeById("00000000-0000-0000-0000-000000000000"), + (err) => { + assert.ok(err instanceof NotFoundAbodeError); + return true; + } + ); + }); + + it("listAbodes includes the created abode", async () => { + const created = await db.createAbode( + { name: `Listed Abode ${Date.now()}` }, + { uid: ctxUid } + ); + const abodes = await db.listAbodes(); + assert.ok(Array.isArray(abodes)); + const found = abodes.find((a) => a.aid === created.aid); + assert.ok(found, "created abode appears in listAbodes"); + }); + + it("updateAbode updates the name", async () => { + const created = await db.createAbode({ name: "Before" }, { uid: ctxUid }); + const updated = await db.updateAbode( + { aid: created.aid, name: "After" }, + { uid: ctxUid } + ); + assert.equal(updated.aid, created.aid); + assert.equal(updated.name, "After"); + }); + + it("updateAbode with no fields throws InvalidAbodeError", async () => { + const created = await db.createAbode({ name: "No Update" }, { uid: ctxUid }); + await assert.rejects( + () => db.updateAbode({ aid: created.aid }, { uid: ctxUid }), + (err) => { + assert.ok(err instanceof InvalidAbodeError); + return true; + } + ); + }); + + it("deleteAbodeById removes the abode", async () => { + const created = await db.createAbode({ name: "To Delete" }, { uid: ctxUid }); + await db.deleteAbodeById(created.aid); + await assert.rejects( + () => db.getAbodeById(created.aid), + (err) => { + assert.ok(err instanceof NotFoundAbodeError); + return true; + } + ); + }); + + it("deleteAbodeById throws NotFoundAbodeError for unknown aid", async () => { + await assert.rejects( + () => db.deleteAbodeById("00000000-0000-0000-0000-000000000000"), + (err) => { + assert.ok(err instanceof NotFoundAbodeError); + return true; + } + ); + }); + }); +} diff --git a/test/shared/apikeys.ts b/test/shared/apikeys.ts new file mode 100644 index 0000000..b064558 --- /dev/null +++ b/test/shared/apikeys.ts @@ -0,0 +1,102 @@ +import { describe, it, before, after } from "node:test"; +import assert from "node:assert/strict"; +import type { DbInterface } from "../../src/db/types/DbInterface.js"; +import { NotFoundAbodeError } from "../../src/db/types/errors.js"; +import { hashPassword } from "../../src/util/hash.js"; + +export function runApikeyTests( + name: string, + getDb: () => Promise<{ db: DbInterface; close(): void }> +): void { + describe(`${name}: apikeys`, async () => { + let db: DbInterface; + let close: () => void; + let uid: string; + + before(async () => { + ({ db, close } = await getDb()); + const pw = await hashPassword("apikey-password"); + const user = await db.createUser({ + email: `apikey-user-${Date.now()}@test.example`, + name: "Apikey User", + password: pw, + flags: {}, + }); + uid = user.uid; + }); + + after(() => close()); + + it("createApikey returns [ClientApikey, at_token]", async () => { + const [apikey, token] = await db.createApikey({ + uid, + name: "Test Key", + permissions: {}, + }); + assert.ok(apikey.kid, "has kid"); + assert.equal(apikey.uid, uid); + assert.equal(apikey.name, "Test Key"); + assert.ok(!("token" in apikey), "ClientApikey has no token field"); + assert.ok(token.startsWith("at_"), `token starts with at_: ${token}`); + }); + + it("listApikeysByUser returns created key", async () => { + const [created] = await db.createApikey({ + uid, + name: "List Key", + permissions: {}, + }); + const keys = await db.listApikeysByUser(uid); + assert.ok(Array.isArray(keys)); + const found = keys.find((k) => k.kid === created.kid); + assert.ok(found, "created key appears in listApikeysByUser"); + }); + + it("getApikeyById returns the key", async () => { + const [created] = await db.createApikey({ + uid, + name: "GetById Key", + permissions: {}, + }); + const found = await db.getApikeyById(created.kid); + assert.equal(found.kid, created.kid); + assert.equal(found.name, "GetById Key"); + }); + + it("getApikeyById throws NotFoundAbodeError for unknown kid", async () => { + await assert.rejects( + () => db.getApikeyById("00000000-0000-0000-0000-000000000000"), + (err) => { + assert.ok(err instanceof NotFoundAbodeError); + return true; + } + ); + }); + + it("deleteApikeyById removes the key", async () => { + const [created] = await db.createApikey({ + uid, + name: "Delete Key", + permissions: {}, + }); + await db.deleteApikeyById(created.kid); + await assert.rejects( + () => db.getApikeyById(created.kid), + (err) => { + assert.ok(err instanceof NotFoundAbodeError); + return true; + } + ); + }); + + it("deleteApikeyById throws NotFoundAbodeError for unknown kid", async () => { + await assert.rejects( + () => db.deleteApikeyById("00000000-0000-0000-0000-000000000000"), + (err) => { + assert.ok(err instanceof NotFoundAbodeError); + return true; + } + ); + }); + }); +} diff --git a/test/shared/auth.ts b/test/shared/auth.ts new file mode 100644 index 0000000..c2a8a30 --- /dev/null +++ b/test/shared/auth.ts @@ -0,0 +1,136 @@ +import { describe, it, before, after } from "node:test"; +import assert from "node:assert/strict"; +import type { BackendDbInterface } from "../../src/db/types/DbInterface.js"; +import { + NotFoundAbodeError, + NotAuthorizedAbodeError, + ConflictAbodeError, +} from "../../src/db/types/errors.js"; +import { hashPassword } from "../../src/util/hash.js"; + +export function runAuthTests( + name: string, + getDb: () => Promise<{ db: BackendDbInterface; close(): void }>, + createExpiredApikey?: (db: BackendDbInterface, uid: string) => Promise<`at_${string}`> +): void { + describe(`${name}: auth`, async () => { + let db: BackendDbInterface; + let close: () => void; + let email: string; + const password = "auth-test-password"; + + before(async () => { + ({ db, close } = await getDb()); + email = `auth-user-${Date.now()}@test.example`; + const pw = await hashPassword(password); + await db.createUser({ email, name: "Auth User", password: pw, flags: {} }); + }); + + after(() => close()); + + it("getUserByLogin returns user on correct credentials", async () => { + const user = await db.getUserByLogin({ email, password }); + assert.equal(user.email, email); + assert.ok(!("password" in user)); + }); + + it("getUserByLogin throws NotAuthorizedAbodeError for wrong password", async () => { + await assert.rejects( + () => db.getUserByLogin({ email, password: "wrong-password" }), + (err) => { + assert.ok(err instanceof NotAuthorizedAbodeError); + return true; + } + ); + }); + + it("getUserByLogin throws NotFoundAbodeError for unknown email", async () => { + await assert.rejects( + () => + db.getUserByLogin({ + email: "nobody@nowhere.example", + password: "any", + }), + (err) => { + assert.ok(err instanceof NotFoundAbodeError); + return true; + } + ); + }); + + it("getUserByLogin throws ConflictAbodeError for #unset password", async () => { + const unsetEmail = `unset-${Date.now()}@test.example`; + await db.createUser({ + email: unsetEmail, + name: "Unset User", + password: "#unset", + flags: {}, + }); + await assert.rejects( + () => db.getUserByLogin({ email: unsetEmail, password: "any" }), + (err) => { + assert.ok(err instanceof ConflictAbodeError); + return true; + } + ); + }); + }); + + describe(`${name}: auth apikey`, async () => { + let db: BackendDbInterface; + let close: () => void; + let uid: string; + + before(async () => { + ({ db, close } = await getDb()); + const pw = await hashPassword("apikey-auth-password"); + const user = await db.createUser({ + email: `apikey-auth-${Date.now()}@test.example`, + name: "Apikey Auth User", + password: pw, + flags: {}, + }); + uid = user.uid; + }); + + after(() => close()); + + it("getUserByApikey returns [user, apikey] for valid token", async () => { + const [, token] = await db.createApikey({ + uid, + name: "Auth Key", + permissions: {}, + }); + const [user, apikey] = await db.getUserByApikey(token); + assert.equal(user.uid, uid); + assert.ok(apikey.kid); + assert.equal(apikey.uid, uid); + }); + + it("getUserByApikey throws NotAuthorizedAbodeError for expired key", async (t) => { + if (!createExpiredApikey) { + t.skip("createExpiredApikey helper not provided for this backend"); + return; + } + const token = await createExpiredApikey(db, uid); + await assert.rejects( + () => db.getUserByApikey(token), + (err) => { + assert.ok(err instanceof NotAuthorizedAbodeError); + return true; + } + ); + }); + + it("getUserByApikey throws NotFoundAbodeError for unknown token", async () => { + const fakeToken = `at_${"0".repeat(32)}` as `at_${string}`; + await assert.rejects( + () => db.getUserByApikey(fakeToken), + (err) => { + assert.ok(err instanceof NotFoundAbodeError); + return true; + } + ); + }); + }); +} diff --git a/test/shared/residents.ts b/test/shared/residents.ts new file mode 100644 index 0000000..6fdfd0f --- /dev/null +++ b/test/shared/residents.ts @@ -0,0 +1,154 @@ +import { describe, it, before, after } from "node:test"; +import assert from "node:assert/strict"; +import type { DbInterface } from "../../src/db/types/DbInterface.js"; +import { NotFoundAbodeError, InvalidAbodeError } from "../../src/db/types/errors.js"; +import { hashPassword } from "../../src/util/hash.js"; + +export function runResidentTests( + name: string, + getDb: () => Promise<{ db: DbInterface; close(): void }> +): void { + describe(`${name}: residents`, async () => { + let db: DbInterface; + let close: () => void; + let uid: string; + let aid: string; + let ctxUid: string; + + before(async () => { + ({ db, close } = await getDb()); + const pw = await hashPassword("resident-pw"); + // ctx user (creator of abode) + const ctx = await db.createUser({ + email: `res-ctx-${Date.now()}@test.example`, + name: "Resident Ctx", + password: pw, + flags: {}, + }); + ctxUid = ctx.uid; + // the resident user + const resUser = await db.createUser({ + email: `resident-${Date.now()}@test.example`, + name: "Resident User", + password: pw, + flags: {}, + }); + uid = resUser.uid; + const abode = await db.createAbode( + { name: `Resident Abode ${Date.now()}` }, + { uid: ctxUid } + ); + aid = abode.aid; + await db.createResident({ uid, aid, flags: {} }, { uid: ctxUid }); + }); + + after(() => close()); + + it("getResidentById returns the created resident", async () => { + const found = await db.getResidentById(uid, aid); + assert.equal(found.uid, uid); + assert.equal(found.aid, aid); + assert.ok(found.created_at); + }); + + it("getResidentById throws NotFoundAbodeError for unknown pair", async () => { + await assert.rejects( + () => + db.getResidentById( + "00000000-0000-0000-0000-000000000000", + "00000000-0000-0000-0000-000000000001" + ), + (err) => { + assert.ok(err instanceof NotFoundAbodeError); + return true; + } + ); + }); + + it("listResidents includes the created resident", async () => { + const residents = await db.listResidents(); + assert.ok(Array.isArray(residents)); + const found = residents.find((r) => r.uid === uid && r.aid === aid); + assert.ok(found, "created resident appears in listResidents"); + }); + + it("listResidentsByUserId filters by uid", async () => { + const results = await db.listResidentsByUserId(uid); + assert.ok(results.every((r) => r.uid === uid)); + assert.ok(results.some((r) => r.aid === aid)); + }); + + it("listResidentsByAbodeId filters by aid", async () => { + const results = await db.listResidentsByAbodeId(aid); + assert.ok(results.every((r) => r.aid === aid)); + assert.ok(results.some((r) => r.uid === uid)); + }); + + it("listUsersByAbodeId returns users in the abode", async () => { + const users = await db.listUsersByAbodeId(aid); + assert.ok(Array.isArray(users)); + const found = users.find((u) => u.uid === uid); + assert.ok(found, "resident user appears in listUsersByAbodeId"); + }); + + it("listAbodesByUserId returns abodes for user", async () => { + const abodes = await db.listAbodesByUserId(uid); + assert.ok(Array.isArray(abodes)); + const found = abodes.find((a) => a.aid === aid); + assert.ok(found, "abode appears in listAbodesByUserId"); + }); + + it("updateResident updates flags", async () => { + const updated = await db.updateResident( + { uid, aid, flags: { admin: true } }, + { uid: ctxUid } + ); + assert.equal(updated.uid, uid); + assert.deepEqual(updated.flags, { admin: true }); + }); + + it("updateResident with no fields throws InvalidAbodeError", async () => { + await assert.rejects( + () => db.updateResident({ uid, aid }, { uid: ctxUid }), + (err) => { + assert.ok(err instanceof InvalidAbodeError); + return true; + } + ); + }); + + it("deleteResidentById removes the resident then throws on re-fetch", async () => { + const pw = await hashPassword("del-res-pw"); + const user2 = await db.createUser({ + email: `del-res-${Date.now()}@test.example`, + name: "Del Res User", + password: pw, + flags: {}, + }); + const abode2 = await db.createAbode({ name: "Del Abode" }, { uid: ctxUid }); + await db.createResident({ uid: user2.uid, aid: abode2.aid, flags: {} }, { uid: ctxUid }); + await db.deleteResidentById(user2.uid, abode2.aid); + await assert.rejects( + () => db.getResidentById(user2.uid, abode2.aid), + (err) => { + assert.ok(err instanceof NotFoundAbodeError); + return true; + } + ); + }); + + it("deleteResidentById throws NotFoundAbodeError for unknown pair", async () => { + await assert.rejects( + () => + db.deleteResidentById( + "00000000-0000-0000-0000-000000000002", + "00000000-0000-0000-0000-000000000003" + ), + (err) => { + assert.ok(err instanceof NotFoundAbodeError); + return true; + } + ); + }); + }); +} diff --git a/test/shared/sessions.ts b/test/shared/sessions.ts new file mode 100644 index 0000000..6988f15 --- /dev/null +++ b/test/shared/sessions.ts @@ -0,0 +1,65 @@ +import { describe, it, before, after } from "node:test"; +import assert from "node:assert/strict"; +import type { BackendDbInterface } from "../../src/db/types/DbInterface.js"; +import { NotFoundAbodeError } from "../../src/db/types/errors.js"; +import { hashPassword } from "../../src/util/hash.js"; + +export function runSessionTests( + name: string, + getDb: () => Promise<{ db: BackendDbInterface; close(): void }> +): void { + describe(`${name}: sessions`, async () => { + let db: BackendDbInterface; + let close: () => void; + let uid: string; + + before(async () => { + ({ db, close } = await getDb()); + const pw = await hashPassword("session-password"); + const user = await db.createUser({ + email: `session-user-${Date.now()}@test.example`, + name: "Session User", + password: pw, + flags: {}, + }); + uid = user.uid; + }); + + after(() => close()); + + it("createSession returns an as_ token", async () => { + const token = await db.createSession(uid); + assert.ok(token.startsWith("as_"), `token starts with as_: ${token}`); + assert.equal(token.length, 35, "as_ + 32 hex chars"); + }); + + it("getUserBySession returns the correct user", async () => { + const token = await db.createSession(uid); + const user = await db.getUserBySession(token); + assert.equal(user.uid, uid); + }); + + it("getUserBySession throws NotFoundAbodeError for unknown token", async () => { + const fakeToken = `as_${"0".repeat(32)}` as `as_${string}`; + await assert.rejects( + () => db.getUserBySession(fakeToken), + (err) => { + assert.ok(err instanceof NotFoundAbodeError); + return true; + } + ); + }); + + it("deleteSessionsByUser invalidates all sessions for user", async () => { + const token = await db.createSession(uid); + await db.deleteSessionsByUser(uid); + await assert.rejects( + () => db.getUserBySession(token), + (err) => { + assert.ok(err instanceof NotFoundAbodeError); + return true; + } + ); + }); + }); +} diff --git a/test/shared/users.ts b/test/shared/users.ts new file mode 100644 index 0000000..18c6813 --- /dev/null +++ b/test/shared/users.ts @@ -0,0 +1,179 @@ +import { describe, it, before, after } from "node:test"; +import assert from "node:assert/strict"; +import type { DbInterface } from "../../src/db/types/DbInterface.js"; +import { + NotFoundAbodeError, + InvalidAbodeError, + ReadonlyAbodeError, +} from "../../src/db/types/errors.js"; +import { hashPassword } from "../../src/util/hash.js"; + +export function runUserTests( + name: string, + getDb: () => Promise<{ db: DbInterface; close(): void }> +): void { + describe(`${name}: users`, async () => { + let db: DbInterface; + let close: () => void; + let hashedPw: string; + + before(async () => { + ({ db, close } = await getDb()); + hashedPw = await hashPassword("test-password"); + }); + + after(() => close()); + + it("createUser returns a ClientUser without password", async () => { + const user = await db.createUser({ + email: `user-create-${Date.now()}@test.example`, + name: "Test User", + password: hashedPw, + flags: {}, + }); + assert.ok(user.uid, "has uid"); + assert.equal(user.name, "Test User"); + assert.ok(!("password" in user), "no password field"); + assert.ok(user.created_at); + assert.ok(user.updated_at); + }); + + it("getUserById returns the created user", async () => { + const created = await db.createUser({ + email: `user-byid-${Date.now()}@test.example`, + name: "ById User", + password: hashedPw, + flags: {}, + }); + const found = await db.getUserById(created.uid); + assert.equal(found.uid, created.uid); + assert.equal(found.name, "ById User"); + }); + + it("getUserById throws NotFoundAbodeError for unknown uid", async () => { + await assert.rejects( + () => db.getUserById("00000000-0000-0000-0000-000000000000"), + (err) => { + assert.ok(err instanceof NotFoundAbodeError); + return true; + } + ); + }); + + it("getUserByEmail returns the created user", async () => { + 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.equal(found.email, email); + }); + + it("getUserByEmail throws NotFoundAbodeError for unknown email", async () => { + await assert.rejects( + () => db.getUserByEmail("nobody@nowhere.example"), + (err) => { + assert.ok(err instanceof NotFoundAbodeError); + return true; + } + ); + }); + + it("listUsers includes the created user", async () => { + const email = `user-list-${Date.now()}@test.example`; + const created = await db.createUser({ + email, + name: "Listed User", + password: hashedPw, + flags: {}, + }); + const users = await db.listUsers(); + assert.ok(Array.isArray(users)); + const found = users.find((u) => u.uid === created.uid); + assert.ok(found, "created user appears in listUsers"); + }); + + it("updateUser updates the name", async () => { + const created = await db.createUser({ + email: `user-update-${Date.now()}@test.example`, + name: "Before Update", + password: hashedPw, + flags: {}, + }); + const updated = await db.updateUser({ uid: created.uid, name: "After Update" }); + assert.equal(updated.uid, created.uid); + assert.equal(updated.name, "After Update"); + }); + + it("updateUser with no fields throws InvalidAbodeError", async () => { + const created = await db.createUser({ + email: `user-noupdate-${Date.now()}@test.example`, + name: "No Update", + password: hashedPw, + flags: {}, + }); + await assert.rejects( + () => db.updateUser({ uid: created.uid }), + (err) => { + assert.ok(err instanceof InvalidAbodeError); + return true; + } + ); + }); + + it("deleteUserById removes the user", async () => { + const created = await db.createUser({ + email: `user-delete-${Date.now()}@test.example`, + name: "To Delete", + password: hashedPw, + flags: {}, + }); + await db.deleteUserById(created.uid); + await assert.rejects( + () => db.getUserById(created.uid), + (err) => { + assert.ok(err instanceof NotFoundAbodeError); + return true; + } + ); + }); + + it("deleteUserById throws NotFoundAbodeError for unknown uid", async () => { + await assert.rejects( + () => db.deleteUserById("00000000-0000-0000-0000-000000000000"), + (err) => { + assert.ok(err instanceof NotFoundAbodeError); + return true; + } + ); + }); + }); + + describe(`${name}: users readonly`, async () => { + let db: DbInterface; + let close: () => void; + let hashedPw: string; + + before(async () => { + ({ db, close } = await getDb()); + hashedPw = await hashPassword("test-password"); + }); + + after(() => close()); + + it("createUser on readonly db throws ReadonlyAbodeError", async () => { + if (!db.readonly) return; + await assert.rejects( + () => + db.createUser({ + email: "readonly@test.example", + name: "Readonly", + password: hashedPw, + flags: {}, + }), + (err) => { + assert.ok(err instanceof ReadonlyAbodeError); + return true; + } + ); + }); + }); +} diff --git a/test/tools/authenticate.test.ts b/test/tools/authenticate.test.ts new file mode 100644 index 0000000..1a3e52d --- /dev/null +++ b/test/tools/authenticate.test.ts @@ -0,0 +1,273 @@ +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)}`, + 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"); + }); + }); +}); diff --git a/test/tools/convertError.test.ts b/test/tools/convertError.test.ts new file mode 100644 index 0000000..e5e2be0 --- /dev/null +++ b/test/tools/convertError.test.ts @@ -0,0 +1,74 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { convertError } from "../../src/webapi/middleware/convertError.js"; +import { + NotFoundAbodeError, + NotAuthorizedAbodeError, + ConflictAbodeError, + InvalidAbodeError, + ReadonlyAbodeError, +} from "../../src/db/types/errors.js"; + +function makeCtx() { + return { status: 200, body: null as unknown }; +} + +async function runWithError(err: unknown) { + const ctx = makeCtx(); + await convertError(ctx as any, async () => { + throw err; + }); + return ctx; +} + +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; }); + assert.equal(nextCalled, true); + assert.equal(ctx.status, 200); + }); + + it("NotFoundAbodeError → 404 not_found", async () => { + const ctx = await runWithError(new NotFoundAbodeError()); + assert.equal(ctx.status, 404); + assert.deepEqual(ctx.body, { ok: false, error: "not_found" }); + }); + + it("NotAuthorizedAbodeError → 401 not_authorized", async () => { + const ctx = await runWithError(new NotAuthorizedAbodeError()); + assert.equal(ctx.status, 401); + assert.deepEqual(ctx.body, { ok: false, error: "not_authorized" }); + }); + + it("ConflictAbodeError → 409 conflict", async () => { + const ctx = await runWithError(new ConflictAbodeError()); + assert.equal(ctx.status, 409); + assert.deepEqual(ctx.body, { ok: false, error: "conflict" }); + }); + + it("InvalidAbodeError → 400 invalid", async () => { + const ctx = await runWithError(new InvalidAbodeError()); + assert.equal(ctx.status, 400); + assert.deepEqual(ctx.body, { ok: false, error: "invalid" }); + }); + + it("ReadonlyAbodeError → 403 readonly", async () => { + const ctx = await runWithError(new ReadonlyAbodeError()); + assert.equal(ctx.status, 403); + assert.deepEqual(ctx.body, { ok: false, error: "readonly" }); + }); + + it("unknown Error → 500 unknown", async () => { + const ctx = await runWithError(new Error("something went wrong")); + assert.equal(ctx.status, 500); + assert.deepEqual(ctx.body, { ok: false, error: "unknown" }); + }); + + it("non-Error thrown → 500 unknown", async () => { + const ctx = await runWithError("string error"); + assert.equal(ctx.status, 500); + assert.deepEqual(ctx.body, { ok: false, error: "unknown" }); + }); +}); diff --git a/test/tools/hash.test.ts b/test/tools/hash.test.ts new file mode 100644 index 0000000..48af2ed --- /dev/null +++ b/test/tools/hash.test.ts @@ -0,0 +1,39 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { hashPassword, validatePassword } from "../../src/util/hash.js"; + +describe("hashPassword", () => { + it("returns a string in PHC/argon2 format", async () => { + const hash = await hashPassword("secret"); + assert.ok(hash.startsWith("$argon2"), `expected argon2 hash, got: ${hash}`); + assert.ok(hash.includes("$"), "has delimiter"); + }); + + it("two hashes of the same password differ (random salt)", async () => { + const [h1, h2] = await Promise.all([ + hashPassword("same-password"), + hashPassword("same-password"), + ]); + assert.notEqual(h1, h2, "hashes should differ due to random salt"); + }); +}); + +describe("validatePassword", () => { + it("returns true for matching password and hash", async () => { + const hash = await hashPassword("correct-password"); + const result = await validatePassword("correct-password", hash); + assert.equal(result, true); + }); + + it("returns false for wrong password", async () => { + const hash = await hashPassword("correct-password"); + const result = await validatePassword("wrong-password", hash); + assert.equal(result, false); + }); + + it("returns false for a completely different password", async () => { + const hash = await hashPassword("original-password"); + const result = await validatePassword("different-password", hash); + assert.equal(result, false); + }); +}); diff --git a/test/tools/jsonBody.test.ts b/test/tools/jsonBody.test.ts new file mode 100644 index 0000000..2d3eacb --- /dev/null +++ b/test/tools/jsonBody.test.ts @@ -0,0 +1,140 @@ +import { describe, it, before, after } from "node:test"; +import assert from "node:assert/strict"; +import Koa from "koa"; +import KoaRouter from "@koa/router"; +import { createServer } from "node:http"; +import type { AddressInfo } from "node:net"; +import { jsonBody } from "../../src/webapi/middleware/jsonBody.js"; + +async function request( + url: string, + opts: { method?: string; body?: unknown; contentType?: string } = {} +): Promise<{ status: number; body: unknown }> { + const method = opts.method ?? "POST"; + const bodyStr = + opts.body !== undefined ? JSON.stringify(opts.body) : undefined; + const headers: Record = {}; + if (bodyStr !== undefined) { + headers["Content-Type"] = opts.contentType ?? "application/json"; + headers["Content-Length"] = String(bodyStr.length); + } + 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; } + 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 app = new Koa(); + const router = new KoaRouter(); + + router.post("/echo", jsonBody(), async (ctx) => { + ctx.status = 200; + ctx.body = { ok: true, received: ctx.request.body }; + }); + + router.post( + "/fail-validate", + jsonBody({ validate: failValidator as any }), + async (ctx) => { + ctx.status = 200; + ctx.body = { ok: true }; + } + ); + + router.post( + "/with-params/:uid", + jsonBody({ includeParams: ["uid"] }), + async (ctx) => { + ctx.status = 200; + ctx.body = { ok: true, body: ctx.request.body }; + } + ); + + app.use(router.routes()); + app.use(router.allowedMethods()); + + const server = createServer(app.callback()); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const { port } = server.address() as AddressInfo; + const url = `http://127.0.0.1:${port}`; + const close = () => + new Promise((resolve, reject) => + server.close((err) => (err ? reject(err) : resolve())) + ); + return { url, close }; +} + +describe("jsonBody middleware", () => { + let url: string; + let closeServer: () => Promise; + + before(async () => { + ({ url, close: closeServer } = await makeTestServer()); + }); + + after(() => closeServer()); + + it("no Content-Type → 200 with empty body (bodyparser sets body to {})", async () => { + const res = await fetch(url + "/echo", { method: "POST" }); + const json = await res.json(); + assert.equal(res.status, 200); + assert.deepEqual(json.received, {}); + }); + + it("invalid JSON with Content-Type: application/json → 400 from bodyparser", async () => { + const res = await fetch(url + "/echo", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "not-valid-json", + }); + assert.equal(res.status, 400); + }); + + it("valid JSON body → 200 with echoed body", async () => { + const res = await request(url + "/echo", { body: { hello: "world" } }); + assert.equal(res.status, 200); + assert.deepEqual((res.body as any).received, { hello: "world" }); + }); + + it("failing validator → 400 jsonchema_validation_failed with schema and errors", async () => { + 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"); + }); + + it("includeParams: param absent from body → merged in", async () => { + const res = await request(url + "/with-params/user-42", { + body: { other: "field" }, + }); + assert.equal(res.status, 200); + assert.equal((res.body as any).body?.uid, "user-42"); + }); + + it("includeParams: param present with matching value → ok", async () => { + const res = await request(url + "/with-params/user-42", { + body: { uid: "user-42" }, + }); + assert.equal(res.status, 200); + }); + + it("includeParams: mismatching param value → 400 mismatch_params", async () => { + const res = await request(url + "/with-params/user-42", { + body: { uid: "different-uid" }, + }); + assert.equal(res.status, 400); + assert.equal((res.body as any).error, "mismatch_params"); + assert.equal((res.body as any).param, "uid"); + }); +}); diff --git a/test/tools/token.test.ts b/test/tools/token.test.ts new file mode 100644 index 0000000..84d1b1d --- /dev/null +++ b/test/tools/token.test.ts @@ -0,0 +1,86 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + createSessionToken, + createApikeyToken, + isSessionToken, + isApikeyToken, +} from "../../src/util/token.js"; + +describe("createSessionToken", () => { + it("starts with 'as_'", () => { + const token = createSessionToken(); + assert.ok(token.startsWith("as_"), `expected as_ prefix, got: ${token}`); + }); + + it("has 35 chars total (as_ + 32 hex)", () => { + const token = createSessionToken(); + assert.equal(token.length, 35); + }); + + it("suffix is all hex characters", () => { + const token = createSessionToken(); + const hex = token.slice(3); + assert.ok(/^[0-9a-f]{32}$/.test(hex), `not all hex: ${hex}`); + }); + + it("produces different tokens on each call", () => { + const tokens = new Set(Array.from({ length: 10 }, createSessionToken)); + assert.equal(tokens.size, 10, "all tokens should be unique"); + }); +}); + +describe("createApikeyToken", () => { + it("starts with 'at_'", () => { + const token = createApikeyToken(); + assert.ok(token.startsWith("at_"), `expected at_ prefix, got: ${token}`); + }); + + it("has 35 chars total (at_ + 32 hex)", () => { + const token = createApikeyToken(); + assert.equal(token.length, 35); + }); + + it("suffix is all hex characters", () => { + const token = createApikeyToken(); + const hex = token.slice(3); + assert.ok(/^[0-9a-f]{32}$/.test(hex), `not all hex: ${hex}`); + }); + + it("produces different tokens on each call", () => { + const tokens = new Set(Array.from({ length: 10 }, createApikeyToken)); + assert.equal(tokens.size, 10, "all tokens should be unique"); + }); +}); + +describe("isSessionToken", () => { + it("returns true for as_ prefixed strings", () => { + assert.equal(isSessionToken("as_" + "a".repeat(32)), true); + }); + + it("returns false for at_ prefixed strings", () => { + assert.equal(isSessionToken("at_abc"), false); + }); + + it("returns false for empty string", () => { + assert.equal(isSessionToken(""), false); + }); + + it("returns false for plain strings", () => { + assert.equal(isSessionToken("hello"), false); + }); +}); + +describe("isApikeyToken", () => { + it("returns true for at_ prefixed strings", () => { + assert.equal(isApikeyToken("at_" + "b".repeat(32)), true); + }); + + it("returns false for as_ prefixed strings", () => { + assert.equal(isApikeyToken("as_abc"), false); + }); + + it("returns false for empty string", () => { + assert.equal(isApikeyToken(""), false); + }); +}); diff --git a/test/tools/validators.test.ts b/test/tools/validators.test.ts new file mode 100644 index 0000000..2b10740 --- /dev/null +++ b/test/tools/validators.test.ts @@ -0,0 +1,181 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + createuser, + updateuser, + clientuser, + loginuser, + createabode, + updateabode, + createresident, + createapikey, +} from "../../src/schema/validators.js"; + +describe("createuser validator", () => { + it("accepts a valid CreateUser object", () => { + const result = createuser({ + email: "user@example.com", + name: "Alice", + password: "#unset", + flags: {}, + }); + assert.equal(result, true); + }); + + it("rejects missing name", () => { + const result = createuser({ + email: "user@example.com", + password: "#unset", + flags: {}, + }); + assert.equal(result, false); + assert.ok(createuser.errors && createuser.errors.length > 0); + }); + + it("rejects missing email", () => { + const result = createuser({ + name: "Alice", + password: "#unset", + flags: {}, + }); + assert.equal(result, false); + }); + + it("rejects missing password", () => { + const result = createuser({ + email: "user@example.com", + name: "Alice", + flags: {}, + }); + assert.equal(result, false); + }); +}); + +describe("updateuser validator", () => { + it("accepts update with just uid", () => { + const result = updateuser({ uid: "11111111-1111-1111-1111-111111111111" }); + assert.equal(result, true); + }); + + it("accepts update with uid and name", () => { + const result = updateuser({ + uid: "11111111-1111-1111-1111-111111111111", + name: "New Name", + }); + assert.equal(result, true); + }); + + it("rejects missing uid", () => { + const result = updateuser({ name: "Alice" }); + assert.equal(result, false); + }); +}); + +describe("clientuser validator", () => { + it("accepts a valid ClientUser (no password)", () => { + const result = clientuser({ + uid: "11111111-1111-1111-1111-111111111111", + email: "user@example.com", + name: "Alice", + flags: {}, + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z", + }); + assert.equal(result, true); + }); + + it("rejects object with password field", () => { + const result = clientuser({ + uid: "11111111-1111-1111-1111-111111111111", + email: "user@example.com", + name: "Alice", + password: "#unset", + flags: {}, + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z", + }); + assert.equal(result, false); + }); +}); + +describe("loginuser validator", () => { + it("accepts valid LoginUser", () => { + const result = loginuser({ email: "user@example.com", password: "secret" }); + assert.equal(result, true); + }); + + it("rejects missing password", () => { + const result = loginuser({ email: "user@example.com" }); + assert.equal(result, false); + }); + + it("rejects missing email", () => { + const result = loginuser({ password: "secret" }); + assert.equal(result, false); + }); +}); + +describe("createabode validator", () => { + it("accepts valid CreateAbode", () => { + const result = createabode({ name: "My Abode" }); + assert.equal(result, true); + }); + + it("rejects missing name", () => { + const result = createabode({}); + assert.equal(result, false); + }); +}); + +describe("updateabode validator", () => { + it("accepts update with just aid", () => { + const result = updateabode({ aid: "22222222-2222-2222-2222-222222222222" }); + assert.equal(result, true); + }); + + it("accepts update with aid and name", () => { + const result = updateabode({ + aid: "22222222-2222-2222-2222-222222222222", + name: "New Name", + }); + assert.equal(result, true); + }); +}); + +describe("createresident validator", () => { + it("accepts valid CreateResident", () => { + const result = createresident({ + uid: "11111111-1111-1111-1111-111111111111", + aid: "22222222-2222-2222-2222-222222222222", + flags: {}, + }); + assert.equal(result, true); + }); + + it("rejects missing uid", () => { + const result = createresident({ + aid: "22222222-2222-2222-2222-222222222222", + flags: {}, + }); + assert.equal(result, false); + }); +}); + +describe("createapikey validator", () => { + it("accepts valid CreateApikey", () => { + const result = createapikey({ + uid: "11111111-1111-1111-1111-111111111111", + name: "My Key", + permissions: {}, + }); + assert.equal(result, true); + }); + + it("rejects missing name", () => { + const result = createapikey({ + uid: "11111111-1111-1111-1111-111111111111", + permissions: {}, + }); + assert.equal(result, false); + }); +});