From 33251d9b776394d5620c3dfb4765f8df9d24a66a Mon Sep 17 00:00:00 2001 From: Michael Wolf Date: Sun, 25 Jan 2026 20:40:49 -0600 Subject: [PATCH] Add comprehensive test suite for express modules Tests for: - user.ts: User class, roles, permissions, status checks - util.ts: loadFile utility - handlers.ts: multiHandler - types.ts: methodParser, requireAuth, requirePermission - logging.ts: module structure - database.ts: connectionConfig, raw queries, PostgresAuthStore - auth/token.ts: generateToken, hashToken, parseAuthorizationHeader - auth/password.ts: hashPassword, verifyPassword (scrypt) - auth/types.ts: Zod parsers, Session class, tokenLifetimes - auth/store.ts: InMemoryAuthStore - auth/service.ts: AuthService (login, register, verify, reset) - basic/*.ts: route structure tests Co-Authored-By: Claude Opus 4.5 --- express/auth/password.spec.ts | 80 ++++ express/auth/service.spec.ts | 419 ++++++++++++++++++ express/auth/store.spec.ts | 321 ++++++++++++++ express/auth/token.spec.ts | 94 ++++ express/auth/types.spec.ts | 253 +++++++++++ express/basic/login.spec.ts | 24 + express/basic/logout.spec.ts | 24 + express/basic/routes.spec.ts | 73 +++ express/database.spec.ts | 285 ++++++++++++ .../tests/{user.test.ts => user.spec.ts} | 0 express/handlers.spec.ts | 71 +++ express/logging.spec.ts | 53 +++ express/package.json | 4 +- express/types.spec.ts | 179 ++++++++ express/user.spec.ts | 213 +++++++++ express/util.spec.ts | 61 +++ 16 files changed, 2152 insertions(+), 2 deletions(-) create mode 100644 express/auth/password.spec.ts create mode 100644 express/auth/service.spec.ts create mode 100644 express/auth/store.spec.ts create mode 100644 express/auth/token.spec.ts create mode 100644 express/auth/types.spec.ts create mode 100644 express/basic/login.spec.ts create mode 100644 express/basic/logout.spec.ts create mode 100644 express/basic/routes.spec.ts create mode 100644 express/database.spec.ts rename express/framework/hydrators/tests/{user.test.ts => user.spec.ts} (100%) create mode 100644 express/handlers.spec.ts create mode 100644 express/logging.spec.ts create mode 100644 express/types.spec.ts create mode 100644 express/user.spec.ts create mode 100644 express/util.spec.ts diff --git a/express/auth/password.spec.ts b/express/auth/password.spec.ts new file mode 100644 index 0000000..b9a975c --- /dev/null +++ b/express/auth/password.spec.ts @@ -0,0 +1,80 @@ +// Tests for auth/password.ts +// Pure unit tests - no database needed + +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { hashPassword, verifyPassword } from "./password"; + +describe("password", () => { + describe("hashPassword", () => { + it("returns a scrypt formatted hash", async () => { + const hash = await hashPassword("testpassword"); + assert.ok(hash.startsWith("$scrypt$")); + }); + + it("includes all scrypt parameters", async () => { + const hash = await hashPassword("testpassword"); + const parts = hash.split("$"); + // Format: $scrypt$N$r$p$salt$hash + assert.equal(parts.length, 7); + assert.equal(parts[0], ""); + assert.equal(parts[1], "scrypt"); + // N, r, p should be numbers + assert.ok(!Number.isNaN(parseInt(parts[2], 10))); + assert.ok(!Number.isNaN(parseInt(parts[3], 10))); + assert.ok(!Number.isNaN(parseInt(parts[4], 10))); + }); + + it("generates different hashes for same password (different salt)", async () => { + const hash1 = await hashPassword("testpassword"); + const hash2 = await hashPassword("testpassword"); + assert.notEqual(hash1, hash2); + }); + }); + + describe("verifyPassword", () => { + it("returns true for correct password", async () => { + const hash = await hashPassword("correctpassword"); + const result = await verifyPassword("correctpassword", hash); + assert.equal(result, true); + }); + + it("returns false for incorrect password", async () => { + const hash = await hashPassword("correctpassword"); + const result = await verifyPassword("wrongpassword", hash); + assert.equal(result, false); + }); + + it("throws for invalid hash format", async () => { + await assert.rejects( + verifyPassword("password", "invalid-hash"), + /Invalid password hash format/, + ); + }); + + it("throws for non-scrypt hash", async () => { + await assert.rejects( + verifyPassword("password", "$bcrypt$10$salt$hash"), + /Invalid password hash format/, + ); + }); + + it("works with empty password", async () => { + const hash = await hashPassword(""); + const result = await verifyPassword("", hash); + assert.equal(result, true); + }); + + it("works with unicode password", async () => { + const hash = await hashPassword("p@$$w0rd\u{1F511}"); + const result = await verifyPassword("p@$$w0rd\u{1F511}", hash); + assert.equal(result, true); + }); + + it("is case sensitive", async () => { + const hash = await hashPassword("Password"); + const result = await verifyPassword("password", hash); + assert.equal(result, false); + }); + }); +}); diff --git a/express/auth/service.spec.ts b/express/auth/service.spec.ts new file mode 100644 index 0000000..568ecc3 --- /dev/null +++ b/express/auth/service.spec.ts @@ -0,0 +1,419 @@ +// Tests for auth/service.ts +// Uses InMemoryAuthStore - no database needed + +import assert from "node:assert/strict"; +import { beforeEach, describe, it } from "node:test"; +import { AuthService } from "./service"; +import { InMemoryAuthStore } from "./store"; + +describe("AuthService", () => { + let store: InMemoryAuthStore; + let service: AuthService; + + beforeEach(() => { + store = new InMemoryAuthStore(); + service = new AuthService(store); + }); + + describe("register", () => { + it("creates a new user", async () => { + const result = await service.register( + "test@example.com", + "password123", + "Test User", + ); + + assert.equal(result.success, true); + if (result.success) { + assert.equal(result.user.email, "test@example.com"); + assert.equal(result.user.displayName, "Test User"); + assert.ok(result.verificationToken.length > 0); + } + }); + + it("fails when email already registered", async () => { + await service.register("test@example.com", "password123"); + const result = await service.register( + "test@example.com", + "password456", + ); + + assert.equal(result.success, false); + if (!result.success) { + assert.equal(result.error, "Email already registered"); + } + }); + + it("creates user without displayName", async () => { + const result = await service.register( + "test@example.com", + "password123", + ); + + assert.equal(result.success, true); + if (result.success) { + assert.equal(result.user.displayName, undefined); + } + }); + }); + + describe("login", () => { + beforeEach(async () => { + // Create and verify a user + const result = await service.register( + "test@example.com", + "password123", + "Test User", + ); + if (result.success) { + // Verify email to activate user + await service.verifyEmail(result.verificationToken); + } + }); + + it("succeeds with correct credentials", async () => { + const result = await service.login( + "test@example.com", + "password123", + "cookie", + ); + + assert.equal(result.success, true); + if (result.success) { + assert.ok(result.token.length > 0); + assert.equal(result.user.email, "test@example.com"); + } + }); + + it("fails with wrong password", async () => { + const result = await service.login( + "test@example.com", + "wrongpassword", + "cookie", + ); + + assert.equal(result.success, false); + if (!result.success) { + assert.equal(result.error, "Invalid credentials"); + } + }); + + it("fails with unknown email", async () => { + const result = await service.login( + "unknown@example.com", + "password123", + "cookie", + ); + + assert.equal(result.success, false); + if (!result.success) { + assert.equal(result.error, "Invalid credentials"); + } + }); + + it("fails for inactive user", async () => { + // Create a user but don't verify email (stays pending) + await service.register("pending@example.com", "password123"); + + const result = await service.login( + "pending@example.com", + "password123", + "cookie", + ); + + assert.equal(result.success, false); + if (!result.success) { + assert.equal(result.error, "Account is not active"); + } + }); + + it("stores metadata", async () => { + const result = await service.login( + "test@example.com", + "password123", + "cookie", + { userAgent: "TestAgent", ipAddress: "192.168.1.1" }, + ); + + assert.equal(result.success, true); + }); + }); + + describe("validateToken", () => { + let token: string; + + beforeEach(async () => { + const regResult = await service.register( + "test@example.com", + "password123", + ); + if (regResult.success) { + await service.verifyEmail(regResult.verificationToken); + } + + const loginResult = await service.login( + "test@example.com", + "password123", + "cookie", + ); + if (loginResult.success) { + token = loginResult.token; + } + }); + + it("returns authenticated for valid token", async () => { + const result = await service.validateToken(token); + + assert.equal(result.authenticated, true); + if (result.authenticated) { + assert.equal(result.user.email, "test@example.com"); + assert.notEqual(result.session, null); + } + }); + + it("returns unauthenticated for invalid token", async () => { + const result = await service.validateToken("invalid-token"); + + assert.equal(result.authenticated, false); + assert.equal(result.user.isAnonymous(), true); + assert.equal(result.session, null); + }); + }); + + describe("logout", () => { + it("invalidates the session", async () => { + const regResult = await service.register( + "test@example.com", + "password123", + ); + if (regResult.success) { + await service.verifyEmail(regResult.verificationToken); + } + + const loginResult = await service.login( + "test@example.com", + "password123", + "cookie", + ); + assert.equal(loginResult.success, true); + if (!loginResult.success) return; + + const token = loginResult.token; + + // Token should be valid before logout + const beforeLogout = await service.validateToken(token); + assert.equal(beforeLogout.authenticated, true); + + // Logout + await service.logout(token); + + // Token should be invalid after logout + const afterLogout = await service.validateToken(token); + assert.equal(afterLogout.authenticated, false); + }); + }); + + describe("logoutAllSessions", () => { + it("invalidates all user sessions", async () => { + const regResult = await service.register( + "test@example.com", + "password123", + ); + if (regResult.success) { + await service.verifyEmail(regResult.verificationToken); + } + + // Create multiple sessions + const login1 = await service.login( + "test@example.com", + "password123", + "cookie", + ); + const login2 = await service.login( + "test@example.com", + "password123", + "bearer", + ); + + assert.equal(login1.success, true); + assert.equal(login2.success, true); + if (!login1.success || !login2.success) return; + + // Both should be valid + const before1 = await service.validateToken(login1.token); + const before2 = await service.validateToken(login2.token); + assert.equal(before1.authenticated, true); + assert.equal(before2.authenticated, true); + + // Logout all + const user = await store.getUserByEmail("test@example.com"); + const count = await service.logoutAllSessions(user!.id); + assert.equal(count, 2); + + // Both should be invalid + const after1 = await service.validateToken(login1.token); + const after2 = await service.validateToken(login2.token); + assert.equal(after1.authenticated, false); + assert.equal(after2.authenticated, false); + }); + }); + + describe("verifyEmail", () => { + it("activates user with valid token", async () => { + const regResult = await service.register( + "test@example.com", + "password123", + ); + assert.equal(regResult.success, true); + if (!regResult.success) return; + + const result = await service.verifyEmail( + regResult.verificationToken, + ); + assert.equal(result.success, true); + + // User should now be active and can login + const loginResult = await service.login( + "test@example.com", + "password123", + "cookie", + ); + assert.equal(loginResult.success, true); + }); + + it("fails with invalid token", async () => { + const result = await service.verifyEmail("invalid-token"); + + assert.equal(result.success, false); + if (!result.success) { + assert.equal( + result.error, + "Invalid or expired verification token", + ); + } + }); + + it("fails when token already used", async () => { + const regResult = await service.register( + "test@example.com", + "password123", + ); + assert.equal(regResult.success, true); + if (!regResult.success) return; + + // First verification succeeds + const result1 = await service.verifyEmail( + regResult.verificationToken, + ); + assert.equal(result1.success, true); + + // Second verification fails (token deleted) + const result2 = await service.verifyEmail( + regResult.verificationToken, + ); + assert.equal(result2.success, false); + }); + }); + + describe("createPasswordResetToken", () => { + it("returns token for existing user", async () => { + const regResult = await service.register( + "test@example.com", + "password123", + ); + assert.equal(regResult.success, true); + + const result = + await service.createPasswordResetToken("test@example.com"); + assert.notEqual(result, null); + assert.ok(result!.token.length > 0); + }); + + it("returns null for unknown email", async () => { + const result = await service.createPasswordResetToken( + "unknown@example.com", + ); + assert.equal(result, null); + }); + }); + + describe("resetPassword", () => { + it("changes password with valid token", async () => { + const regResult = await service.register( + "test@example.com", + "oldpassword", + ); + if (regResult.success) { + await service.verifyEmail(regResult.verificationToken); + } + + const resetToken = + await service.createPasswordResetToken("test@example.com"); + assert.notEqual(resetToken, null); + + const result = await service.resetPassword( + resetToken!.token, + "newpassword", + ); + assert.equal(result.success, true); + + // Old password should no longer work + const loginOld = await service.login( + "test@example.com", + "oldpassword", + "cookie", + ); + assert.equal(loginOld.success, false); + + // New password should work + const loginNew = await service.login( + "test@example.com", + "newpassword", + "cookie", + ); + assert.equal(loginNew.success, true); + }); + + it("fails with invalid token", async () => { + const result = await service.resetPassword( + "invalid-token", + "newpassword", + ); + + assert.equal(result.success, false); + if (!result.success) { + assert.equal(result.error, "Invalid or expired reset token"); + } + }); + + it("invalidates all existing sessions", async () => { + const regResult = await service.register( + "test@example.com", + "password123", + ); + if (regResult.success) { + await service.verifyEmail(regResult.verificationToken); + } + + // Create a session + const loginResult = await service.login( + "test@example.com", + "password123", + "cookie", + ); + assert.equal(loginResult.success, true); + if (!loginResult.success) return; + + const sessionToken = loginResult.token; + + // Reset password + const resetToken = + await service.createPasswordResetToken("test@example.com"); + await service.resetPassword(resetToken!.token, "newpassword"); + + // Old session should be invalid + const validateResult = await service.validateToken(sessionToken); + assert.equal(validateResult.authenticated, false); + }); + }); +}); diff --git a/express/auth/store.spec.ts b/express/auth/store.spec.ts new file mode 100644 index 0000000..dc24dea --- /dev/null +++ b/express/auth/store.spec.ts @@ -0,0 +1,321 @@ +// Tests for auth/store.ts (InMemoryAuthStore) +// Pure unit tests - no database needed + +import assert from "node:assert/strict"; +import { after, before, beforeEach, describe, it } from "node:test"; +import type { UserId } from "../user"; +import { InMemoryAuthStore } from "./store"; +import { hashToken } from "./token"; +import type { TokenId } from "./types"; + +describe("InMemoryAuthStore", () => { + let store: InMemoryAuthStore; + + beforeEach(() => { + store = new InMemoryAuthStore(); + }); + + describe("createUser", () => { + it("creates a user with pending status", async () => { + const user = await store.createUser({ + email: "test@example.com", + passwordHash: "hash123", + displayName: "Test User", + }); + + assert.equal(user.email, "test@example.com"); + assert.equal(user.displayName, "Test User"); + assert.equal(user.status, "pending"); + }); + + it("creates a user without displayName", async () => { + const user = await store.createUser({ + email: "test@example.com", + passwordHash: "hash123", + }); + + assert.equal(user.email, "test@example.com"); + assert.equal(user.displayName, undefined); + }); + + it("generates a unique id", async () => { + const user1 = await store.createUser({ + email: "test1@example.com", + passwordHash: "hash123", + }); + const user2 = await store.createUser({ + email: "test2@example.com", + passwordHash: "hash456", + }); + + assert.notEqual(user1.id, user2.id); + }); + }); + + describe("getUserByEmail", () => { + it("returns user when found", async () => { + await store.createUser({ + email: "test@example.com", + passwordHash: "hash123", + }); + + const user = await store.getUserByEmail("test@example.com"); + assert.notEqual(user, null); + assert.equal(user!.email, "test@example.com"); + }); + + it("is case-insensitive", async () => { + await store.createUser({ + email: "Test@Example.COM", + passwordHash: "hash123", + }); + + const user = await store.getUserByEmail("test@example.com"); + assert.notEqual(user, null); + }); + + it("returns null when not found", async () => { + const user = await store.getUserByEmail("notfound@example.com"); + assert.equal(user, null); + }); + }); + + describe("getUserById", () => { + it("returns user when found", async () => { + const created = await store.createUser({ + email: "test@example.com", + passwordHash: "hash123", + }); + + const user = await store.getUserById(created.id); + assert.notEqual(user, null); + assert.equal(user!.id, created.id); + }); + + it("returns null when not found", async () => { + const user = await store.getUserById("nonexistent" as UserId); + assert.equal(user, null); + }); + }); + + describe("getUserPasswordHash", () => { + it("returns hash when found", async () => { + const user = await store.createUser({ + email: "test@example.com", + passwordHash: "hash123", + }); + + const hash = await store.getUserPasswordHash(user.id); + assert.equal(hash, "hash123"); + }); + + it("returns null when not found", async () => { + const hash = await store.getUserPasswordHash( + "nonexistent" as UserId, + ); + assert.equal(hash, null); + }); + }); + + describe("setUserPassword", () => { + it("updates password hash", async () => { + const user = await store.createUser({ + email: "test@example.com", + passwordHash: "oldhash", + }); + + await store.setUserPassword(user.id, "newhash"); + + const hash = await store.getUserPasswordHash(user.id); + assert.equal(hash, "newhash"); + }); + }); + + describe("updateUserEmailVerified", () => { + it("sets user status to active", async () => { + const created = await store.createUser({ + email: "test@example.com", + passwordHash: "hash123", + }); + assert.equal(created.status, "pending"); + + await store.updateUserEmailVerified(created.id); + + const user = await store.getUserById(created.id); + assert.equal(user!.status, "active"); + }); + }); + + describe("createSession", () => { + it("creates a session with token", async () => { + const user = await store.createUser({ + email: "test@example.com", + passwordHash: "hash123", + }); + + const { token, session } = await store.createSession({ + userId: user.id, + tokenType: "session", + authMethod: "cookie", + expiresAt: new Date(Date.now() + 3600000), + }); + + assert.ok(token.length > 0); + assert.equal(session.userId, user.id); + assert.equal(session.tokenType, "session"); + assert.equal(session.authMethod, "cookie"); + }); + + it("stores metadata", async () => { + const user = await store.createUser({ + email: "test@example.com", + passwordHash: "hash123", + }); + + const { session } = await store.createSession({ + userId: user.id, + tokenType: "session", + authMethod: "cookie", + expiresAt: new Date(Date.now() + 3600000), + userAgent: "Mozilla/5.0", + ipAddress: "127.0.0.1", + }); + + assert.equal(session.userAgent, "Mozilla/5.0"); + assert.equal(session.ipAddress, "127.0.0.1"); + }); + }); + + describe("getSession", () => { + it("returns session when found and not expired", async () => { + const user = await store.createUser({ + email: "test@example.com", + passwordHash: "hash123", + }); + + const { token } = await store.createSession({ + userId: user.id, + tokenType: "session", + authMethod: "cookie", + expiresAt: new Date(Date.now() + 3600000), // 1 hour from now + }); + + const tokenId = hashToken(token) as TokenId; + const session = await store.getSession(tokenId); + assert.notEqual(session, null); + assert.equal(session!.userId, user.id); + }); + + it("returns null for expired session", async () => { + const user = await store.createUser({ + email: "test@example.com", + passwordHash: "hash123", + }); + + const { token } = await store.createSession({ + userId: user.id, + tokenType: "session", + authMethod: "cookie", + expiresAt: new Date(Date.now() - 1000), // Expired 1 second ago + }); + + const tokenId = hashToken(token) as TokenId; + const session = await store.getSession(tokenId); + assert.equal(session, null); + }); + + it("returns null for nonexistent session", async () => { + const session = await store.getSession("nonexistent" as TokenId); + assert.equal(session, null); + }); + }); + + describe("deleteSession", () => { + it("removes the session", async () => { + const user = await store.createUser({ + email: "test@example.com", + passwordHash: "hash123", + }); + + const { token } = await store.createSession({ + userId: user.id, + tokenType: "session", + authMethod: "cookie", + expiresAt: new Date(Date.now() + 3600000), + }); + + const tokenId = hashToken(token) as TokenId; + await store.deleteSession(tokenId); + + const session = await store.getSession(tokenId); + assert.equal(session, null); + }); + }); + + describe("deleteUserSessions", () => { + it("removes all sessions for user", async () => { + const user = await store.createUser({ + email: "test@example.com", + passwordHash: "hash123", + }); + + const { token: token1 } = await store.createSession({ + userId: user.id, + tokenType: "session", + authMethod: "cookie", + expiresAt: new Date(Date.now() + 3600000), + }); + + const { token: token2 } = await store.createSession({ + userId: user.id, + tokenType: "session", + authMethod: "bearer", + expiresAt: new Date(Date.now() + 3600000), + }); + + const count = await store.deleteUserSessions(user.id); + assert.equal(count, 2); + + const session1 = await store.getSession( + hashToken(token1) as TokenId, + ); + const session2 = await store.getSession( + hashToken(token2) as TokenId, + ); + assert.equal(session1, null); + assert.equal(session2, null); + }); + + it("returns 0 when user has no sessions", async () => { + const count = await store.deleteUserSessions( + "nonexistent" as UserId, + ); + assert.equal(count, 0); + }); + }); + + describe("updateLastUsed", () => { + it("updates lastUsedAt timestamp", async () => { + const user = await store.createUser({ + email: "test@example.com", + passwordHash: "hash123", + }); + + const { token } = await store.createSession({ + userId: user.id, + tokenType: "session", + authMethod: "cookie", + expiresAt: new Date(Date.now() + 3600000), + }); + + const tokenId = hashToken(token) as TokenId; + const beforeUpdate = await store.getSession(tokenId); + assert.equal(beforeUpdate!.lastUsedAt, undefined); + + await store.updateLastUsed(tokenId); + + const afterUpdate = await store.getSession(tokenId); + assert.ok(afterUpdate!.lastUsedAt instanceof Date); + }); + }); +}); diff --git a/express/auth/token.spec.ts b/express/auth/token.spec.ts new file mode 100644 index 0000000..9c75e42 --- /dev/null +++ b/express/auth/token.spec.ts @@ -0,0 +1,94 @@ +// Tests for auth/token.ts +// Pure unit tests - no database needed + +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + generateToken, + hashToken, + parseAuthorizationHeader, + SESSION_COOKIE_NAME, +} from "./token"; + +describe("token", () => { + describe("generateToken", () => { + it("generates a non-empty string", () => { + const token = generateToken(); + assert.equal(typeof token, "string"); + assert.ok(token.length > 0); + }); + + it("generates unique tokens", () => { + const tokens = new Set(); + for (let i = 0; i < 100; i++) { + tokens.add(generateToken()); + } + assert.equal(tokens.size, 100); + }); + + it("generates base64url encoded tokens", () => { + const token = generateToken(); + // base64url uses A-Z, a-z, 0-9, -, _ + assert.match(token, /^[A-Za-z0-9_-]+$/); + }); + }); + + describe("hashToken", () => { + it("returns a hex string", () => { + const hash = hashToken("test-token"); + assert.match(hash, /^[a-f0-9]+$/); + }); + + it("returns consistent hash for same input", () => { + const hash1 = hashToken("test-token"); + const hash2 = hashToken("test-token"); + assert.equal(hash1, hash2); + }); + + it("returns different hash for different input", () => { + const hash1 = hashToken("token-1"); + const hash2 = hashToken("token-2"); + assert.notEqual(hash1, hash2); + }); + + it("returns 64 character hash (SHA-256)", () => { + const hash = hashToken("test-token"); + assert.equal(hash.length, 64); + }); + }); + + describe("parseAuthorizationHeader", () => { + it("returns null for undefined header", () => { + assert.equal(parseAuthorizationHeader(undefined), null); + }); + + it("returns null for empty string", () => { + assert.equal(parseAuthorizationHeader(""), null); + }); + + it("returns null for non-bearer auth", () => { + assert.equal(parseAuthorizationHeader("Basic abc123"), null); + }); + + it("returns null for malformed header", () => { + assert.equal(parseAuthorizationHeader("Bearer"), null); + assert.equal(parseAuthorizationHeader("Bearer token extra"), null); + }); + + it("extracts token from valid bearer header", () => { + assert.equal(parseAuthorizationHeader("Bearer abc123"), "abc123"); + }); + + it("is case-insensitive for Bearer keyword", () => { + assert.equal(parseAuthorizationHeader("bearer abc123"), "abc123"); + assert.equal(parseAuthorizationHeader("BEARER abc123"), "abc123"); + }); + }); + + describe("SESSION_COOKIE_NAME", () => { + it("is defined", () => { + assert.equal(typeof SESSION_COOKIE_NAME, "string"); + assert.ok(SESSION_COOKIE_NAME.length > 0); + }); + }); +}); diff --git a/express/auth/types.spec.ts b/express/auth/types.spec.ts new file mode 100644 index 0000000..9c39850 --- /dev/null +++ b/express/auth/types.spec.ts @@ -0,0 +1,253 @@ +// Tests for auth/types.ts +// Pure unit tests - no database needed + +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { z } from "zod"; +import { AuthenticatedUser, anonymousUser } from "../user"; +import { + authMethodParser, + forgotPasswordInputParser, + loginInputParser, + registerInputParser, + resetPasswordInputParser, + Session, + sessionDataParser, + tokenLifetimes, + tokenTypeParser, +} from "./types"; + +describe("auth/types", () => { + describe("tokenTypeParser", () => { + it("accepts valid token types", () => { + assert.equal(tokenTypeParser.parse("session"), "session"); + assert.equal( + tokenTypeParser.parse("password_reset"), + "password_reset", + ); + assert.equal(tokenTypeParser.parse("email_verify"), "email_verify"); + }); + + it("rejects invalid token types", () => { + assert.throws(() => tokenTypeParser.parse("invalid")); + }); + }); + + describe("authMethodParser", () => { + it("accepts valid auth methods", () => { + assert.equal(authMethodParser.parse("cookie"), "cookie"); + assert.equal(authMethodParser.parse("bearer"), "bearer"); + }); + + it("rejects invalid auth methods", () => { + assert.throws(() => authMethodParser.parse("basic")); + }); + }); + + describe("sessionDataParser", () => { + it("accepts valid session data", () => { + const data = { + tokenId: "abc123", + userId: "user-1", + tokenType: "session", + authMethod: "cookie", + createdAt: new Date(), + expiresAt: new Date(), + }; + const result = sessionDataParser.parse(data); + assert.equal(result.tokenId, "abc123"); + assert.equal(result.userId, "user-1"); + }); + + it("coerces date strings to dates", () => { + const data = { + tokenId: "abc123", + userId: "user-1", + tokenType: "session", + authMethod: "cookie", + createdAt: "2025-01-01T00:00:00Z", + expiresAt: "2025-01-02T00:00:00Z", + }; + const result = sessionDataParser.parse(data); + assert.ok(result.createdAt instanceof Date); + assert.ok(result.expiresAt instanceof Date); + }); + + it("accepts optional fields", () => { + const data = { + tokenId: "abc123", + userId: "user-1", + tokenType: "session", + authMethod: "cookie", + createdAt: new Date(), + expiresAt: new Date(), + lastUsedAt: new Date(), + userAgent: "Mozilla/5.0", + ipAddress: "127.0.0.1", + isUsed: true, + }; + const result = sessionDataParser.parse(data); + assert.equal(result.userAgent, "Mozilla/5.0"); + assert.equal(result.ipAddress, "127.0.0.1"); + assert.equal(result.isUsed, true); + }); + }); + + describe("loginInputParser", () => { + it("accepts valid login input", () => { + const result = loginInputParser.parse({ + email: "test@example.com", + password: "secret", + }); + assert.equal(result.email, "test@example.com"); + assert.equal(result.password, "secret"); + }); + + it("rejects invalid email", () => { + assert.throws(() => + loginInputParser.parse({ + email: "not-an-email", + password: "secret", + }), + ); + }); + + it("rejects empty password", () => { + assert.throws(() => + loginInputParser.parse({ + email: "test@example.com", + password: "", + }), + ); + }); + }); + + describe("registerInputParser", () => { + it("accepts valid registration input", () => { + const result = registerInputParser.parse({ + email: "test@example.com", + password: "password123", + displayName: "Test User", + }); + assert.equal(result.email, "test@example.com"); + assert.equal(result.password, "password123"); + assert.equal(result.displayName, "Test User"); + }); + + it("accepts registration without displayName", () => { + const result = registerInputParser.parse({ + email: "test@example.com", + password: "password123", + }); + assert.equal(result.displayName, undefined); + }); + + it("rejects password shorter than 8 characters", () => { + assert.throws(() => + registerInputParser.parse({ + email: "test@example.com", + password: "short", + }), + ); + }); + }); + + describe("forgotPasswordInputParser", () => { + it("accepts valid email", () => { + const result = forgotPasswordInputParser.parse({ + email: "test@example.com", + }); + assert.equal(result.email, "test@example.com"); + }); + + it("rejects invalid email", () => { + assert.throws(() => + forgotPasswordInputParser.parse({ + email: "invalid", + }), + ); + }); + }); + + describe("resetPasswordInputParser", () => { + it("accepts valid reset input", () => { + const result = resetPasswordInputParser.parse({ + token: "abc123", + password: "newpassword", + }); + assert.equal(result.token, "abc123"); + assert.equal(result.password, "newpassword"); + }); + + it("rejects empty token", () => { + assert.throws(() => + resetPasswordInputParser.parse({ + token: "", + password: "newpassword", + }), + ); + }); + + it("rejects password shorter than 8 characters", () => { + assert.throws(() => + resetPasswordInputParser.parse({ + token: "abc123", + password: "short", + }), + ); + }); + }); + + describe("tokenLifetimes", () => { + it("defines session lifetime", () => { + assert.ok(tokenLifetimes.session > 0); + // 30 days in ms + assert.equal(tokenLifetimes.session, 30 * 24 * 60 * 60 * 1000); + }); + + it("defines password_reset lifetime", () => { + assert.ok(tokenLifetimes.password_reset > 0); + // 1 hour in ms + assert.equal(tokenLifetimes.password_reset, 1 * 60 * 60 * 1000); + }); + + it("defines email_verify lifetime", () => { + assert.ok(tokenLifetimes.email_verify > 0); + // 24 hours in ms + assert.equal(tokenLifetimes.email_verify, 24 * 60 * 60 * 1000); + }); + }); + + describe("Session", () => { + it("wraps authenticated session", () => { + const user = AuthenticatedUser.create("test@example.com", { + id: "user-1", + }); + const sessionData = { + tokenId: "token-1", + userId: "user-1", + tokenType: "session" as const, + authMethod: "cookie" as const, + createdAt: new Date(), + expiresAt: new Date(), + }; + const session = new Session(sessionData, user); + + assert.equal(session.isAuthenticated(), true); + assert.equal(session.getUser(), user); + assert.equal(session.getData(), sessionData); + assert.equal(session.tokenId, "token-1"); + assert.equal(session.userId, "user-1"); + }); + + it("wraps anonymous session", () => { + const session = new Session(null, anonymousUser); + + assert.equal(session.isAuthenticated(), false); + assert.equal(session.getUser(), anonymousUser); + assert.equal(session.getData(), null); + assert.equal(session.tokenId, undefined); + assert.equal(session.userId, undefined); + }); + }); +}); diff --git a/express/basic/login.spec.ts b/express/basic/login.spec.ts new file mode 100644 index 0000000..2b4e44a --- /dev/null +++ b/express/basic/login.spec.ts @@ -0,0 +1,24 @@ +// Tests for basic/login.ts +// These tests verify the route structure and export + +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { loginRoute } from "./login"; + +describe("basic/login", () => { + describe("loginRoute", () => { + it("has correct path", () => { + assert.equal(loginRoute.path, "/login"); + }); + + it("handles GET and POST methods", () => { + assert.ok(loginRoute.methods.includes("GET")); + assert.ok(loginRoute.methods.includes("POST")); + assert.equal(loginRoute.methods.length, 2); + }); + + it("has a handler function", () => { + assert.equal(typeof loginRoute.handler, "function"); + }); + }); +}); diff --git a/express/basic/logout.spec.ts b/express/basic/logout.spec.ts new file mode 100644 index 0000000..3baaa34 --- /dev/null +++ b/express/basic/logout.spec.ts @@ -0,0 +1,24 @@ +// Tests for basic/logout.ts +// These tests verify the route structure and export + +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { logoutRoute } from "./logout"; + +describe("basic/logout", () => { + describe("logoutRoute", () => { + it("has correct path", () => { + assert.equal(logoutRoute.path, "/logout"); + }); + + it("handles GET and POST methods", () => { + assert.ok(logoutRoute.methods.includes("GET")); + assert.ok(logoutRoute.methods.includes("POST")); + assert.equal(logoutRoute.methods.length, 2); + }); + + it("has a handler function", () => { + assert.equal(typeof logoutRoute.handler, "function"); + }); + }); +}); diff --git a/express/basic/routes.spec.ts b/express/basic/routes.spec.ts new file mode 100644 index 0000000..0c6b0b9 --- /dev/null +++ b/express/basic/routes.spec.ts @@ -0,0 +1,73 @@ +// Tests for basic/routes.ts +// These tests verify the route structure and exports + +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { routes } from "./routes"; + +describe("basic/routes", () => { + describe("routes object", () => { + it("exports routes as an object", () => { + assert.equal(typeof routes, "object"); + }); + + it("contains hello route", () => { + assert.ok("hello" in routes); + assert.equal(routes.hello.path, "/hello"); + assert.ok(routes.hello.methods.includes("GET")); + }); + + it("contains home route", () => { + assert.ok("home" in routes); + assert.equal(routes.home.path, "/"); + assert.ok(routes.home.methods.includes("GET")); + }); + + it("contains login route", () => { + assert.ok("login" in routes); + assert.equal(routes.login.path, "/login"); + }); + + it("contains logout route", () => { + assert.ok("logout" in routes); + assert.equal(routes.logout.path, "/logout"); + }); + + it("all routes have handlers", () => { + for (const [name, route] of Object.entries(routes)) { + assert.equal( + typeof route.handler, + "function", + `Route ${name} should have a handler function`, + ); + } + }); + + it("all routes have methods array", () => { + for (const [name, route] of Object.entries(routes)) { + assert.ok( + Array.isArray(route.methods), + `Route ${name} should have methods array`, + ); + assert.ok( + route.methods.length > 0, + `Route ${name} should have at least one method`, + ); + } + }); + + it("all routes have path string", () => { + for (const [name, route] of Object.entries(routes)) { + assert.equal( + typeof route.path, + "string", + `Route ${name} should have a path string`, + ); + assert.ok( + route.path.startsWith("/"), + `Route ${name} path should start with /`, + ); + } + }); + }); +}); diff --git a/express/database.spec.ts b/express/database.spec.ts new file mode 100644 index 0000000..6718a07 --- /dev/null +++ b/express/database.spec.ts @@ -0,0 +1,285 @@ +// Tests for database.ts +// Requires test PostgreSQL: docker compose -f docker-compose.test.yml up -d + +import assert from "node:assert/strict"; +import { after, before, beforeEach, describe, it } from "node:test"; +import { + connectionConfig, + db, + migrate, + migrationStatus, + PostgresAuthStore, + pool, + raw, + rawPool, +} from "./database"; +import type { UserId } from "./user"; + +describe("database", () => { + before(async () => { + // Run migrations to set up schema + await migrate(); + }); + + after(async () => { + await pool.end(); + }); + + describe("connectionConfig", () => { + it("has required fields", () => { + assert.ok("host" in connectionConfig); + assert.ok("port" in connectionConfig); + assert.ok("user" in connectionConfig); + assert.ok("password" in connectionConfig); + assert.ok("database" in connectionConfig); + }); + + it("port is a number", () => { + assert.equal(typeof connectionConfig.port, "number"); + }); + }); + + describe("raw", () => { + it("executes raw SQL queries", async () => { + const result = await raw<{ one: number }>("SELECT 1 as one"); + assert.equal(result.length, 1); + assert.equal(result[0].one, 1); + }); + + it("supports parameterized queries", async () => { + const result = await raw<{ sum: number }>( + "SELECT $1::int + $2::int as sum", + [2, 3], + ); + assert.equal(result[0].sum, 5); + }); + }); + + describe("db (Kysely instance)", () => { + it("can execute SELECT queries", async () => { + const result = await db + .selectFrom("users") + .select("id") + .limit(1) + .execute(); + + // May be empty, just verify it runs + assert.ok(Array.isArray(result)); + }); + }); + + describe("rawPool", () => { + it("is a pg Pool instance", () => { + assert.ok(rawPool.query !== undefined); + }); + + it("can execute queries", async () => { + const result = await rawPool.query("SELECT 1 as one"); + assert.equal(result.rows[0].one, 1); + }); + }); + + describe("migrate", () => { + it("runs without error when migrations are up to date", async () => { + // Should not throw + await migrate(); + }); + }); + + describe("migrationStatus", () => { + it("returns applied and pending arrays", async () => { + const status = await migrationStatus(); + + assert.ok(Array.isArray(status.applied)); + assert.ok(Array.isArray(status.pending)); + }); + + it("shows framework migrations as applied", async () => { + const status = await migrationStatus(); + + // At least the users migration should be applied + const hasUsersMigration = status.applied.some((m) => + m.includes("users"), + ); + assert.ok(hasUsersMigration); + }); + }); + + describe("PostgresAuthStore", () => { + let store: PostgresAuthStore; + + before(() => { + store = new PostgresAuthStore(); + }); + + beforeEach(async () => { + // Clean up test data before each test + await rawPool.query("DELETE FROM sessions"); + await rawPool.query("DELETE FROM user_credentials"); + await rawPool.query("DELETE FROM user_emails"); + await rawPool.query("DELETE FROM users"); + }); + + describe("createUser", () => { + it("creates a user with pending status", async () => { + const user = await store.createUser({ + email: "test@example.com", + passwordHash: "hash123", + displayName: "Test User", + }); + + assert.equal(user.email, "test@example.com"); + assert.equal(user.displayName, "Test User"); + assert.equal(user.status, "pending"); + }); + + it("stores the password hash", async () => { + const user = await store.createUser({ + email: "test@example.com", + passwordHash: "secrethash", + }); + + const hash = await store.getUserPasswordHash(user.id); + assert.equal(hash, "secrethash"); + }); + }); + + describe("getUserByEmail", () => { + it("returns user when found", async () => { + await store.createUser({ + email: "find@example.com", + passwordHash: "hash", + }); + + const user = await store.getUserByEmail("find@example.com"); + assert.notEqual(user, null); + assert.equal(user!.email, "find@example.com"); + }); + + it("is case-insensitive", async () => { + await store.createUser({ + email: "UPPER@EXAMPLE.COM", + passwordHash: "hash", + }); + + const user = await store.getUserByEmail("upper@example.com"); + assert.notEqual(user, null); + }); + + it("returns null when not found", async () => { + const user = await store.getUserByEmail("notfound@example.com"); + assert.equal(user, null); + }); + }); + + describe("getUserById", () => { + it("returns user when found", async () => { + const created = await store.createUser({ + email: "test@example.com", + passwordHash: "hash", + }); + + const user = await store.getUserById(created.id); + assert.notEqual(user, null); + assert.equal(user!.id, created.id); + }); + + it("returns null when not found", async () => { + const user = await store.getUserById( + "00000000-0000-0000-0000-000000000000" as UserId, + ); + assert.equal(user, null); + }); + }); + + describe("setUserPassword", () => { + it("updates the password hash", async () => { + const user = await store.createUser({ + email: "test@example.com", + passwordHash: "oldhash", + }); + + await store.setUserPassword(user.id, "newhash"); + + const hash = await store.getUserPasswordHash(user.id); + assert.equal(hash, "newhash"); + }); + }); + + describe("updateUserEmailVerified", () => { + it("sets user status to active", async () => { + const created = await store.createUser({ + email: "test@example.com", + passwordHash: "hash", + }); + assert.equal(created.status, "pending"); + + await store.updateUserEmailVerified(created.id); + + const user = await store.getUserById(created.id); + assert.equal(user!.status, "active"); + }); + }); + + describe("session operations", () => { + let userId: UserId; + + beforeEach(async () => { + const user = await store.createUser({ + email: "session@example.com", + passwordHash: "hash", + }); + userId = user.id; + }); + + it("creates and retrieves sessions", async () => { + const { token, session } = await store.createSession({ + userId, + tokenType: "session", + authMethod: "cookie", + expiresAt: new Date(Date.now() + 3600000), + }); + + assert.ok(token.length > 0); + assert.equal(session.userId, userId); + assert.equal(session.tokenType, "session"); + }); + + it("deletes sessions", async () => { + const { session } = await store.createSession({ + userId, + tokenType: "session", + authMethod: "cookie", + expiresAt: new Date(Date.now() + 3600000), + }); + + await store.deleteSession(session.tokenId as any); + + // Session should be soft-deleted (revoked) + const retrieved = await store.getSession( + session.tokenId as any, + ); + assert.equal(retrieved, null); + }); + + it("deletes all user sessions", async () => { + await store.createSession({ + userId, + tokenType: "session", + authMethod: "cookie", + expiresAt: new Date(Date.now() + 3600000), + }); + + await store.createSession({ + userId, + tokenType: "session", + authMethod: "bearer", + expiresAt: new Date(Date.now() + 3600000), + }); + + const count = await store.deleteUserSessions(userId); + assert.equal(count, 2); + }); + }); + }); +}); diff --git a/express/framework/hydrators/tests/user.test.ts b/express/framework/hydrators/tests/user.spec.ts similarity index 100% rename from express/framework/hydrators/tests/user.test.ts rename to express/framework/hydrators/tests/user.spec.ts diff --git a/express/handlers.spec.ts b/express/handlers.spec.ts new file mode 100644 index 0000000..175b865 --- /dev/null +++ b/express/handlers.spec.ts @@ -0,0 +1,71 @@ +// Tests for handlers.ts +// These tests use mock Call objects + +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import type { Request as ExpressRequest } from "express"; +import { Session } from "./auth/types"; +import { contentTypes } from "./content-types"; +import { multiHandler } from "./handlers"; +import { httpCodes } from "./http-codes"; +import type { Call } from "./types"; +import { anonymousUser } from "./user"; + +// Helper to create a minimal mock Call +function createMockCall(overrides: Partial = {}): Call { + const defaultSession = new Session(null, anonymousUser); + return { + pattern: "/test", + path: "/test", + method: "GET", + parameters: {}, + request: {} as ExpressRequest, + user: anonymousUser, + session: defaultSession, + ...overrides, + }; +} + +describe("handlers", () => { + describe("multiHandler", () => { + it("returns OK status", async () => { + const call = createMockCall({ method: "GET" }); + const result = await multiHandler(call); + + assert.equal(result.code, httpCodes.success.OK); + }); + + it("returns text/plain content type", async () => { + const call = createMockCall(); + const result = await multiHandler(call); + + assert.equal(result.contentType, contentTypes.text.plain); + }); + + it("includes method in result", async () => { + const call = createMockCall({ method: "POST" }); + const result = await multiHandler(call); + + assert.ok(result.result.includes("POST")); + }); + + it("includes a random number in result", async () => { + const call = createMockCall(); + const result = await multiHandler(call); + + // Result format: "that was GET (0.123456789)" + assert.match(result.result, /that was \w+ \(\d+\.?\d*\)/); + }); + + it("works with different HTTP methods", async () => { + const methods = ["GET", "POST", "PUT", "PATCH", "DELETE"] as const; + + for (const method of methods) { + const call = createMockCall({ method }); + const result = await multiHandler(call); + + assert.ok(result.result.includes(method)); + } + }); + }); +}); diff --git a/express/logging.spec.ts b/express/logging.spec.ts new file mode 100644 index 0000000..0ae43e9 --- /dev/null +++ b/express/logging.spec.ts @@ -0,0 +1,53 @@ +// Tests for logging.ts +// Note: These tests verify the module structure and types. +// Full integration tests would require a running logging service. + +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +// We can't easily test log() and getLogs() without mocking fetch, +// but we can verify the module exports correctly and types work. + +describe("logging", () => { + describe("module structure", () => { + it("exports log function", async () => { + const { log } = await import("./logging"); + assert.equal(typeof log, "function"); + }); + + it("exports getLogs function", async () => { + const { getLogs } = await import("./logging"); + assert.equal(typeof getLogs, "function"); + }); + }); + + describe("Message type", () => { + // Type-level tests - if these compile, the types are correct + it("accepts valid message sources", () => { + type MessageSource = "logging" | "diagnostic" | "user"; + const sources: MessageSource[] = ["logging", "diagnostic", "user"]; + assert.equal(sources.length, 3); + }); + }); + + describe("FilterArgument type", () => { + // Type-level tests + it("accepts valid filter options", () => { + type FilterArgument = { + limit?: number; + before?: number; + after?: number; + match?: (string | RegExp)[]; + }; + + const filter: FilterArgument = { + limit: 10, + before: Date.now(), + after: Date.now() - 3600000, + match: ["error", /warning/i], + }; + + assert.ok(filter.limit === 10); + }); + }); +}); diff --git a/express/package.json b/express/package.json index f6317fa..23a2d2e 100644 --- a/express/package.json +++ b/express/package.json @@ -4,8 +4,8 @@ "description": "", "main": "index.js", "scripts": { - "test": "DB_PORT=5433 DB_USER=diachron_test DB_PASSWORD=diachron_test DB_NAME=diachron_test tsx --test 'framework/**/*.{test,spec}.ts'", - "test:watch": "DB_PORT=5433 DB_USER=diachron_test DB_PASSWORD=diachron_test DB_NAME=diachron_test tsx --test --watch 'framework/**/*.{test,spec}.ts'", + "test": "DB_PORT=5433 DB_USER=diachron_test DB_PASSWORD=diachron_test DB_NAME=diachron_test tsx --test '**/*.{test,spec}.ts'", + "test:watch": "DB_PORT=5433 DB_USER=diachron_test DB_PASSWORD=diachron_test DB_NAME=diachron_test tsx --test --watch '**/*.{test,spec}.ts'", "nodemon": "nodemon dist/index.js", "kysely-codegen": "kysely-codegen" }, diff --git a/express/types.spec.ts b/express/types.spec.ts new file mode 100644 index 0000000..8b98526 --- /dev/null +++ b/express/types.spec.ts @@ -0,0 +1,179 @@ +// Tests for types.ts +// Pure unit tests + +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import type { Request as ExpressRequest } from "express"; +import { Session } from "./auth/types"; +import { contentTypes } from "./content-types"; +import { httpCodes } from "./http-codes"; +import { + AuthenticationRequired, + AuthorizationDenied, + type Call, + isRedirect, + massageMethod, + methodParser, + type Permission, + type RedirectResult, + type Result, + requireAuth, + requirePermission, +} from "./types"; +import { AuthenticatedUser, anonymousUser } from "./user"; + +// Helper to create a minimal mock Call +function createMockCall(overrides: Partial = {}): Call { + const defaultSession = new Session(null, anonymousUser); + return { + pattern: "/test", + path: "/test", + method: "GET", + parameters: {}, + request: {} as ExpressRequest, + user: anonymousUser, + session: defaultSession, + ...overrides, + }; +} + +describe("types", () => { + describe("methodParser", () => { + it("accepts valid HTTP methods", () => { + assert.equal(methodParser.parse("GET"), "GET"); + assert.equal(methodParser.parse("POST"), "POST"); + assert.equal(methodParser.parse("PUT"), "PUT"); + assert.equal(methodParser.parse("PATCH"), "PATCH"); + assert.equal(methodParser.parse("DELETE"), "DELETE"); + }); + + it("rejects invalid methods", () => { + assert.throws(() => methodParser.parse("get")); + assert.throws(() => methodParser.parse("OPTIONS")); + assert.throws(() => methodParser.parse("HEAD")); + assert.throws(() => methodParser.parse("")); + }); + }); + + describe("massageMethod", () => { + it("converts lowercase to uppercase", () => { + assert.equal(massageMethod("get"), "GET"); + assert.equal(massageMethod("post"), "POST"); + assert.equal(massageMethod("put"), "PUT"); + assert.equal(massageMethod("patch"), "PATCH"); + assert.equal(massageMethod("delete"), "DELETE"); + }); + + it("handles mixed case", () => { + assert.equal(massageMethod("Get"), "GET"); + assert.equal(massageMethod("pOsT"), "POST"); + }); + + it("throws for invalid methods", () => { + assert.throws(() => massageMethod("options")); + assert.throws(() => massageMethod("head")); + }); + }); + + describe("isRedirect", () => { + it("returns true for redirect results", () => { + const result: RedirectResult = { + code: httpCodes.redirection.Found, + contentType: contentTypes.text.html, + result: "", + redirect: "/other", + }; + assert.equal(isRedirect(result), true); + }); + + it("returns false for non-redirect results", () => { + const result: Result = { + code: httpCodes.success.OK, + contentType: contentTypes.text.html, + result: "hello", + }; + assert.equal(isRedirect(result), false); + }); + }); + + describe("AuthenticationRequired", () => { + it("has correct name and message", () => { + const err = new AuthenticationRequired(); + assert.equal(err.name, "AuthenticationRequired"); + assert.equal(err.message, "Authentication required"); + }); + + it("is an instance of Error", () => { + const err = new AuthenticationRequired(); + assert.ok(err instanceof Error); + }); + }); + + describe("AuthorizationDenied", () => { + it("has correct name and message", () => { + const err = new AuthorizationDenied(); + assert.equal(err.name, "AuthorizationDenied"); + assert.equal(err.message, "Authorization denied"); + }); + + it("is an instance of Error", () => { + const err = new AuthorizationDenied(); + assert.ok(err instanceof Error); + }); + }); + + describe("requireAuth", () => { + it("returns user for authenticated call", () => { + const user = AuthenticatedUser.create("test@example.com"); + const session = new Session(null, user); + const call = createMockCall({ user, session }); + + const result = requireAuth(call); + assert.equal(result, user); + }); + + it("throws AuthenticationRequired for anonymous user", () => { + const call = createMockCall({ user: anonymousUser }); + + assert.throws(() => requireAuth(call), AuthenticationRequired); + }); + }); + + describe("requirePermission", () => { + it("returns user when they have the permission", () => { + const user = AuthenticatedUser.create("test@example.com", { + permissions: ["posts:create" as Permission], + }); + const session = new Session(null, user); + const call = createMockCall({ user, session }); + + const result = requirePermission( + call, + "posts:create" as Permission, + ); + assert.equal(result, user); + }); + + it("throws AuthenticationRequired for anonymous user", () => { + const call = createMockCall({ user: anonymousUser }); + + assert.throws( + () => requirePermission(call, "posts:create" as Permission), + AuthenticationRequired, + ); + }); + + it("throws AuthorizationDenied when missing permission", () => { + const user = AuthenticatedUser.create("test@example.com", { + permissions: ["posts:read" as Permission], + }); + const session = new Session(null, user); + const call = createMockCall({ user, session }); + + assert.throws( + () => requirePermission(call, "posts:create" as Permission), + AuthorizationDenied, + ); + }); + }); +}); diff --git a/express/user.spec.ts b/express/user.spec.ts new file mode 100644 index 0000000..37cb742 --- /dev/null +++ b/express/user.spec.ts @@ -0,0 +1,213 @@ +// Tests for user.ts +// These are pure unit tests - no database needed + +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + AnonymousUser, + AuthenticatedUser, + anonymousUser, + type Permission, + type Role, +} from "./user"; + +describe("User", () => { + describe("AuthenticatedUser.create", () => { + it("creates a user with default values", () => { + const user = AuthenticatedUser.create("test@example.com"); + + assert.equal(user.email, "test@example.com"); + assert.equal(user.status, "active"); + assert.equal(user.isAnonymous(), false); + assert.deepEqual([...user.roles], []); + assert.deepEqual([...user.permissions], []); + }); + + it("creates a user with custom values", () => { + const user = AuthenticatedUser.create("test@example.com", { + id: "custom-id", + displayName: "Test User", + status: "pending", + roles: ["admin"], + permissions: ["posts:create"], + }); + + assert.equal(user.id, "custom-id"); + assert.equal(user.displayName, "Test User"); + assert.equal(user.status, "pending"); + assert.deepEqual([...user.roles], ["admin"]); + assert.deepEqual([...user.permissions], ["posts:create"]); + }); + }); + + describe("status checks", () => { + it("isActive returns true for active users", () => { + const user = AuthenticatedUser.create("test@example.com", { + status: "active", + }); + assert.equal(user.isActive(), true); + }); + + it("isActive returns false for suspended users", () => { + const user = AuthenticatedUser.create("test@example.com", { + status: "suspended", + }); + assert.equal(user.isActive(), false); + }); + + it("isActive returns false for pending users", () => { + const user = AuthenticatedUser.create("test@example.com", { + status: "pending", + }); + assert.equal(user.isActive(), false); + }); + }); + + describe("role checks", () => { + it("hasRole returns true when user has the role", () => { + const user = AuthenticatedUser.create("test@example.com", { + roles: ["admin", "editor"], + }); + assert.equal(user.hasRole("admin"), true); + assert.equal(user.hasRole("editor"), true); + }); + + it("hasRole returns false when user does not have the role", () => { + const user = AuthenticatedUser.create("test@example.com", { + roles: ["user"], + }); + assert.equal(user.hasRole("admin"), false); + }); + + it("hasAnyRole returns true when user has at least one role", () => { + const user = AuthenticatedUser.create("test@example.com", { + roles: ["editor"], + }); + assert.equal(user.hasAnyRole(["admin", "editor"]), true); + }); + + it("hasAnyRole returns false when user has none of the roles", () => { + const user = AuthenticatedUser.create("test@example.com", { + roles: ["user"], + }); + assert.equal(user.hasAnyRole(["admin", "editor"]), false); + }); + + it("hasAllRoles returns true when user has all roles", () => { + const user = AuthenticatedUser.create("test@example.com", { + roles: ["admin", "editor", "user"], + }); + assert.equal(user.hasAllRoles(["admin", "editor"]), true); + }); + + it("hasAllRoles returns false when user is missing a role", () => { + const user = AuthenticatedUser.create("test@example.com", { + roles: ["admin"], + }); + assert.equal(user.hasAllRoles(["admin", "editor"]), false); + }); + }); + + describe("permission checks", () => { + it("hasPermission returns true for direct permissions", () => { + const user = AuthenticatedUser.create("test@example.com", { + permissions: ["posts:create" as Permission], + }); + assert.equal( + user.hasPermission("posts:create" as Permission), + true, + ); + }); + + it("hasPermission returns true for role-derived permissions", () => { + const user = AuthenticatedUser.create("test@example.com", { + roles: ["admin" as Role], + }); + // admin role has users:read, users:create, users:update, users:delete + assert.equal(user.hasPermission("users:read" as Permission), true); + assert.equal( + user.hasPermission("users:delete" as Permission), + true, + ); + }); + + it("hasPermission returns false when permission not granted", () => { + const user = AuthenticatedUser.create("test@example.com", { + roles: ["user" as Role], + }); + // user role only has users:read + assert.equal( + user.hasPermission("users:delete" as Permission), + false, + ); + }); + + it("can() is a convenience method for hasPermission", () => { + const user = AuthenticatedUser.create("test@example.com", { + roles: ["admin" as Role], + }); + assert.equal(user.can("read", "users"), true); + assert.equal(user.can("delete", "users"), true); + assert.equal(user.can("create", "posts"), false); + }); + }); + + describe("effectivePermissions", () => { + it("returns combined direct and role-derived permissions", () => { + const user = AuthenticatedUser.create("test@example.com", { + roles: ["user" as Role], + permissions: ["posts:create" as Permission], + }); + + const perms = user.effectivePermissions(); + assert.equal(perms.has("posts:create" as Permission), true); + assert.equal(perms.has("users:read" as Permission), true); // from user role + }); + + it("returns empty set for user with no roles or permissions", () => { + const user = AuthenticatedUser.create("test@example.com"); + const perms = user.effectivePermissions(); + assert.equal(perms.size, 0); + }); + }); + + describe("serialization", () => { + it("toJSON returns plain object", () => { + const user = AuthenticatedUser.create("test@example.com", { + id: "test-id", + displayName: "Test", + status: "active", + roles: ["admin"], + permissions: ["posts:create"], + }); + + const json = user.toJSON(); + assert.equal(json.id, "test-id"); + assert.equal(json.email, "test@example.com"); + assert.equal(json.displayName, "Test"); + assert.equal(json.status, "active"); + assert.deepEqual(json.roles, ["admin"]); + assert.deepEqual(json.permissions, ["posts:create"]); + }); + + it("toString returns readable string", () => { + const user = AuthenticatedUser.create("test@example.com", { + id: "test-id", + }); + assert.equal(user.toString(), "User(id test-id)"); + }); + }); + + describe("AnonymousUser", () => { + it("isAnonymous returns true", () => { + const user = AnonymousUser.create("anon@example.com"); + assert.equal(user.isAnonymous(), true); + }); + + it("anonymousUser singleton is anonymous", () => { + assert.equal(anonymousUser.isAnonymous(), true); + assert.equal(anonymousUser.id, "-1"); + assert.equal(anonymousUser.email, "anonymous@example.com"); + }); + }); +}); diff --git a/express/util.spec.ts b/express/util.spec.ts new file mode 100644 index 0000000..5627d8d --- /dev/null +++ b/express/util.spec.ts @@ -0,0 +1,61 @@ +// Tests for util.ts +// Pure unit tests with filesystem + +import assert from "node:assert/strict"; +import { mkdir, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { after, before, describe, it } from "node:test"; +import { loadFile } from "./util"; + +describe("util", () => { + const testDir = join(import.meta.dirname, ".test-util-tmp"); + + before(async () => { + await mkdir(testDir, { recursive: true }); + }); + + after(async () => { + await rm(testDir, { recursive: true, force: true }); + }); + + describe("loadFile", () => { + it("loads file contents as string", async () => { + const testFile = join(testDir, "test.txt"); + await writeFile(testFile, "hello world"); + + const content = await loadFile(testFile); + assert.equal(content, "hello world"); + }); + + it("handles utf-8 content", async () => { + const testFile = join(testDir, "utf8.txt"); + await writeFile(testFile, "hello \u{1F511} world"); + + const content = await loadFile(testFile); + assert.equal(content, "hello \u{1F511} world"); + }); + + it("handles empty file", async () => { + const testFile = join(testDir, "empty.txt"); + await writeFile(testFile, ""); + + const content = await loadFile(testFile); + assert.equal(content, ""); + }); + + it("handles multiline content", async () => { + const testFile = join(testDir, "multiline.txt"); + await writeFile(testFile, "line1\nline2\nline3"); + + const content = await loadFile(testFile); + assert.equal(content, "line1\nline2\nline3"); + }); + + it("throws for nonexistent file", async () => { + await assert.rejects( + loadFile(join(testDir, "nonexistent.txt")), + /ENOENT/, + ); + }); + }); +});