516 lines
15 KiB
TypeScript
516 lines
15 KiB
TypeScript
import { describe, expect, spyOn, test } from "bun:test";
|
|
import { runCommand } from "citty";
|
|
import { listAuditEvents } from "../../command/audit";
|
|
import { main } from "../../command/main";
|
|
import { getOperation, listOperations } from "../../command/operations";
|
|
import {
|
|
addUser,
|
|
createApiKey,
|
|
deleteUser,
|
|
listApiKeys,
|
|
listUsers,
|
|
revokeApiKey,
|
|
revokeUserSessions,
|
|
setUserDisabled,
|
|
updateUser,
|
|
users,
|
|
} from "../../command/users";
|
|
import type { ApiRequestInit } from "../../lib/api";
|
|
import * as api from "../../lib/api";
|
|
|
|
type Call = { path: string; init?: ApiRequestInit };
|
|
|
|
function requestReturning<T>(result: T, calls: Call[]) {
|
|
return async <R>(path: string, init?: ApiRequestInit): Promise<R> => {
|
|
calls.push({ path, init });
|
|
return result as unknown as R;
|
|
};
|
|
}
|
|
|
|
const user = {
|
|
username: "alice",
|
|
roles: ["admin"],
|
|
disabled: false,
|
|
updatedAt: "2026-09-02T10:00:00.000Z",
|
|
};
|
|
|
|
describe("user administration commands", () => {
|
|
test("lists and creates users through authenticated API routes", async () => {
|
|
const calls: Call[] = [];
|
|
expect(
|
|
await listUsers(requestReturning({ items: [user] }, calls)),
|
|
).toContain("alice");
|
|
expect(
|
|
await addUser(
|
|
"alice",
|
|
"secret",
|
|
["admin"],
|
|
requestReturning(user, calls),
|
|
),
|
|
).toContain("admin");
|
|
|
|
expect(calls).toEqual([
|
|
{ path: "/users", init: undefined },
|
|
{
|
|
path: "/users",
|
|
init: {
|
|
method: "POST",
|
|
json: { username: "alice", password: "secret", roles: ["admin"] },
|
|
},
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("updates roles, passwords, and enabled state with PATCH", async () => {
|
|
const calls: Call[] = [];
|
|
const request = requestReturning(user, calls);
|
|
await updateUser(
|
|
"alice/example",
|
|
{ roles: ["operator"], password: "new" },
|
|
request,
|
|
);
|
|
await setUserDisabled("alice", true, request);
|
|
await setUserDisabled("alice", false, request);
|
|
|
|
expect(calls).toEqual([
|
|
{
|
|
path: "/users/alice%2Fexample",
|
|
init: {
|
|
method: "PATCH",
|
|
json: { roles: ["operator"], password: "new" },
|
|
},
|
|
},
|
|
{
|
|
path: "/users/alice",
|
|
init: { method: "PATCH", json: { disabled: true } },
|
|
},
|
|
{
|
|
path: "/users/alice",
|
|
init: { method: "PATCH", json: { disabled: false } },
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("requires confirmation for deletion and supports session revocation", async () => {
|
|
const calls: Call[] = [];
|
|
const request = requestReturning(undefined, calls);
|
|
expect(await deleteUser("alice", false, request)).toBe(
|
|
"Deletion cancelled",
|
|
);
|
|
expect(await deleteUser("alice", true, request)).toBe("Deleted user alice");
|
|
expect(
|
|
await revokeUserSessions(
|
|
"alice",
|
|
requestReturning({ username: "alice", revoked: 2 }, calls),
|
|
),
|
|
).toBe("Revoked 2 sessions for alice");
|
|
|
|
expect(calls).toEqual([
|
|
{ path: "/users/alice", init: { method: "DELETE" } },
|
|
{
|
|
path: "/users/alice/sessions/revoke",
|
|
init: { method: "POST" },
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("manages API keys below the user route without leaking token in lists", async () => {
|
|
const calls: Call[] = [];
|
|
const key = {
|
|
id: "key-identifier-123",
|
|
username: "alice",
|
|
capabilities: ["kubernetes:write"] as const,
|
|
expiresAt: "2026-12-01T00:00:00.000Z",
|
|
disabled: false,
|
|
token: "shown-once-token",
|
|
};
|
|
expect(
|
|
await listApiKeys(
|
|
"alice/example",
|
|
requestReturning({ items: [{ ...key, token: undefined }] }, calls),
|
|
),
|
|
).not.toContain(key.token);
|
|
await createApiKey(
|
|
"alice",
|
|
{ capabilities: ["kubernetes:write"] },
|
|
async <T>(path: string, init?: ApiRequestInit): Promise<T> => {
|
|
calls.push({ path, init });
|
|
return (init ? key : user) as T;
|
|
},
|
|
);
|
|
expect(
|
|
await revokeApiKey(
|
|
"alice",
|
|
key.id,
|
|
false,
|
|
requestReturning(undefined, calls),
|
|
),
|
|
).toContain("cancelled");
|
|
await revokeApiKey(
|
|
"alice",
|
|
key.id,
|
|
true,
|
|
requestReturning(undefined, calls),
|
|
);
|
|
expect(calls).toEqual([
|
|
{ path: "/users/alice%2Fexample/keys", init: undefined },
|
|
{ path: "/users/alice", init: undefined },
|
|
{
|
|
path: "/users/alice/keys",
|
|
init: { method: "POST", json: { capabilities: ["kubernetes:write"] } },
|
|
},
|
|
{
|
|
path: "/users/alice/keys/key-identifier-123",
|
|
init: { method: "DELETE" },
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("lists all users' keys without a username and filters with one", async () => {
|
|
const calls: Call[] = [];
|
|
const request = async <T>(
|
|
path: string,
|
|
init?: ApiRequestInit,
|
|
): Promise<T> => {
|
|
calls.push({ path, init });
|
|
if (path === "/users")
|
|
return { items: [user, { ...user, username: "bob" }] } as T;
|
|
return {
|
|
items: [
|
|
{
|
|
id: `${path.includes("bob") ? "bob" : "alice"}-key`,
|
|
username: path.includes("bob") ? "bob" : "alice",
|
|
capabilities: ["kubernetes:read"],
|
|
disabled: false,
|
|
},
|
|
],
|
|
} as T;
|
|
};
|
|
const all = await listApiKeys(undefined, request);
|
|
expect(all).toContain("alice-key");
|
|
expect(all).toContain("bob-key");
|
|
expect(all).toContain("never");
|
|
expect(calls.map((call) => call.path)).toEqual([
|
|
"/users",
|
|
"/users/alice/keys",
|
|
"/users/bob/keys",
|
|
]);
|
|
calls.length = 0;
|
|
expect(await listApiKeys("alice", request)).not.toContain("bob-key");
|
|
expect(calls.map((call) => call.path)).toEqual(["/users/alice/keys"]);
|
|
});
|
|
|
|
test("key creation requires an existing active user", async () => {
|
|
const calls: Call[] = [];
|
|
const body = { capabilities: ["kubernetes:read" as const] };
|
|
await expect(
|
|
createApiKey("missing", body, async (path) => {
|
|
calls.push({ path });
|
|
throw new Error("User 'missing' not found");
|
|
}),
|
|
).rejects.toThrow("User 'missing' not found");
|
|
expect(calls).toEqual([{ path: "/users/missing" }]);
|
|
await expect(
|
|
createApiKey(
|
|
"alice",
|
|
body,
|
|
requestReturning({ ...user, disabled: true }, calls),
|
|
),
|
|
).rejects.toThrow("user 'alice' is inactive");
|
|
expect(calls.at(-1)?.path).toBe("/users/alice");
|
|
});
|
|
|
|
test("creates non-expiring and finite API keys with the requested POST bodies", async () => {
|
|
const calls: Call[] = [];
|
|
const key = {
|
|
id: "key-identifier-123",
|
|
username: "alice",
|
|
capabilities: ["kubernetes:read"] as const,
|
|
disabled: false,
|
|
token: "shown-once-token",
|
|
};
|
|
const request = async <T>(
|
|
path: string,
|
|
init?: ApiRequestInit,
|
|
): Promise<T> => {
|
|
calls.push({ path, init });
|
|
return (init ? key : user) as T;
|
|
};
|
|
|
|
await createApiKey(
|
|
"alice",
|
|
{ capabilities: ["kubernetes:read"], workspace: "team/shop" },
|
|
request,
|
|
);
|
|
const expiresAt = "2026-12-01T00:00:00.000Z";
|
|
await createApiKey(
|
|
"alice",
|
|
{
|
|
capabilities: ["kubernetes:read"],
|
|
workspace: "team/shop",
|
|
expiresAt,
|
|
},
|
|
request,
|
|
);
|
|
|
|
expect(calls).toEqual([
|
|
{ path: "/users/alice", init: undefined },
|
|
{
|
|
path: "/users/alice/keys",
|
|
init: {
|
|
method: "POST",
|
|
json: { capabilities: ["kubernetes:read"], workspace: "team/shop" },
|
|
},
|
|
},
|
|
{ path: "/users/alice", init: undefined },
|
|
{
|
|
path: "/users/alice/keys",
|
|
init: {
|
|
method: "POST",
|
|
json: {
|
|
capabilities: ["kubernetes:read"],
|
|
workspace: "team/shop",
|
|
expiresAt,
|
|
},
|
|
},
|
|
},
|
|
]);
|
|
expect(calls[1]?.init?.json).not.toHaveProperty("expiresAt");
|
|
});
|
|
|
|
test("key command help declares username, capability examples, and non-expiry", async () => {
|
|
const commands = (users.subCommands as Record<string, any>)?.keys;
|
|
if (!commands || typeof commands === "function")
|
|
throw new Error("Missing keys command");
|
|
const subCommands = await Promise.resolve(commands.subCommands);
|
|
const create = subCommands?.create;
|
|
const ls = subCommands?.ls;
|
|
if (
|
|
!create ||
|
|
typeof create === "function" ||
|
|
!ls ||
|
|
typeof ls === "function"
|
|
)
|
|
throw new Error("Missing key subcommands");
|
|
expect(create.args?.username).toMatchObject({
|
|
type: "positional",
|
|
required: true,
|
|
});
|
|
expect(create.args?.capabilities?.description).toContain(
|
|
"kubernetes:read,kubernetes:write",
|
|
);
|
|
expect(create.args?.["expires-days"]?.description).toContain("none");
|
|
expect(create.args?.workspace?.alias).toBe("w");
|
|
expect(create.args?.["expires-days"]?.alias).toBe("e");
|
|
expect(create.meta?.description).toContain("--expireDays=none");
|
|
expect(ls.meta?.description).toContain("optional positional username");
|
|
await expect(
|
|
create.run?.({
|
|
args: {
|
|
_: [],
|
|
username: "alice",
|
|
capabilities: "invalid",
|
|
"expires-days": "90",
|
|
},
|
|
} as never),
|
|
).rejects.toThrow("kubernetes:read,kubernetes:write");
|
|
await expect(
|
|
create.run?.({
|
|
args: {
|
|
_: [],
|
|
username: "alice",
|
|
capabilities: "kubernetes:read",
|
|
"expires-days": "NaN",
|
|
},
|
|
} as never),
|
|
).rejects.toThrow("1 to 365, or none");
|
|
});
|
|
|
|
test("parses both API key creation forms and retains username position", async () => {
|
|
const calls: Call[] = [];
|
|
const request = spyOn(api, "apiRequest").mockImplementation((async <T>(
|
|
path: string,
|
|
init?: ApiRequestInit,
|
|
) => {
|
|
calls.push({ path, init });
|
|
return (
|
|
init
|
|
? {
|
|
id: "fake-key-id-123456",
|
|
username: "dmgnr",
|
|
capabilities:
|
|
init.json &&
|
|
(init.json as { capabilities: string[] }).capabilities,
|
|
workspace: "kuber-server",
|
|
token: "fake-token-never-real",
|
|
disabled: false,
|
|
}
|
|
: { username: "dmgnr", roles: ["admin"], disabled: false }
|
|
) as T;
|
|
}) as typeof api.apiRequest);
|
|
const log = spyOn(console, "log").mockImplementation(() => {});
|
|
const keyCommand = (users.subCommands as Record<string, any>).keys;
|
|
const create = keyCommand.subCommands.create;
|
|
try {
|
|
await runCommand(users, {
|
|
rawArgs: [
|
|
"keys",
|
|
"create",
|
|
"--capabilities",
|
|
"kubernetes:write",
|
|
"--workspace",
|
|
"kuber-server",
|
|
"--expires-days",
|
|
"none",
|
|
"dmgnr",
|
|
],
|
|
});
|
|
expect(calls[1]?.path).toBe("/users/dmgnr/keys");
|
|
expect(calls[1]?.init?.json).toEqual({
|
|
capabilities: ["kubernetes:write"],
|
|
workspace: "kuber-server",
|
|
});
|
|
calls.length = 0;
|
|
await runCommand(users, {
|
|
rawArgs: [
|
|
"keys",
|
|
"create",
|
|
"--capabilities",
|
|
"kubernetes:read",
|
|
"--expires-days",
|
|
"30",
|
|
"dmgnr",
|
|
],
|
|
});
|
|
expect(
|
|
(calls[1]?.init?.json as { expiresAt: string }).expiresAt,
|
|
).toBeString();
|
|
calls.length = 0;
|
|
await runCommand(users, {
|
|
rawArgs: [
|
|
"keys",
|
|
"create",
|
|
"-w",
|
|
"kuber-server",
|
|
"-e",
|
|
"none",
|
|
"dmgnr",
|
|
"kubernetes:write,kubernetes:read",
|
|
],
|
|
});
|
|
expect(calls[1]?.path).toBe("/users/dmgnr/keys");
|
|
expect(calls[1]?.init?.json).toEqual({
|
|
capabilities: ["kubernetes:write", "kubernetes:read"],
|
|
workspace: "kuber-server",
|
|
});
|
|
|
|
await expect(
|
|
create.run?.({ args: { username: "dmgnr" } } as never),
|
|
).rejects.toThrow("Provide capabilities");
|
|
await expect(
|
|
create.run?.({
|
|
args: {
|
|
username: "dmgnr",
|
|
capabilities: "kubernetes:read",
|
|
_: ["dmgnr", "kubernetes:write"],
|
|
},
|
|
} as never),
|
|
).rejects.toThrow("not both");
|
|
await expect(
|
|
create.run?.({
|
|
args: {
|
|
username: "dmgnr",
|
|
capabilities: "kubernetes:read",
|
|
"expires-days": "0",
|
|
},
|
|
} as never),
|
|
).rejects.toThrow("1 to 365, or none");
|
|
calls.length = 0;
|
|
await runCommand(users, {
|
|
rawArgs: [
|
|
"keys",
|
|
"create",
|
|
"dmgnr",
|
|
"--capabilities",
|
|
"kubernetes:read",
|
|
],
|
|
});
|
|
expect(
|
|
(calls[1]?.init?.json as { expiresAt: string }).expiresAt,
|
|
).toBeString();
|
|
} finally {
|
|
request.mockRestore();
|
|
log.mockRestore();
|
|
}
|
|
});
|
|
});
|
|
|
|
const operation = {
|
|
metadata: {
|
|
name: "operation-1",
|
|
creationTimestamp: "2026-09-02T10:00:00.000Z",
|
|
},
|
|
spec: { workspaceId: "team/shop", action: "restart" },
|
|
status: { state: "succeeded", result: { deployments: ["web"] } },
|
|
};
|
|
|
|
describe("operations and audit commands", () => {
|
|
test("lists filtered operations and gets operation details", async () => {
|
|
const calls: Call[] = [];
|
|
const listing = await listOperations(
|
|
"team/shop",
|
|
requestReturning({ items: [operation] }, calls),
|
|
);
|
|
const detail = await getOperation(
|
|
"operation/1",
|
|
requestReturning(operation, calls),
|
|
);
|
|
|
|
expect(listing).toContain("restart");
|
|
expect(detail).toContain('Result: {"deployments":["web"]}');
|
|
expect(calls).toEqual([
|
|
{ path: "/operations?workspaceId=team%2Fshop", init: undefined },
|
|
{ path: "/operations/operation%2F1", init: undefined },
|
|
]);
|
|
});
|
|
|
|
test("lists audit events with an optional workspace filter", async () => {
|
|
const calls: Call[] = [];
|
|
const output = await listAuditEvents(
|
|
"team/shop",
|
|
requestReturning(
|
|
{
|
|
items: [
|
|
{
|
|
metadata: {
|
|
name: "audit-1",
|
|
creationTimestamp: "2026-09-02T10:00:00.000Z",
|
|
},
|
|
spec: {
|
|
actor: { username: "alice" },
|
|
action: "workspace.restart",
|
|
workspaceId: "team/shop",
|
|
outcome: "success",
|
|
},
|
|
},
|
|
],
|
|
},
|
|
calls,
|
|
),
|
|
);
|
|
|
|
expect(output).toContain("alice");
|
|
expect(output).toContain("workspace.restart");
|
|
expect(calls).toEqual([
|
|
{ path: "/audit?workspaceId=team%2Fshop", init: undefined },
|
|
]);
|
|
});
|
|
|
|
test("registers administration command groups", async () => {
|
|
const subCommands = await Promise.resolve(main.subCommands);
|
|
expect(Object.keys(subCommands ?? {})).toEqual(
|
|
expect.arrayContaining(["users", "operations", "audit"]),
|
|
);
|
|
});
|
|
});
|