199 lines
5.9 KiB
TypeScript
199 lines
5.9 KiB
TypeScript
import { describe, expect, test } from "bun:test";
|
|
import {
|
|
MemoryAuthStore,
|
|
hashToken,
|
|
tokenHashesEqual,
|
|
} from "../../server/auth";
|
|
import {
|
|
CAPABILITIES,
|
|
capabilitiesForRoles,
|
|
hasCapability,
|
|
} from "../../server/authorization";
|
|
|
|
const expiresAt = "2026-09-03T00:00:00.000Z";
|
|
|
|
describe("authorization", () => {
|
|
test("grants named capabilities through deny-by-default roles", () => {
|
|
expect([...capabilitiesForRoles(["viewer"])]).toEqual(["kubernetes:read"]);
|
|
expect(hasCapability(["operator"], "kubernetes:write")).toBe(true);
|
|
expect(hasCapability(["operator"], "users:read")).toBe(false);
|
|
expect([...capabilitiesForRoles(["unknown"])]).toEqual([]);
|
|
expect([...capabilitiesForRoles(["admin"])]).toEqual([...CAPABILITIES]);
|
|
});
|
|
});
|
|
|
|
describe("memory auth store", () => {
|
|
test("maintains the API-key hash index across key mutations", async () => {
|
|
const store = new MemoryAuthStore();
|
|
await store.putUser({
|
|
username: "alice",
|
|
passwordHash: "hash",
|
|
roles: ["viewer"],
|
|
});
|
|
const activeHash = hashToken("active-api-key");
|
|
const expiredHash = hashToken("expired-api-key");
|
|
const activeKey = {
|
|
id: "active-key-id-1234",
|
|
tokenHash: activeHash,
|
|
username: "alice",
|
|
capabilities: ["kubernetes:read" as (typeof CAPABILITIES)[number]],
|
|
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
|
};
|
|
await store.createApiKey(activeKey);
|
|
expect(store.apiKeysByTokenHash.get(activeHash)).toBe(
|
|
store.apiKeys.get(activeKey.id),
|
|
);
|
|
expect(await store.getApiKey(activeHash)).toEqual(
|
|
store.apiKeys.get(activeKey.id),
|
|
);
|
|
|
|
expect(await store.revokeApiKey("alice", activeKey.id)).toBe(true);
|
|
expect(store.apiKeysByTokenHash.has(activeHash)).toBe(false);
|
|
expect(await store.getApiKey(activeHash)).toBeUndefined();
|
|
|
|
const expiredKey = {
|
|
...activeKey,
|
|
id: "expired-key-id-1234",
|
|
tokenHash: expiredHash,
|
|
expiresAt: "2020-01-01T00:00:00.000Z",
|
|
};
|
|
await store.createApiKey(expiredKey);
|
|
expect(store.apiKeysByTokenHash.get(expiredHash)).toBe(
|
|
store.apiKeys.get(expiredKey.id),
|
|
);
|
|
expect(await store.deleteExpiredApiKeys()).toBe(1);
|
|
expect(store.apiKeysByTokenHash.has(expiredHash)).toBe(false);
|
|
|
|
await store.createApiKey(activeKey);
|
|
expect(await store.deleteUser("alice")).toBe(true);
|
|
expect(store.apiKeysByTokenHash.has(activeHash)).toBe(false);
|
|
expect(await store.getApiKey(activeHash)).toBeUndefined();
|
|
});
|
|
|
|
test("creates, lists, updates, and deletes users", async () => {
|
|
const store = new MemoryAuthStore();
|
|
const bob = await store.createUser({
|
|
username: "bob",
|
|
passwordHash: "hash-b",
|
|
roles: ["viewer"],
|
|
});
|
|
await store.createUser({
|
|
username: "alice",
|
|
passwordHash: "hash-a",
|
|
roles: ["operator"],
|
|
});
|
|
|
|
expect(bob.authVersion).toBe(1);
|
|
expect((await store.listUsers()).map((user) => user.username)).toEqual([
|
|
"alice",
|
|
"bob",
|
|
]);
|
|
await expect(store.createUser({ ...bob })).rejects.toThrow(
|
|
"User already exists",
|
|
);
|
|
|
|
const updated = await store.updateUser("bob", {
|
|
roles: ["admin"],
|
|
disabled: true,
|
|
});
|
|
expect(updated).toMatchObject({
|
|
roles: ["admin"],
|
|
disabled: true,
|
|
authVersion: 2,
|
|
});
|
|
expect(await store.updateUser("missing", {})).toBeUndefined();
|
|
expect(await store.deleteUser("bob")).toBe(true);
|
|
expect(await store.deleteUser("bob")).toBe(false);
|
|
});
|
|
|
|
test("references user authVersion and ignores legacy role snapshots", async () => {
|
|
const store = new MemoryAuthStore();
|
|
await store.putUser({
|
|
username: "alice",
|
|
passwordHash: "hash",
|
|
roles: ["viewer"],
|
|
});
|
|
const tokenHash = hashToken("token");
|
|
await store.putSession({
|
|
tokenHash,
|
|
username: "alice",
|
|
roles: ["admin"],
|
|
expiresAt,
|
|
});
|
|
|
|
expect(store.sessions.get(tokenHash)).toEqual({
|
|
tokenHash,
|
|
username: "alice",
|
|
authVersion: 1,
|
|
expiresAt,
|
|
});
|
|
await store.updateUser("alice", { roles: ["operator"] });
|
|
expect(await store.getSession(tokenHash)).toBeUndefined();
|
|
await expect(
|
|
store.putSession({
|
|
tokenHash: hashToken("stale"),
|
|
username: "alice",
|
|
authVersion: 1,
|
|
expiresAt,
|
|
}),
|
|
).rejects.toThrow("stale");
|
|
});
|
|
|
|
test("revokes user sessions and cleans up expired sessions", async () => {
|
|
const store = new MemoryAuthStore();
|
|
for (const username of ["alice", "bob"]) {
|
|
await store.putUser({
|
|
username,
|
|
passwordHash: "hash",
|
|
roles: ["viewer"],
|
|
});
|
|
}
|
|
await store.putSession({
|
|
tokenHash: hashToken("alice-expired"),
|
|
username: "alice",
|
|
authVersion: 1,
|
|
expiresAt: "2026-09-01T00:00:00.000Z",
|
|
});
|
|
await store.putSession({
|
|
tokenHash: hashToken("alice-active"),
|
|
username: "alice",
|
|
authVersion: 1,
|
|
expiresAt,
|
|
});
|
|
await store.putSession({
|
|
tokenHash: hashToken("bob-expired"),
|
|
username: "bob",
|
|
authVersion: 1,
|
|
expiresAt: "2026-09-01T00:00:00.000Z",
|
|
});
|
|
|
|
expect(
|
|
await store.listExpiredSessions(Date.parse("2026-09-02T00:00:00.000Z")),
|
|
).toHaveLength(2);
|
|
expect(
|
|
await store.deleteExpiredSessions(Date.parse("2026-09-02T00:00:00.000Z")),
|
|
).toBe(2);
|
|
expect(await store.revokeUserSessions("alice")).toBe(1);
|
|
expect(store.sessions.size).toBe(0);
|
|
});
|
|
|
|
test("rejects invalid domain records and malformed token hashes", async () => {
|
|
const store = new MemoryAuthStore();
|
|
await expect(
|
|
store.putUser({
|
|
username: " alice",
|
|
passwordHash: "hash",
|
|
roles: ["viewer"],
|
|
}),
|
|
).rejects.toThrow("Username");
|
|
await expect(
|
|
store.putUser({
|
|
username: "alice",
|
|
passwordHash: "hash",
|
|
roles: ["root"] as never,
|
|
}),
|
|
).rejects.toThrow("valid role");
|
|
expect(tokenHashesEqual("zz", "zz")).toBe(false);
|
|
});
|
|
});
|