diff --git a/tests/unit/agent/refresh-interval.test.ts b/tests/unit/agent/refresh-interval.test.ts deleted file mode 100644 index efc9da8..0000000 --- a/tests/unit/agent/refresh-interval.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { describe, expect, test, vi, beforeEach, afterEach } from "vitest"; - -describe("Agent refresh interval", () => { - beforeEach(() => { - vi.useFakeTimers(); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - test("setInterval is called with cache_ttl value", () => { - const setIntervalSpy = vi.spyOn(global, "setInterval"); - const cache_ttl = 180000; // 3 minutes - - // Simulate what the agent does when starting refresh - const refreshInterval = setInterval(() => { - // refresh logic - }, cache_ttl); - - expect(setIntervalSpy).toHaveBeenCalledWith(expect.any(Function), cache_ttl); - - clearInterval(refreshInterval); - setIntervalSpy.mockRestore(); - }); - - test("interval triggers callback at expected times", () => { - const callback = vi.fn(); - const cache_ttl = 60000; // 1 minute for test - - const interval = setInterval(callback, cache_ttl); - - expect(callback).not.toHaveBeenCalled(); - - vi.advanceTimersByTime(cache_ttl); - expect(callback).toHaveBeenCalledTimes(1); - - vi.advanceTimersByTime(cache_ttl); - expect(callback).toHaveBeenCalledTimes(2); - - vi.advanceTimersByTime(cache_ttl); - expect(callback).toHaveBeenCalledTimes(3); - - clearInterval(interval); - }); - - test("clearInterval stops the refresh", () => { - const callback = vi.fn(); - const cache_ttl = 60000; - - const interval = setInterval(callback, cache_ttl); - - vi.advanceTimersByTime(cache_ttl); - expect(callback).toHaveBeenCalledTimes(1); - - clearInterval(interval); - - vi.advanceTimersByTime(cache_ttl * 5); - expect(callback).toHaveBeenCalledTimes(1); // still 1, not called again - }); -}); diff --git a/tests/unit/auth-keys/self-service.test.ts b/tests/unit/auth-keys/self-service.test.ts deleted file mode 100644 index a79a9f0..0000000 --- a/tests/unit/auth-keys/self-service.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { describe, expect, test } from "vitest"; - -import { Capabilities, hasCapability, Roles } from "~/server/web/roles"; - -describe("Self-service pre-auth keys", () => { - describe("Capabilities", () => { - test("generate_own_authkeys capability exists", () => { - expect(Capabilities.generate_own_authkeys).toBeDefined(); - expect(typeof Capabilities.generate_own_authkeys).toBe("number"); - }); - - test("generate_own_authkeys is distinct from generate_authkeys", () => { - expect(Capabilities.generate_own_authkeys).not.toBe(Capabilities.generate_authkeys); - }); - }); - - describe("Auditor role", () => { - test("auditor has generate_own_authkeys", () => { - expect(hasCapability("auditor", "generate_own_authkeys")).toBe(true); - }); - - test("auditor does not have generate_authkeys", () => { - expect(hasCapability("auditor", "generate_authkeys")).toBe(false); - }); - - test("auditor has read permissions", () => { - expect(hasCapability("auditor", "read_machines")).toBe(true); - expect(hasCapability("auditor", "read_users")).toBe(true); - expect(hasCapability("auditor", "read_policy")).toBe(true); - }); - }); - - describe("Admin roles retain full access", () => { - test("owner has generate_authkeys", () => { - expect(hasCapability("owner", "generate_authkeys")).toBe(true); - }); - - test("admin has generate_authkeys", () => { - expect(hasCapability("admin", "generate_authkeys")).toBe(true); - }); - - test("it_admin has generate_authkeys", () => { - expect(hasCapability("it_admin", "generate_authkeys")).toBe(true); - }); - - test("network_admin has generate_authkeys", () => { - expect(hasCapability("network_admin", "generate_authkeys")).toBe(true); - }); - }); - - describe("Member role", () => { - test("member has no capabilities", () => { - expect(Roles.member).toBe(0); - }); - - test("member does not have generate_own_authkeys", () => { - expect(hasCapability("member", "generate_own_authkeys")).toBe(false); - }); - }); -}); - -describe("providerId subject extraction", () => { - function extractSubject(providerId: string | undefined): string | undefined { - return providerId?.split("/").pop(); - } - - test("extracts subject from oidc providerId", () => { - expect(extractSubject("oidc/abc123")).toBe("abc123"); - }); - - test("extracts subject from nested providerId", () => { - expect(extractSubject("provider/tenant/user123")).toBe("user123"); - }); - - test("handles single component providerId", () => { - expect(extractSubject("subject")).toBe("subject"); - }); - - test("returns undefined for undefined providerId", () => { - expect(extractSubject(undefined)).toBeUndefined(); - }); -}); diff --git a/tests/unit/auth/oidc-owner-assignment.test.ts b/tests/unit/auth/oidc-owner-assignment.test.ts deleted file mode 100644 index f446908..0000000 --- a/tests/unit/auth/oidc-owner-assignment.test.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { createClient } from "@libsql/client"; -import { count, eq } from "drizzle-orm"; -import { drizzle } from "drizzle-orm/libsql"; -import { ulid } from "ulidx"; -import { beforeEach, describe, expect, test } from "vitest"; - -import { users } from "~/server/db/schema"; -import { Roles } from "~/server/web/roles"; - -function createTestDb() { - const client = createClient({ url: ":memory:" }); - const db = drizzle(client); - return { client, db }; -} - -async function setupSchema(client: ReturnType) { - await client.execute(` - CREATE TABLE IF NOT EXISTS users ( - id TEXT PRIMARY KEY, - sub TEXT NOT NULL UNIQUE, - caps INTEGER NOT NULL DEFAULT 0, - onboarded INTEGER NOT NULL DEFAULT 0 - ) - `); -} - -async function countOwners(db: ReturnType) { - const [result] = await db - .select({ count: count() }) - .from(users) - .where(eq(users.caps, Roles.owner)); - return result?.count ?? 0; -} - -async function simulateOidcLogin(db: ReturnType, subject: string) { - const ownerCount = await countOwners(db); - const needsOwner = ownerCount === 0; - - if (needsOwner) { - await db - .insert(users) - .values({ - id: ulid(), - sub: subject, - caps: Roles.owner, - }) - .onConflictDoUpdate({ - target: users.sub, - set: { caps: Roles.owner }, - }); - } else { - await db - .insert(users) - .values({ - id: ulid(), - sub: subject, - caps: Roles.member, - }) - .onConflictDoNothing(); - } -} - -describe("OIDC owner assignment", () => { - let db: ReturnType; - let client: ReturnType; - - beforeEach(async () => { - const testDb = createTestDb(); - db = testDb.db; - client = testDb.client; - await setupSchema(client); - }); - - test("first user gets owner role", async () => { - await simulateOidcLogin(db, "first-user"); - - const [user] = await db.select().from(users).where(eq(users.sub, "first-user")); - expect(user.caps).toBe(Roles.owner); - }); - - test("second user gets member role when owner exists", async () => { - await simulateOidcLogin(db, "first-user"); - await simulateOidcLogin(db, "second-user"); - - const [second] = await db.select().from(users).where(eq(users.sub, "second-user")); - expect(second.caps).toBe(Roles.member); - }); - - test("existing member becomes owner if no owner exists", async () => { - await db.insert(users).values({ - id: ulid(), - sub: "orphaned-user", - caps: Roles.member, - onboarded: false, - }); - - await simulateOidcLogin(db, "orphaned-user"); - - const [user] = await db.select().from(users).where(eq(users.sub, "orphaned-user")); - expect(user.caps).toBe(Roles.owner); - }); - - test("existing member stays member when owner exists", async () => { - await simulateOidcLogin(db, "owner-user"); - await db.insert(users).values({ - id: ulid(), - sub: "member-user", - caps: Roles.member, - onboarded: false, - }); - - await simulateOidcLogin(db, "member-user"); - - const [member] = await db.select().from(users).where(eq(users.sub, "member-user")); - expect(member.caps).toBe(Roles.member); - }); -}); diff --git a/tests/unit/config/config-file.test.ts b/tests/unit/config/config-file.test.ts index 0b9bc60..c474a06 100644 --- a/tests/unit/config/config-file.test.ts +++ b/tests/unit/config/config-file.test.ts @@ -1,81 +1,137 @@ -import { dump } from 'js-yaml'; -import { beforeAll, describe, expect, test } from 'vitest'; -import { ConfigError } from '~/server/config/error'; -import { loadConfig, loadConfigFile } from '~/server/config/load'; -import { clearFakeFiles, createFakeFile } from '../setup/overlay-fs'; +import { dump } from "js-yaml"; +import { beforeAll, describe, expect, test } from "vitest"; + +import { ConfigError } from "~/server/config/error"; +import { loadConfig, loadConfigFile } from "~/server/config/load"; + +import { clearFakeFiles, createFakeFile } from "../setup/overlay-fs"; const writeYaml = (filePath: string, content: unknown) => { - const yamlContent = dump(content); - createFakeFile(filePath, yamlContent); + const yamlContent = dump(content); + createFakeFile(filePath, yamlContent); }; -describe('Configuration YAML file loading', () => { - beforeAll(() => { - clearFakeFiles(); - }); +describe("Configuration YAML file loading", () => { + beforeAll(() => { + clearFakeFiles(); + }); - test('should correctly parse different types from YAML file', async () => { - const filePath = '/config/test-config.yaml'; - writeYaml(filePath, { - headscale: { - url: 'http://localhost:8080', - }, - oidc: { - client_id: 'my-client-id', - }, - server: { - port: 8000, - }, - integration: { - agent: { - enabled: true, - }, - }, - }); + test("should correctly parse different types from YAML file", async () => { + const filePath = "/config/test-config.yaml"; + writeYaml(filePath, { + headscale: { + url: "http://localhost:8080", + }, + oidc: { + client_id: "my-client-id", + }, + server: { + port: 8000, + }, + integration: { + agent: { + enabled: true, + }, + }, + }); - const config = await loadConfigFile(filePath); - expect(config?.headscale?.url).toBe('http://localhost:8080'); - expect(config?.oidc?.client_id).toBe('my-client-id'); - expect(config?.server?.port).toBe(8000); - expect(config?.integration?.agent?.enabled).toBe(true); - }); + const config = await loadConfigFile(filePath); + expect(config?.headscale?.url).toBe("http://localhost:8080"); + expect(config?.oidc?.client_id).toBe("my-client-id"); + expect(config?.server?.port).toBe(8000); + expect(config?.integration?.agent?.enabled).toBe(true); + }); - test('should not throw errors for inaccessible file', async () => { - await expect( - loadConfigFile('/non-existent-path/config.yaml'), - ).resolves.toBeUndefined(); - }); + test("should not throw errors for inaccessible file", async () => { + await expect(loadConfigFile("/non-existent-path/config.yaml")).resolves.toBeUndefined(); + }); - test('should correctly get a finalized config from YAML', async () => { - const filePath = '/config/minimal-config.yaml'; - writeYaml(filePath, { - headscale: { - url: 'http://localhost:8080', - }, - server: { - cookie_secret: 'thirtytwo-character-cookiesecret', - }, - }); + test("should correctly get a finalized config from YAML", async () => { + const filePath = "/config/minimal-config.yaml"; + writeYaml(filePath, { + headscale: { + url: "http://localhost:8080", + }, + server: { + cookie_secret: "thirtytwo-character-cookiesecret", + }, + }); - const config = await loadConfig(filePath); - expect(config.headscale.url).toBe('http://localhost:8080'); - expect(config.server.cookie_secret).toBe( - 'thirtytwo-character-cookiesecret', - ); - }); + const config = await loadConfig(filePath); + expect(config.headscale.url).toBe("http://localhost:8080"); + expect(config.server.cookie_secret).toBe("thirtytwo-character-cookiesecret"); + }); - test('should throw error for missing required fields', async () => { - const filePath = '/config/invalid-config.yaml'; - writeYaml(filePath, { - server: { - port: 8000, - }, - }); + test("should throw error for missing required fields", async () => { + const filePath = "/config/invalid-config.yaml"; + writeYaml(filePath, { + server: { + port: 8000, + }, + }); - await expect(loadConfig(filePath)).rejects.toEqual( - expect.objectContaining( - ConfigError.from('INVALID_REQUIRED_FIELDS', { messages: [] }), - ), - ); - }); + await expect(loadConfig(filePath)).rejects.toEqual( + expect.objectContaining(ConfigError.from("INVALID_REQUIRED_FIELDS", { messages: [] })), + ); + }); + + test("oidc.enabled defaults to true when oidc section is present", async () => { + const filePath = "/config/oidc-default-enabled.yaml"; + writeYaml(filePath, { + headscale: { url: "http://localhost:8080" }, + server: { cookie_secret: "thirtytwo-character-cookiesecret" }, + oidc: { + issuer: "https://accounts.google.com", + client_id: "my-client-id", + client_secret: "my-client-secret", + headscale_api_key: "my-api-key", + }, + }); + + const config = await loadConfig(filePath); + expect(config.oidc).toBeDefined(); + expect(config.oidc?.enabled).toBe(true); + }); + + test("oidc.enabled can be set to false to disable OIDC", async () => { + const filePath = "/config/oidc-disabled.yaml"; + writeYaml(filePath, { + headscale: { url: "http://localhost:8080" }, + server: { cookie_secret: "thirtytwo-character-cookiesecret" }, + oidc: { + enabled: false, + issuer: "https://accounts.google.com", + client_id: "my-client-id", + client_secret: "my-client-secret", + headscale_api_key: "my-api-key", + }, + }); + + const config = await loadConfig(filePath); + expect(config.oidc).toBeDefined(); + expect(config.oidc?.enabled).toBe(false); + }); + + test("partial oidc config with enabled field can be parsed", async () => { + const filePath = "/config/oidc-partial.yaml"; + writeYaml(filePath, { + headscale: { url: "http://localhost:8080" }, + server: { cookie_secret: "thirtytwo-character-cookiesecret" }, + oidc: { enabled: false }, + }); + + const partialConfig = await loadConfigFile(filePath); + expect(partialConfig?.oidc?.enabled).toBe(false); + }); + + test("config without oidc section has undefined oidc", async () => { + const filePath = "/config/no-oidc.yaml"; + writeYaml(filePath, { + headscale: { url: "http://localhost:8080" }, + server: { cookie_secret: "thirtytwo-character-cookiesecret" }, + }); + + const config = await loadConfig(filePath); + expect(config.oidc).toBeUndefined(); + }); }); diff --git a/tests/unit/config/env-vars.test.ts b/tests/unit/config/env-vars.test.ts index 3e56f89..cad1d94 100644 --- a/tests/unit/config/env-vars.test.ts +++ b/tests/unit/config/env-vars.test.ts @@ -1,54 +1,85 @@ -import { beforeEach, describe, expect, test } from 'vitest'; -import { ConfigError } from '~/server/config/error'; -import { loadConfig, loadConfigEnv } from '~/server/config/load'; +import { beforeEach, describe, expect, test } from "vitest"; + +import { ConfigError } from "~/server/config/error"; +import { loadConfig, loadConfigEnv } from "~/server/config/load"; const envVarSnapshot = { ...process.env }; -describe('Configuration environment variable handling', () => { - beforeEach(() => { - process.env = { ...envVarSnapshot }; - }); +describe("Configuration environment variable handling", () => { + beforeEach(() => { + process.env = { ...envVarSnapshot }; + }); - test('should correctly parse different types from env vars', async () => { - process.env.HEADPLANE_HEADSCALE__URL = 'http://localhost:8080'; - process.env.HEADPLANE_OIDC__CLIENT_ID = 'my-client-id'; - process.env.HEADPLANE_SERVER__PORT = '8000'; - process.env.HEADPLANE_INTEGRATION__AGENT__ENABLED = 'true'; + test("should correctly parse different types from env vars", async () => { + process.env.HEADPLANE_HEADSCALE__URL = "http://localhost:8080"; + process.env.HEADPLANE_OIDC__CLIENT_ID = "my-client-id"; + process.env.HEADPLANE_SERVER__PORT = "8000"; + process.env.HEADPLANE_INTEGRATION__AGENT__ENABLED = "true"; - const config = await loadConfigEnv(); - expect(config?.headscale?.url).toBe('http://localhost:8080'); - expect(config?.oidc?.client_id).toBe('my-client-id'); - expect(config?.server?.port).toBe(8000); - expect(config?.integration?.agent?.enabled).toBe(true); - }); + const config = await loadConfigEnv(); + expect(config?.headscale?.url).toBe("http://localhost:8080"); + expect(config?.oidc?.client_id).toBe("my-client-id"); + expect(config?.server?.port).toBe(8000); + expect(config?.integration?.agent?.enabled).toBe(true); + }); - test('should not load env vars without the HEADPLANE_ prefix', async () => { - process.env.HEADPLANE_HEADSCALE__URL = 'http://localhost:8080'; - process.env.OTHER_PREFIX_OIDC__CLIENT_ID = 'should-not-be-loaded'; + test("should not load env vars without the HEADPLANE_ prefix", async () => { + process.env.HEADPLANE_HEADSCALE__URL = "http://localhost:8080"; + process.env.OTHER_PREFIX_OIDC__CLIENT_ID = "should-not-be-loaded"; - const config = await loadConfigEnv(); - expect(config?.headscale?.url).toBe('http://localhost:8080'); - expect(config?.oidc?.client_id).toBeUndefined(); - }); + const config = await loadConfigEnv(); + expect(config?.headscale?.url).toBe("http://localhost:8080"); + expect(config?.oidc?.client_id).toBeUndefined(); + }); - test('should correctly get a finalized config from env vars', async () => { - process.env.HEADPLANE_HEADSCALE__URL = 'http://localhost:8080'; - process.env.HEADPLANE_SERVER__COOKIE_SECRET = - 'thirtytwo-character-cookiesecret'; + test("should correctly get a finalized config from env vars", async () => { + process.env.HEADPLANE_HEADSCALE__URL = "http://localhost:8080"; + process.env.HEADPLANE_SERVER__COOKIE_SECRET = "thirtytwo-character-cookiesecret"; - const config = await loadConfig('./non-existent-path.yaml'); - expect(config.headscale.url).toBe('http://localhost:8080'); - expect(config.server.cookie_secret).toBe( - 'thirtytwo-character-cookiesecret', - ); - }); + const config = await loadConfig("./non-existent-path.yaml"); + expect(config.headscale.url).toBe("http://localhost:8080"); + expect(config.server.cookie_secret).toBe("thirtytwo-character-cookiesecret"); + }); - test('should throw error for missing required fields', async () => { - process.env.HEADPLANE_SERVER__PORT = '8000'; + test("should throw error for missing required fields", async () => { + process.env.HEADPLANE_SERVER__PORT = "8000"; - await expect(loadConfig('./non-existent-path.yaml')).rejects.toEqual( - expect.objectContaining( - ConfigError.from('INVALID_REQUIRED_FIELDS', { messages: [] }), - ), - ); - }); + await expect(loadConfig("./non-existent-path.yaml")).rejects.toEqual( + expect.objectContaining(ConfigError.from("INVALID_REQUIRED_FIELDS", { messages: [] })), + ); + }); + + test("oidc.enabled can be set via HEADPLANE_OIDC__ENABLED env var", async () => { + process.env.HEADPLANE_OIDC__ENABLED = "true"; + process.env.HEADPLANE_OIDC__ISSUER = "https://accounts.google.com"; + process.env.HEADPLANE_OIDC__CLIENT_ID = "my-client-id"; + + const config = await loadConfigEnv(); + expect(config?.oidc?.enabled).toBe(true); + expect(config?.oidc?.issuer).toBe("https://accounts.google.com"); + }); + + test("oidc.enabled=false can be set via env var", async () => { + process.env.HEADPLANE_OIDC__ENABLED = "false"; + process.env.HEADPLANE_OIDC__ISSUER = "https://accounts.google.com"; + process.env.HEADPLANE_OIDC__CLIENT_ID = "my-client-id"; + + const config = await loadConfigEnv(); + expect(config?.oidc?.enabled).toBe(false); + }); + + test("oidc.enabled=false via env var preserves full OIDC config", async () => { + process.env.HEADPLANE_HEADSCALE__URL = "http://localhost:8080"; + process.env.HEADPLANE_SERVER__COOKIE_SECRET = "thirtytwo-character-cookiesecret"; + process.env.HEADPLANE_OIDC__ENABLED = "false"; + process.env.HEADPLANE_OIDC__ISSUER = "https://accounts.google.com"; + process.env.HEADPLANE_OIDC__CLIENT_ID = "my-client-id"; + process.env.HEADPLANE_OIDC__CLIENT_SECRET = "my-client-secret"; + process.env.HEADPLANE_OIDC__HEADSCALE_API_KEY = "my-api-key"; + + const config = await loadConfig("./non-existent-path.yaml"); + expect(config.oidc).toBeDefined(); + expect(config.oidc?.enabled).toBe(false); + expect(config.oidc?.issuer).toBe("https://accounts.google.com"); + expect(config.oidc?.client_id).toBe("my-client-id"); + }); }); diff --git a/tests/unit/config/oidc-enabled.test.ts b/tests/unit/config/oidc-enabled.test.ts deleted file mode 100644 index 70965a7..0000000 --- a/tests/unit/config/oidc-enabled.test.ts +++ /dev/null @@ -1,165 +0,0 @@ -import { dump } from "js-yaml"; -import { beforeAll, beforeEach, describe, expect, test } from "vitest"; - -import { loadConfig, loadConfigEnv, loadConfigFile } from "~/server/config/load"; - -import { clearFakeFiles, createFakeFile } from "../setup/overlay-fs"; - -const writeYaml = (filePath: string, content: unknown) => { - const yamlContent = dump(content); - createFakeFile(filePath, yamlContent); -}; - -const baseConfig = { - headscale: { - url: "http://localhost:8080", - }, - server: { - cookie_secret: "thirtytwo-character-cookiesecret", - }, -}; - -const fullOidcConfig = { - enabled: true, - issuer: "https://accounts.google.com", - client_id: "my-client-id", - client_secret: "my-client-secret", - headscale_api_key: "my-api-key", -}; - -describe("OIDC enabled configuration", () => { - beforeAll(() => { - clearFakeFiles(); - }); - - test("oidc.enabled defaults to true when oidc section is present", async () => { - const filePath = "/config/oidc-default-enabled.yaml"; - writeYaml(filePath, { - ...baseConfig, - oidc: { - issuer: "https://accounts.google.com", - client_id: "my-client-id", - client_secret: "my-client-secret", - headscale_api_key: "my-api-key", - }, - }); - - const config = await loadConfig(filePath); - expect(config.oidc).toBeDefined(); - expect(config.oidc?.enabled).toBe(true); - }); - - test("oidc.enabled can be explicitly set to true", async () => { - const filePath = "/config/oidc-explicit-true.yaml"; - writeYaml(filePath, { - ...baseConfig, - oidc: fullOidcConfig, - }); - - const config = await loadConfig(filePath); - expect(config.oidc).toBeDefined(); - expect(config.oidc?.enabled).toBe(true); - }); - - test("oidc.enabled can be set to false to disable OIDC", async () => { - const filePath = "/config/oidc-disabled.yaml"; - writeYaml(filePath, { - ...baseConfig, - oidc: { - ...fullOidcConfig, - enabled: false, - }, - }); - - const config = await loadConfig(filePath); - expect(config.oidc).toBeDefined(); - expect(config.oidc?.enabled).toBe(false); - }); - - test("oidc section can be defined with enabled: false for templating purposes", async () => { - const filePath = "/config/oidc-templating.yaml"; - writeYaml(filePath, { - ...baseConfig, - oidc: { - enabled: false, - issuer: "https://example.com", - client_id: "placeholder-client-id", - client_secret: "placeholder-client-secret", - headscale_api_key: "placeholder-api-key", - }, - }); - - const config = await loadConfig(filePath); - expect(config.oidc).toBeDefined(); - expect(config.oidc?.enabled).toBe(false); - expect(config.oidc?.issuer).toBe("https://example.com"); - expect(config.oidc?.client_id).toBe("placeholder-client-id"); - }); - - test("partial oidc config with enabled field can be parsed", async () => { - const filePath = "/config/oidc-partial.yaml"; - writeYaml(filePath, { - ...baseConfig, - oidc: { - enabled: false, - }, - }); - - // This should parse without error at the partial config level - const partialConfig = await loadConfigFile(filePath); - expect(partialConfig?.oidc?.enabled).toBe(false); - }); - - test("config without oidc section has undefined oidc", async () => { - const filePath = "/config/no-oidc.yaml"; - writeYaml(filePath, baseConfig); - - const config = await loadConfig(filePath); - expect(config.oidc).toBeUndefined(); - }); -}); - -// Environment variable tests for oidc.enabled -const envVarSnapshot = { ...process.env }; -describe("OIDC enabled via environment variables", () => { - beforeEach(() => { - process.env = { ...envVarSnapshot }; - }); - - test("oidc.enabled can be set via HEADPLANE_OIDC__ENABLED env var", async () => { - process.env.HEADPLANE_OIDC__ENABLED = "true"; - process.env.HEADPLANE_OIDC__ISSUER = "https://accounts.google.com"; - process.env.HEADPLANE_OIDC__CLIENT_ID = "my-client-id"; - - const config = await loadConfigEnv(); - expect(config?.oidc?.enabled).toBe(true); - expect(config?.oidc?.issuer).toBe("https://accounts.google.com"); - }); - - test("oidc.enabled=false can be set via env var", async () => { - process.env.HEADPLANE_OIDC__ENABLED = "false"; - process.env.HEADPLANE_OIDC__ISSUER = "https://accounts.google.com"; - process.env.HEADPLANE_OIDC__CLIENT_ID = "my-client-id"; - - const config = await loadConfigEnv(); - expect(config?.oidc?.enabled).toBe(false); - expect(config?.oidc?.issuer).toBe("https://accounts.google.com"); - }); - - test("oidc.enabled can be set via env var to disable full OIDC config", async () => { - process.env.HEADPLANE_HEADSCALE__URL = "http://localhost:8080"; - process.env.HEADPLANE_SERVER__COOKIE_SECRET = "thirtytwo-character-cookiesecret"; - process.env.HEADPLANE_OIDC__ENABLED = "false"; - process.env.HEADPLANE_OIDC__ISSUER = "https://accounts.google.com"; - process.env.HEADPLANE_OIDC__CLIENT_ID = "my-client-id"; - process.env.HEADPLANE_OIDC__CLIENT_SECRET = "my-client-secret"; - process.env.HEADPLANE_OIDC__HEADSCALE_API_KEY = "my-api-key"; - - const config = await loadConfig("./non-existent-path.yaml"); - expect(config.oidc).toBeDefined(); - expect(config.oidc?.enabled).toBe(false); - // All other OIDC fields should still be present - expect(config.oidc?.issuer).toBe("https://accounts.google.com"); - expect(config.oidc?.client_id).toBe("my-client-id"); - }); -}); diff --git a/tests/unit/live-data/live-data.test.ts b/tests/unit/live-data/live-data.test.ts deleted file mode 100644 index bc2a529..0000000 --- a/tests/unit/live-data/live-data.test.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { describe, expect, test, vi, beforeEach, afterEach } from "vitest"; - -vi.mock("react-router", () => ({ - useRevalidator: vi.fn(), -})); - -vi.mock("usehooks-ts", () => ({ - useInterval: vi.fn(), -})); - -vi.mock("react", async () => { - const actual = await vi.importActual("react"); - return { - ...actual, - createContext: vi.fn(() => ({ Provider: vi.fn() })), - useContext: vi.fn(), - useEffect: vi.fn(), - useState: vi.fn(), - }; -}); - -describe("LiveDataProvider", () => { - beforeEach(() => vi.useFakeTimers()); - afterEach(() => { - vi.useRealTimers(); - vi.clearAllMocks(); - }); - - describe("refresh interval", () => { - test("uses 3 second interval", async () => { - const { useInterval } = await import("usehooks-ts"); - expect(useInterval).toBeDefined(); - expect(3000).toBe(3000); - }); - - test("disables interval when paused", () => { - const visible = true; - const paused = true; - const interval = visible && !paused ? 3000 : null; - expect(interval).toBeNull(); - }); - - test("disables interval when hidden", () => { - const visible = false; - const paused = false; - const interval = visible && !paused ? 3000 : null; - expect(interval).toBeNull(); - }); - - test("only revalidates when idle", () => { - const mockRevalidate = vi.fn(); - const revalidateIfIdle = (state: string) => { - if (state === "idle") mockRevalidate(); - }; - - revalidateIfIdle("idle"); - expect(mockRevalidate).toHaveBeenCalledTimes(1); - - mockRevalidate.mockClear(); - revalidateIfIdle("loading"); - expect(mockRevalidate).not.toHaveBeenCalled(); - }); - }); - - describe("useLiveData hook", () => { - test("returns pause and resume functions", () => { - const mockSetPaused = vi.fn(); - const hook = { - pause: () => mockSetPaused(true), - resume: () => mockSetPaused(false), - }; - - hook.pause(); - expect(mockSetPaused).toHaveBeenCalledWith(true); - - hook.resume(); - expect(mockSetPaused).toHaveBeenCalledWith(false); - }); - }); -}); - -describe("pending approval page", () => { - test("redirects when user has access", () => { - const hasAccess = true; - const redirect = hasAccess ? "/machines" : null; - expect(redirect).toBe("/machines"); - }); - - test("stays on page when user lacks access", () => { - const hasAccess = false; - const redirect = hasAccess ? "/machines" : null; - expect(redirect).toBeNull(); - }); -}); diff --git a/tests/unit/sessions/sessions.test.ts b/tests/unit/sessions/sessions.test.ts deleted file mode 100644 index 90700db..0000000 --- a/tests/unit/sessions/sessions.test.ts +++ /dev/null @@ -1,180 +0,0 @@ -import { createClient } from "@libsql/client"; -import { eq } from "drizzle-orm"; -import { drizzle } from "drizzle-orm/libsql"; -import { ulid } from "ulidx"; -import { beforeEach, describe, expect, test } from "vitest"; - -import { users } from "~/server/db/schema"; -import { Roles } from "~/server/web/roles"; - -// Create in-memory database for testing -function createTestDb() { - const client = createClient({ url: ":memory:" }); - const db = drizzle(client); - return { client, db }; -} - -// Create the users table schema -async function setupSchema(client: ReturnType) { - await client.execute(` - CREATE TABLE IF NOT EXISTS users ( - id TEXT PRIMARY KEY, - sub TEXT NOT NULL UNIQUE, - caps INTEGER NOT NULL DEFAULT 0, - onboarded INTEGER NOT NULL DEFAULT 0 - ) - `); -} - -describe("Session role assignment", () => { - let db: ReturnType; - let client: ReturnType; - - beforeEach(async () => { - const testDb = createTestDb(); - db = testDb.db; - client = testDb.client; - await setupSchema(client); - }); - - describe("reassignSubject upsert behavior", () => { - test("creates user record when subject does not exist", async () => { - const subject = "new-user-subject"; - const role = "admin"; - - // Verify user doesn't exist - const beforeInsert = await db.select().from(users).where(eq(users.sub, subject)); - expect(beforeInsert.length).toBe(0); - - // Perform upsert (simulating reassignSubject) - await db - .insert(users) - .values({ - id: ulid(), - sub: subject, - caps: Roles[role], - onboarded: false, - }) - .onConflictDoUpdate({ - target: users.sub, - set: { caps: Roles[role] }, - }); - - // Verify user was created with correct role - const afterInsert = await db.select().from(users).where(eq(users.sub, subject)); - expect(afterInsert.length).toBe(1); - expect(afterInsert[0].caps).toBe(Roles.admin); - }); - - test("updates existing user role without creating duplicate", async () => { - const subject = "existing-user"; - const initialRole = "member"; - const newRole = "admin"; - - // Create initial user - await db.insert(users).values({ - id: ulid(), - sub: subject, - caps: Roles[initialRole], - onboarded: true, - }); - - // Verify initial state - const beforeUpdate = await db.select().from(users).where(eq(users.sub, subject)); - expect(beforeUpdate.length).toBe(1); - expect(beforeUpdate[0].caps).toBe(Roles.member); - expect(beforeUpdate[0].onboarded).toBe(true); - - // Perform upsert (simulating reassignSubject) - await db - .insert(users) - .values({ - id: ulid(), - sub: subject, - caps: Roles[newRole], - onboarded: false, - }) - .onConflictDoUpdate({ - target: users.sub, - set: { caps: Roles[newRole] }, - }); - - // Verify role was updated, no duplicate created - const afterUpdate = await db.select().from(users).where(eq(users.sub, subject)); - expect(afterUpdate.length).toBe(1); - expect(afterUpdate[0].caps).toBe(Roles.admin); - // onboarded should remain true (not overwritten) - expect(afterUpdate[0].onboarded).toBe(true); - }); - - test("can assign all role types", async () => { - const roles = ["admin", "network_admin", "it_admin", "auditor", "member"] as const; - - for (const role of roles) { - const subject = `user-${role}`; - - await db - .insert(users) - .values({ - id: ulid(), - sub: subject, - caps: Roles[role], - onboarded: false, - }) - .onConflictDoUpdate({ - target: users.sub, - set: { caps: Roles[role] }, - }); - - const [user] = await db.select().from(users).where(eq(users.sub, subject)); - expect(user.caps).toBe(Roles[role]); - } - }); - }); - - describe("member role handling", () => { - test("member role has zero capabilities", async () => { - const subject = "member-user"; - - await db.insert(users).values({ - id: ulid(), - sub: subject, - caps: Roles.member, - onboarded: false, - }); - - const [user] = await db.select().from(users).where(eq(users.sub, subject)); - expect(user.caps).toBe(0); - }); - - test("upgrading from member to admin grants ui_access", async () => { - const subject = "upgrading-user"; - - // Start as member - await db.insert(users).values({ - id: ulid(), - sub: subject, - caps: Roles.member, - onboarded: false, - }); - - // Upgrade to admin - await db - .insert(users) - .values({ - id: ulid(), - sub: subject, - caps: Roles.admin, - onboarded: false, - }) - .onConflictDoUpdate({ - target: users.sub, - set: { caps: Roles.admin }, - }); - - const [user] = await db.select().from(users).where(eq(users.sub, subject)); - expect(user.caps).toBe(Roles.admin); - expect(user.caps).not.toBe(0); - }); - }); -}); diff --git a/tests/unit/utils/user.test.ts b/tests/unit/utils/user.test.ts index 69a7494..a11dbd0 100644 --- a/tests/unit/utils/user.test.ts +++ b/tests/unit/utils/user.test.ts @@ -1,8 +1,7 @@ import { describe, expect, test } from "vitest"; import type { User } from "~/types/User"; - -import { filterUsersWithValidIds, getUserDisplayName } from "~/utils/user"; +import { getUserDisplayName } from "~/utils/user"; const makeUser = (overrides: Partial): User => ({ id: "default-id", @@ -11,48 +10,6 @@ const makeUser = (overrides: Partial): User => ({ ...overrides, }); -describe("filterUsersWithValidIds", () => { - test("keeps users with valid ID and name", () => { - const users = [makeUser({ id: "123", name: "John" })]; - const result = filterUsersWithValidIds(users); - expect(result).toHaveLength(1); - expect(result[0].id).toBe("123"); - }); - - test("keeps users with valid ID but no name", () => { - const users = [makeUser({ id: "123", name: "" })]; - const result = filterUsersWithValidIds(users); - expect(result).toHaveLength(1); - }); - - test("keeps users with valid ID and no optional fields", () => { - const users = [makeUser({ id: "123", name: "", displayName: undefined, email: undefined })]; - const result = filterUsersWithValidIds(users); - expect(result).toHaveLength(1); - }); - - test("removes users with empty ID", () => { - const users = [makeUser({ id: "", name: "John" })]; - const result = filterUsersWithValidIds(users); - expect(result).toHaveLength(0); - }); - - test("handles mix of valid and invalid", () => { - const users = [ - makeUser({ id: "123", name: "John" }), - makeUser({ id: "", name: "Jane" }), - makeUser({ id: "456", name: "" }), - ]; - const result = filterUsersWithValidIds(users); - expect(result).toHaveLength(2); - expect(result.map((u) => u.id)).toEqual(["123", "456"]); - }); - - test("returns empty for empty input", () => { - expect(filterUsersWithValidIds([])).toHaveLength(0); - }); -}); - describe("getUserDisplayName", () => { test("uses name when set", () => { const user = makeUser({ id: "123", name: "John" });