mirror of
https://github.com/tale/headplane.git
synced 2026-07-26 15:58:14 +00:00
chore: condense/cleanup bad tests
This commit is contained in:
@@ -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
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<typeof createClient>) {
|
||||
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<typeof drizzle>) {
|
||||
const [result] = await db
|
||||
.select({ count: count() })
|
||||
.from(users)
|
||||
.where(eq(users.caps, Roles.owner));
|
||||
return result?.count ?? 0;
|
||||
}
|
||||
|
||||
async function simulateOidcLogin(db: ReturnType<typeof drizzle>, 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<typeof drizzle>;
|
||||
let client: ReturnType<typeof createClient>;
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<typeof createClient>) {
|
||||
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<typeof drizzle>;
|
||||
let client: ReturnType<typeof createClient>;
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>): User => ({
|
||||
id: "default-id",
|
||||
@@ -11,48 +10,6 @@ const makeUser = (overrides: Partial<User>): 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" });
|
||||
|
||||
Reference in New Issue
Block a user