From dfda629b12e50a555886902d9e4bd94753e1993a Mon Sep 17 00:00:00 2001 From: Aarnav Tale Date: Thu, 19 Mar 2026 14:11:17 -0400 Subject: [PATCH] test: add some unit tests for auth methods --- tests/unit/auth/auth-service.test.ts | 265 +++++++++++++++++++++ tests/unit/auth/create-auth.ts | 21 ++ tests/unit/auth/headscale-identity.test.ts | 75 ++++++ 3 files changed, 361 insertions(+) create mode 100644 tests/unit/auth/auth-service.test.ts create mode 100644 tests/unit/auth/create-auth.ts create mode 100644 tests/unit/auth/headscale-identity.test.ts diff --git a/tests/unit/auth/auth-service.test.ts b/tests/unit/auth/auth-service.test.ts new file mode 100644 index 0000000..6605870 --- /dev/null +++ b/tests/unit/auth/auth-service.test.ts @@ -0,0 +1,265 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; + +import type { AuthService, Principal } from "~/server/web/auth"; +import { Capabilities } from "~/server/web/roles"; +import type { Machine } from "~/types"; + +import { createTestAuth } from "./create-auth"; + +vi.mock("~/utils/log", () => ({ + default: { warn: vi.fn(), error: vi.fn(), debug: vi.fn(), info: vi.fn() }, +})); + +describe("findOrCreateUser", () => { + let auth: AuthService; + + beforeEach(() => { + ({ auth } = createTestAuth()); + }); + + test("first created user becomes owner", async () => { + await auth.findOrCreateUser("sub-owner", { name: "Owner" }); + const role = await auth.roleForSubject("sub-owner"); + expect(role).toBe("owner"); + }); + + test("second distinct user stays member", async () => { + await auth.findOrCreateUser("sub-owner", { name: "Owner" }); + await auth.findOrCreateUser("sub-member", { name: "Member" }); + const role = await auth.roleForSubject("sub-member"); + expect(role).toBe("member"); + }); + + test("existing subject returns same id (idempotent)", async () => { + const id1 = await auth.findOrCreateUser("sub-1", { name: "Alice" }); + const id2 = await auth.findOrCreateUser("sub-1", { name: "Alice" }); + expect(id1).toBe(id2); + }); + + test("updates name and email on re-login", async () => { + await auth.findOrCreateUser("sub-1", { name: "Old", email: "old@test.com" }); + await auth.findOrCreateUser("sub-1", { name: "New", email: "new@test.com" }); + + const users = await auth.listUsers(); + const user = users.find((u) => u.sub === "sub-1"); + expect(user?.name).toBe("New"); + expect(user?.email).toBe("new@test.com"); + }); +}); + +describe("linkHeadscaleUser", () => { + let auth: AuthService; + + beforeEach(() => { + ({ auth } = createTestAuth()); + }); + + test("links unclaimed Headscale user successfully", async () => { + const userId = await auth.findOrCreateUser("sub-1"); + const result = await auth.linkHeadscaleUser(userId, "hs-1"); + expect(result).toBe(true); + }); + + test("returns false if another Headplane user already claimed that headscale_user_id", async () => { + const id1 = await auth.findOrCreateUser("sub-1"); + const id2 = await auth.findOrCreateUser("sub-2"); + await auth.linkHeadscaleUser(id1, "hs-1"); + + const result = await auth.linkHeadscaleUser(id2, "hs-1"); + expect(result).toBe(false); + }); + + test("unlinkHeadscaleUser clears the link", async () => { + const userId = await auth.findOrCreateUser("sub-1"); + await auth.linkHeadscaleUser(userId, "hs-1"); + await auth.unlinkHeadscaleUser(userId); + + const users = await auth.listUsers(); + const user = users.find((u) => u.id === userId); + expect(user?.headscale_user_id).toBeNull(); + }); + + test("linkHeadscaleUserBySubject works through subject lookup", async () => { + await auth.findOrCreateUser("sub-1"); + const result = await auth.linkHeadscaleUserBySubject("sub-1", "hs-1"); + expect(result).toBe(true); + }); +}); + +describe("reassignSubject", () => { + let auth: AuthService; + + beforeEach(() => { + ({ auth } = createTestAuth()); + }); + + test("updates an existing non-owner role", async () => { + await auth.findOrCreateUser("sub-owner"); + await auth.findOrCreateUser("sub-user"); + + const result = await auth.reassignSubject("sub-user", "admin"); + expect(result).toBe(true); + + const role = await auth.roleForSubject("sub-user"); + expect(role).toBe("admin"); + }); + + test("returns false for owner demotion attempt", async () => { + await auth.findOrCreateUser("sub-owner"); + + const result = await auth.reassignSubject("sub-owner", "member"); + expect(result).toBe(false); + }); + + test("creates user with role if subject doesn't exist yet (upsert behavior)", async () => { + const result = await auth.reassignSubject("sub-new", "auditor"); + expect(result).toBe(true); + + const role = await auth.roleForSubject("sub-new"); + expect(role).toBe("auditor"); + }); +}); + +describe("transferOwnership", () => { + let auth: AuthService; + + beforeEach(() => { + ({ auth } = createTestAuth()); + }); + + test("swaps roles: owner→admin, target→owner", async () => { + await auth.findOrCreateUser("sub-owner"); + await auth.findOrCreateUser("sub-target"); + + const result = await auth.transferOwnership("sub-owner", "sub-target"); + expect(result).toBe(true); + + expect(await auth.roleForSubject("sub-owner")).toBe("admin"); + expect(await auth.roleForSubject("sub-target")).toBe("owner"); + }); + + test("returns false if caller is not owner", async () => { + await auth.findOrCreateUser("sub-owner"); + await auth.findOrCreateUser("sub-other"); + + const result = await auth.transferOwnership("sub-other", "sub-owner"); + expect(result).toBe(false); + }); + + test("returns false if target doesn't exist", async () => { + await auth.findOrCreateUser("sub-owner"); + + const result = await auth.transferOwnership("sub-owner", "sub-ghost"); + expect(result).toBe(false); + }); + + test("returns false if target is same user as owner", async () => { + await auth.findOrCreateUser("sub-owner"); + + const result = await auth.transferOwnership("sub-owner", "sub-owner"); + expect(result).toBe(false); + }); +}); + +describe("session round-trip", () => { + let auth: AuthService; + + beforeEach(() => { + ({ auth } = createTestAuth()); + }); + + test("createOidcSession + require resolves back to OIDC principal with correct user data", async () => { + const userId = await auth.findOrCreateUser("sub-1", { name: "Alice", email: "alice@test.com" }); + + const cookieHeader = await auth.createOidcSession(userId, { + name: "Alice", + email: "alice@test.com", + }); + + const cookieValue = cookieHeader.split(";")[0]; + const request = new Request("http://localhost/test", { + headers: { cookie: cookieValue }, + }); + + const principal = await auth.require(request); + expect(principal.kind).toBe("oidc"); + if (principal.kind === "oidc") { + expect(principal.user.id).toBe(userId); + expect(principal.user.subject).toBe("sub-1"); + expect(principal.profile.name).toBe("Alice"); + expect(principal.profile.email).toBe("alice@test.com"); + } + }); + + test("expired session throws", async () => { + const userId = await auth.findOrCreateUser("sub-1", { name: "Alice" }); + + const cookieHeader = await auth.createOidcSession(userId, { name: "Alice" }, -1); + + const cookieValue = cookieHeader.split(";")[0]; + const request = new Request("http://localhost/test", { + headers: { cookie: cookieValue }, + }); + + await expect(auth.require(request)).rejects.toThrow(); + }); +}); + +describe("authorization", () => { + let auth: AuthService; + + beforeEach(() => { + ({ auth } = createTestAuth()); + }); + + const apiKeyPrincipal: Principal = { + kind: "api_key", + sessionId: "test", + displayName: "Test Key", + apiKey: "key", + }; + + const oidcPrincipal: Principal = { + kind: "oidc", + sessionId: "test", + user: { id: "u1", subject: "sub1", role: "viewer", headscaleUserId: "hs-1" }, + profile: { name: "Test" }, + }; + + const machine: Machine = { + id: "m1", + machineKey: "", + nodeKey: "", + discoKey: "", + ipAddresses: [], + name: "test", + lastSeen: "", + expiry: null, + createdAt: "", + registerMethod: "REGISTER_METHOD_OIDC", + tags: [], + givenName: "test", + online: true, + approvedRoutes: [], + availableRoutes: [], + subnetRoutes: [], + user: { id: "hs-1", name: "test", createdAt: "" }, + }; + + test("can() returns true for api_key principal regardless of capability", () => { + expect(auth.can(apiKeyPrincipal, Capabilities.write_machines)).toBe(true); + expect(auth.can(apiKeyPrincipal, Capabilities.owner)).toBe(true); + }); + + test("canManageNode() returns true when user owns the node (matching headscaleUserId)", () => { + expect(auth.canManageNode(oidcPrincipal, machine)).toBe(true); + }); + + test("canManageNode() returns false when user doesn't own the node and lacks write_machines", () => { + const otherMachine: Machine = { + ...machine, + user: { id: "hs-other", name: "other", createdAt: "" }, + }; + expect(auth.canManageNode(oidcPrincipal, otherMachine)).toBe(false); + }); +}); diff --git a/tests/unit/auth/create-auth.ts b/tests/unit/auth/create-auth.ts new file mode 100644 index 0000000..0c05322 --- /dev/null +++ b/tests/unit/auth/create-auth.ts @@ -0,0 +1,21 @@ +import { drizzle } from "drizzle-orm/node-sqlite"; +import { migrate } from "drizzle-orm/node-sqlite/migrator"; + +import { AuthService } from "~/server/web/auth"; + +export function createTestAuth() { + const db = drizzle(":memory:"); + migrate(db, { migrationsFolder: "./drizzle" }); + + const auth = new AuthService({ + secret: "test-secret-key-for-unit-tests", + db, + cookie: { + name: "_hp_test", + secure: false, + maxAge: 3600, + }, + }); + + return { auth, db }; +} diff --git a/tests/unit/auth/headscale-identity.test.ts b/tests/unit/auth/headscale-identity.test.ts new file mode 100644 index 0000000..b0dbe4c --- /dev/null +++ b/tests/unit/auth/headscale-identity.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, test } from "vitest"; + +import { findHeadscaleUserBySubject, getOidcSubject } from "~/server/web/headscale-identity"; +import type { User } from "~/types/User"; + +function makeUser(overrides: Partial = {}): User { + return { + id: "1", + name: "test", + createdAt: "2024-01-01T00:00:00Z", + ...overrides, + }; +} + +describe("getOidcSubject", () => { + test("returns undefined for non-OIDC users", () => { + expect(getOidcSubject(makeUser({ provider: "local" }))).toBeUndefined(); + expect(getOidcSubject(makeUser())).toBeUndefined(); + }); + + test("returns undefined when providerId is missing", () => { + expect(getOidcSubject(makeUser({ provider: "oidc" }))).toBeUndefined(); + expect(getOidcSubject(makeUser({ provider: "oidc", providerId: "" }))).toBeUndefined(); + }); + + test("returns last path segment from providerId URL", () => { + const user = makeUser({ + provider: "oidc", + providerId: "https://idp.example.com/abc-123", + }); + expect(getOidcSubject(user)).toBe("abc-123"); + }); +}); + +describe("findHeadscaleUserBySubject", () => { + const oidcUser = makeUser({ + id: "1", + provider: "oidc", + providerId: "https://idp.example.com/sub-1", + email: "alice@example.com", + }); + + const localUser = makeUser({ + id: "2", + provider: "local", + email: "bob@example.com", + }); + + const users = [oidcUser, localUser]; + + test("matches by subject first", () => { + expect(findHeadscaleUserBySubject(users, "sub-1")).toBe(oidcUser); + }); + + test("falls back to email if no subject match", () => { + expect(findHeadscaleUserBySubject(users, "no-match", "bob@example.com")).toBe(localUser); + }); + + test("returns undefined if no email and no subject match", () => { + expect(findHeadscaleUserBySubject(users, "no-match")).toBeUndefined(); + }); + + test("prefers subject match over email match", () => { + const otherOidc = makeUser({ + id: "3", + provider: "oidc", + providerId: "https://idp.example.com/sub-2", + email: "alice@example.com", + }); + + expect(findHeadscaleUserBySubject([oidcUser, otherOidc], "sub-2", "alice@example.com")).toBe( + otherOidc, + ); + }); +});