359 lines
11 KiB
TypeScript
359 lines
11 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import { describe, expect, test } from "bun:test";
|
|
import type {
|
|
KubernetesObject,
|
|
KubernetesObjectApi,
|
|
} from "@kubernetes/client-node";
|
|
import { hashToken } from "../../server/auth";
|
|
import {
|
|
KubernetesAuthStore,
|
|
KUBER_SYSTEM_NAMESPACE,
|
|
} from "../../server/kubernetes-store";
|
|
|
|
type StoredSecret = KubernetesObject & {
|
|
data?: Record<string, string>;
|
|
stringData?: Record<string, string>;
|
|
type?: string;
|
|
};
|
|
|
|
function objectName(prefix: string, value: string): string {
|
|
const digest = createHash("sha256").update(value).digest("hex").slice(0, 48);
|
|
return `${prefix}-${digest}`;
|
|
}
|
|
|
|
function encode(value: string): string {
|
|
return Buffer.from(value).toString("base64");
|
|
}
|
|
|
|
function secret(
|
|
recordType: "user" | "session" | "api-key",
|
|
name: string,
|
|
values: Record<string, string>,
|
|
): StoredSecret {
|
|
return {
|
|
apiVersion: "v1",
|
|
kind: "Secret",
|
|
metadata: {
|
|
name,
|
|
namespace: KUBER_SYSTEM_NAMESPACE,
|
|
labels: { "kuber.astrxl.dev/type": recordType },
|
|
},
|
|
type: "Opaque",
|
|
data: Object.fromEntries(
|
|
Object.entries(values).map(([key, value]) => [key, encode(value)]),
|
|
),
|
|
};
|
|
}
|
|
|
|
class FakeObjects {
|
|
readonly secrets = new Map<string, StoredSecret>();
|
|
readonly patches: StoredSecret[] = [];
|
|
readonly deleted: string[] = [];
|
|
reads = 0;
|
|
|
|
async read(value: KubernetesObject): Promise<StoredSecret> {
|
|
this.reads += 1;
|
|
const found = this.secrets.get(value.metadata?.name ?? "");
|
|
if (!found) throw { code: 404 };
|
|
return found;
|
|
}
|
|
|
|
async patch(value: StoredSecret): Promise<StoredSecret> {
|
|
const stored: StoredSecret = {
|
|
...value,
|
|
data: Object.fromEntries(
|
|
Object.entries(value.stringData ?? {}).map(([key, item]) => [
|
|
key,
|
|
encode(item),
|
|
]),
|
|
),
|
|
};
|
|
delete stored.stringData;
|
|
this.patches.push(value);
|
|
this.secrets.set(value.metadata?.name ?? "", stored);
|
|
return stored;
|
|
}
|
|
|
|
async list(
|
|
_apiVersion: string,
|
|
_kind: string,
|
|
_namespace: string,
|
|
_pretty?: string,
|
|
_exact?: boolean,
|
|
_export?: boolean,
|
|
_fieldSelector?: string,
|
|
labelSelector?: string,
|
|
): Promise<{ items: StoredSecret[] }> {
|
|
const type = labelSelector?.split("=")[1];
|
|
return {
|
|
items: [...this.secrets.values()].filter(
|
|
(item) => item.metadata?.labels?.["kuber.astrxl.dev/type"] === type,
|
|
),
|
|
};
|
|
}
|
|
|
|
async delete(value: KubernetesObject): Promise<void> {
|
|
const name = value.metadata?.name ?? "";
|
|
if (!this.secrets.delete(name)) throw { code: 404 };
|
|
this.deleted.push(name);
|
|
}
|
|
}
|
|
|
|
function setup(): {
|
|
fake: FakeObjects;
|
|
store: KubernetesAuthStore;
|
|
} {
|
|
const fake = new FakeObjects();
|
|
return {
|
|
fake,
|
|
store: new KubernetesAuthStore(fake as unknown as KubernetesObjectApi),
|
|
};
|
|
}
|
|
|
|
describe("KubernetesAuthStore", () => {
|
|
test("persists absent expiry, reads finite legacy keys, and rejects malformed expiries", async () => {
|
|
const { fake, store } = setup();
|
|
await store.putUser({
|
|
username: "alice",
|
|
passwordHash: "hash",
|
|
roles: ["viewer"],
|
|
});
|
|
const never = {
|
|
id: "never-expiring-key-123",
|
|
tokenHash: hashToken("never"),
|
|
username: "alice",
|
|
capabilities: ["kubernetes:read" as const],
|
|
};
|
|
await store.createApiKey(never);
|
|
expect(fake.patches.at(-1)?.stringData).not.toHaveProperty("expiresAt");
|
|
expect(await store.getApiKey(never.tokenHash)).toMatchObject(never);
|
|
const finite = {
|
|
...never,
|
|
id: "finite-expiry-key-123",
|
|
tokenHash: hashToken("finite"),
|
|
expiresAt: "2020-01-01T00:00:00.000Z",
|
|
};
|
|
await store.createApiKey(finite);
|
|
expect(
|
|
(await store.listApiKeys("alice")).map((key) => key.expiresAt),
|
|
).toEqual([finite.expiresAt, undefined]);
|
|
expect(await store.deleteExpiredApiKeys()).toBe(1);
|
|
expect(await store.getApiKey(never.tokenHash)).toMatchObject(never);
|
|
const active = {
|
|
...finite,
|
|
id: "active-finite-key-123",
|
|
tokenHash: hashToken("active-finite"),
|
|
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
|
};
|
|
await store.createApiKey(active);
|
|
expect(await store.getApiKey(active.tokenHash)).toMatchObject(active);
|
|
const name = objectName("api-key", never.tokenHash);
|
|
for (const expiry of ["none", "", "2026-09-03"]) {
|
|
fake.secrets.set(
|
|
name,
|
|
secret("api-key", name, {
|
|
id: never.id,
|
|
tokenHash: never.tokenHash,
|
|
username: never.username,
|
|
capabilities: JSON.stringify(never.capabilities),
|
|
workspace: "",
|
|
disabled: "false",
|
|
expiresAt: expiry,
|
|
}),
|
|
);
|
|
expect(await store.getApiKey(never.tokenHash)).toBeUndefined();
|
|
}
|
|
expect(await store.revokeApiKey("bob", finite.id)).toBe(false);
|
|
await expect(
|
|
store.createApiKey({ ...never, username: "missing" }),
|
|
).rejects.toThrow("not active");
|
|
await store.updateUser("alice", { disabled: true });
|
|
await expect(store.createApiKey(never)).rejects.toThrow("not active");
|
|
});
|
|
test("validates the session and user from the store on every request", async () => {
|
|
const { fake, store } = setup();
|
|
const tokenHash = hashToken("fresh");
|
|
await store.putUser({
|
|
username: "alice",
|
|
passwordHash: "hash",
|
|
roles: ["viewer"],
|
|
});
|
|
await store.putSession({
|
|
tokenHash,
|
|
username: "alice",
|
|
roles: ["viewer"],
|
|
expiresAt: "2026-09-03T00:00:00.000Z",
|
|
});
|
|
fake.reads = 0;
|
|
|
|
await expect(store.getSession(tokenHash)).resolves.toMatchObject({
|
|
tokenHash,
|
|
});
|
|
fake.secrets.set(
|
|
objectName("user", "alice"),
|
|
secret("user", objectName("user", "alice"), {
|
|
username: "alice",
|
|
passwordHash: "hash",
|
|
roles: JSON.stringify(["viewer"]),
|
|
authVersion: "1",
|
|
disabled: "true",
|
|
}),
|
|
);
|
|
await expect(store.getSession(tokenHash)).resolves.toBeUndefined();
|
|
expect(fake.reads).toBe(4);
|
|
});
|
|
|
|
test("persists authVersion and not copied roles in new sessions", async () => {
|
|
const { fake, store } = setup();
|
|
await store.putUser({
|
|
username: "alice",
|
|
passwordHash: "hash",
|
|
roles: ["viewer"],
|
|
});
|
|
const tokenHash = hashToken("token");
|
|
await store.putSession({
|
|
tokenHash,
|
|
username: "alice",
|
|
roles: ["admin"],
|
|
expiresAt: "2026-09-03T00:00:00.000Z",
|
|
});
|
|
|
|
expect(fake.patches[0]?.stringData).toMatchObject({ authVersion: "1" });
|
|
expect(fake.patches[1]?.stringData).toEqual({
|
|
tokenHash,
|
|
username: "alice",
|
|
authVersion: "1",
|
|
expiresAt: "2026-09-03T00:00:00.000Z",
|
|
});
|
|
expect(await store.getSession(tokenHash)).toMatchObject({ authVersion: 1 });
|
|
});
|
|
|
|
test("reads legacy records at version one and invalidates them on update", async () => {
|
|
const { fake, store } = setup();
|
|
const tokenHash = hashToken("legacy");
|
|
fake.secrets.set(
|
|
objectName("user", "alice"),
|
|
secret("user", objectName("user", "alice"), {
|
|
username: "alice",
|
|
passwordHash: "hash",
|
|
roles: JSON.stringify(["viewer"]),
|
|
disabled: "false",
|
|
}),
|
|
);
|
|
fake.secrets.set(
|
|
objectName("session", tokenHash),
|
|
secret("session", objectName("session", tokenHash), {
|
|
tokenHash,
|
|
username: "alice",
|
|
roles: JSON.stringify(["admin"]),
|
|
expiresAt: "2026-09-03T00:00:00.000Z",
|
|
}),
|
|
);
|
|
|
|
expect((await store.getUser("alice"))?.authVersion).toBe(1);
|
|
expect((await store.getSession(tokenHash))?.authVersion).toBe(1);
|
|
expect(
|
|
(await store.updateUser("alice", { roles: ["operator"] }))?.authVersion,
|
|
).toBe(2);
|
|
expect(await store.getSession(tokenHash)).toBeUndefined();
|
|
});
|
|
|
|
test("fails closed for malformed, mislabeled, and swapped Secret data", async () => {
|
|
const { fake, store } = setup();
|
|
const name = objectName("user", "alice");
|
|
const valid = secret("user", name, {
|
|
username: "alice",
|
|
passwordHash: "hash",
|
|
roles: JSON.stringify(["viewer"]),
|
|
authVersion: "1",
|
|
disabled: "false",
|
|
});
|
|
|
|
fake.secrets.set(name, { ...valid, data: { ...valid.data, roles: "%%%" } });
|
|
expect(await store.getUser("alice")).toBeUndefined();
|
|
fake.secrets.set(name, { ...valid, type: "kubernetes.io/tls" });
|
|
expect(await store.getUser("alice")).toBeUndefined();
|
|
fake.secrets.set(name, {
|
|
...valid,
|
|
data: { ...valid.data, unexpected: encode("value") },
|
|
});
|
|
expect(await store.getUser("alice")).toBeUndefined();
|
|
fake.secrets.set(
|
|
name,
|
|
secret("user", name, {
|
|
username: "bob",
|
|
passwordHash: "hash",
|
|
roles: JSON.stringify(["viewer"]),
|
|
authVersion: "1",
|
|
disabled: "false",
|
|
}),
|
|
);
|
|
expect(await store.getUser("alice")).toBeUndefined();
|
|
});
|
|
|
|
test("lists, creates, updates, revokes, and deletes users and sessions", async () => {
|
|
const { fake, store } = setup();
|
|
await store.createUser({
|
|
username: "bob",
|
|
passwordHash: "b",
|
|
roles: ["viewer"],
|
|
});
|
|
await store.createUser({
|
|
username: "alice",
|
|
passwordHash: "a",
|
|
roles: ["operator"],
|
|
});
|
|
await expect(
|
|
store.createUser({
|
|
username: "alice",
|
|
passwordHash: "a",
|
|
roles: ["viewer"],
|
|
}),
|
|
).rejects.toThrow("already exists");
|
|
expect((await store.listUsers()).map((user) => user.username)).toEqual([
|
|
"alice",
|
|
"bob",
|
|
]);
|
|
expect(
|
|
(await store.updateUser("alice", { disabled: true }))?.authVersion,
|
|
).toBe(2);
|
|
|
|
const bobToken = hashToken("bob");
|
|
await store.putSession({
|
|
tokenHash: bobToken,
|
|
username: "bob",
|
|
authVersion: 1,
|
|
expiresAt: "2026-09-03T00:00:00.000Z",
|
|
});
|
|
expect(await store.deleteUser("bob")).toBe(true);
|
|
expect(fake.secrets.has(objectName("session", bobToken))).toBe(false);
|
|
expect(await store.deleteUser("missing")).toBe(false);
|
|
});
|
|
|
|
test("lists and deletes expired sessions without a cluster", async () => {
|
|
const { fake, store } = setup();
|
|
await store.putUser({
|
|
username: "alice",
|
|
passwordHash: "hash",
|
|
roles: ["viewer"],
|
|
});
|
|
for (const [token, expiration] of [
|
|
["expired", "2026-09-01T00:00:00.000Z"],
|
|
["active", "2026-09-03T00:00:00.000Z"],
|
|
] as const) {
|
|
await store.putSession({
|
|
tokenHash: hashToken(token),
|
|
username: "alice",
|
|
authVersion: 1,
|
|
expiresAt: expiration,
|
|
});
|
|
}
|
|
const now = Date.parse("2026-09-02T00:00:00.000Z");
|
|
expect(await store.listExpiredSessions(now)).toHaveLength(1);
|
|
expect(await store.deleteExpiredSessions(now)).toBe(1);
|
|
expect(fake.secrets.has(objectName("session", hashToken("active")))).toBe(
|
|
true,
|
|
);
|
|
});
|
|
});
|