Files
penguin-harness/packages/server/test/authz.test.ts
T
Yaowei Zheng 45bfae6e94 Initialize repository with harness code and assets
Initial import of all source code, config, and README assets: the
packages workspace (cli, core, server, web, docs, landing, skills),
build scripts, tooling config, and CI workflows.

Includes the data-layout revision made on this branch: the local data
root defaults to ~/.penguin/data (PENGUIN_HOME still overrides; the
installer keeps its binaries in ~/.penguin), and every Agent lives
under <project>/agents/<agent>/ — path helpers, the three
agent-enumeration scans, the system prompt, built-in Skills, tests
and docs all follow the new layout.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ihk8iQuo3kv2aPjAYEPuR
2026-07-19 14:06:53 +08:00

183 lines
8.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Authorization rules integration tests: three perspectives — owner / member / no
* access; permission boundaries for model config and member management; access control
* for Session-level routes via index lookup; default_project deletion protection.
*/
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type {
MembersResponse,
ModelsResponse,
ProjectCreateResponse,
ProjectsResponse,
SessionCreateResponse,
} from "../src/api/types.js";
import { apiClient, createTestApp, loginAdmin, provisionUser } from "./helpers.js";
import type { TestApp } from "./helpers.js";
describe("authz", () => {
let t: TestApp;
let owner: ReturnType<typeof apiClient>;
let member: ReturnType<typeof apiClient>;
let outsider: ReturnType<typeof apiClient>;
let projectId: string;
beforeEach(async () => {
t = await createTestApp();
const a = await provisionUser(t.app, "owner_a");
const b = await provisionUser(t.app, "member_b");
const c = await provisionUser(t.app, "outsider_c");
owner = apiClient(t.app, a.cookie);
member = apiClient(t.app, b.cookie);
outsider = apiClient(t.app, c.cookie);
const created = (await (
await owner.post("/api/projects", { projectId: "owner_a-shared", name: "共享项目" })
).json()) as ProjectCreateResponse;
projectId = created.project.projectId;
const add = await owner.post(`/api/projects/${projectId}/members`, { userId: "member_b" });
expect(add.status).toBe(201);
});
afterEach(async () => {
await t.cleanup();
});
it("非成员访问返回 404(不泄露存在性)", async () => {
expect((await outsider.get(`/api/projects/${projectId}/models`)).status).toBe(404);
expect((await outsider.get(`/api/projects/${projectId}/agents`)).status).toBe(404);
expect((await outsider.get(`/api/projects/${projectId}/members`)).status).toBe(404);
expect((await outsider.get(`/api/projects/${projectId}/usage`)).status).toBe(404);
});
it("member 可读模型(掩码)但不可写;owner 可写", async () => {
const put = await owner.put(`/api/projects/${projectId}/models`, {
defaultModel: { provider: "custom", modelId: "m-1" },
models: [{ provider: "custom", modelId: "m-1", apiKey: "sk-super-secret-key-123456" }],
});
expect(put.status).toBe(200);
const res = await member.get(`/api/projects/${projectId}/models`);
expect(res.status).toBe(200);
const models = (await res.json()) as ModelsResponse;
expect(models.defaultModel).toEqual({ provider: "custom", modelId: "m-1" });
const cred = models.models[0]!.credential!;
expect(cred.apiKeyMasked).toBe("sk-s…3456");
expect(JSON.stringify(models)).not.toContain("sk-super-secret-key-123456");
const denied = await member.put(`/api/projects/${projectId}/models`, {
models: [{ provider: "custom", modelId: "m-2" }],
});
expect(denied.status).toBe(403);
});
it("成员管理仅 owner:列表包含 owner 与 member;重复与自授权 409;未知用户 404", async () => {
const list = (await (
await member.get(`/api/projects/${projectId}/members`)
).json()) as MembersResponse;
expect(list.members.map((m) => `${m.userId}:${m.role}`)).toEqual([
"owner_a:owner",
"member_b:member",
]);
expect(
(await member.post(`/api/projects/${projectId}/members`, { userId: "outsider_c" })).status,
).toBe(403);
expect(
(await owner.post(`/api/projects/${projectId}/members`, { userId: "member_b" })).status,
).toBe(409);
expect(
(await owner.post(`/api/projects/${projectId}/members`, { userId: "owner_a" })).status,
).toBe(409);
expect(
(await owner.post(`/api/projects/${projectId}/members`, { userId: "ghost" })).status,
).toBe(404);
// After removing authorization, the member loses access.
expect((await owner.delete(`/api/projects/${projectId}/members/member_b`)).status).toBe(204);
expect((await member.get(`/api/projects/${projectId}/models`)).status).toBe(404);
});
it("Session 级路由经索引反查授权:member 可见、外人 404", async () => {
await owner.put(`/api/projects/${projectId}/models`, {
defaultModel: { provider: "anthropic", modelId: "claude-sonnet-4-6" },
models: [{ provider: "anthropic", modelId: "claude-sonnet-4-6" }],
});
const created = await owner.post(
`/api/projects/${projectId}/agents/default_agent/sessions`,
{},
);
expect(created.status).toBe(201);
const { session } = (await created.json()) as SessionCreateResponse;
expect((await owner.get(`/api/sessions/${session.sessionId}`)).status).toBe(200);
expect((await member.get(`/api/sessions/${session.sessionId}`)).status).toBe(200);
expect((await outsider.get(`/api/sessions/${session.sessionId}`)).status).toBe(404);
expect((await owner.get("/api/sessions/session-unknown")).status).toBe(404);
});
it("删除 Project 仅 owner;default_project 与最后一个可访问 Project 拒绝删除", async () => {
expect((await member.delete(`/api/projects/${projectId}`)).status).toBe(403);
expect((await outsider.delete(`/api/projects/${projectId}`)).status).toBe(404);
// default_project is managed by admin: non-owners always get 404, and the owner (admin) is refused with 409.
expect((await owner.delete("/api/projects/default_project")).status).toBe(404);
const admin = apiClient(t.app, (await loginAdmin(t.app)).cookie);
expect((await admin.delete("/api/projects/default_project")).status).toBe(409);
// outsider-c only has the initial Project created at account setup: deleting it would
// leave the account with no usable Project (stuck on the skeleton screen on the Web
// side) -> refused with 409.
const cProjects = (await (await outsider.get("/api/projects")).json()) as ProjectsResponse;
expect(cProjects.projects).toHaveLength(1);
expect(
(await outsider.delete(`/api/projects/${cProjects.projects[0]!.projectId}`)).status,
).toBe(409);
expect((await owner.delete(`/api/projects/${projectId}`)).status).toBe(204);
expect((await owner.get(`/api/projects/${projectId}/models`)).status).toBe(404);
});
it("PUT models 整表替换:省略 apiKey 保留、clearApiKey 清除、defaultModel 必须在 models 内", async () => {
await owner.put(`/api/projects/${projectId}/models`, {
defaultModel: { provider: "custom", modelId: "m-1" },
models: [
{
provider: "custom",
modelId: "m-1",
contextWindow: 200000,
pricing: { cacheRead: 0.3, cacheWrite: 3.75, output: 15 },
apiKey: "sk-original-key-000111",
baseUrl: "https://example.com/v1",
},
{ provider: "custom", modelId: "m-2" },
],
});
// Omitting apiKey: preserves the original credential and created_at.
const second = (await (
await owner.put(`/api/projects/${projectId}/models`, {
defaultModel: { provider: "custom", modelId: "m-1" },
models: [{ provider: "custom", modelId: "m-1", contextWindow: 100000 }],
})
).json()) as ModelsResponse;
expect(second.models).toHaveLength(1); // m-2 was removed by the full-table replace
expect(second.models[0]!.credential?.apiKeyMasked).toBe("sk-o…0111");
expect(second.models[0]!.credential?.baseUrl).toBe("https://example.com/v1");
expect(second.models[0]!.credential?.createdAt).toBeTruthy();
expect(second.models[0]!.contextWindow).toBe(100000);
expect(second.models[0]!.pricing).toBeUndefined(); // Fields not resubmitted are removed by the replace
// clearApiKey + baseUrl null clears the value.
const third = (await (
await owner.put(`/api/projects/${projectId}/models`, {
defaultModel: { provider: "custom", modelId: "m-1" },
models: [{ provider: "custom", modelId: "m-1", clearApiKey: true, baseUrl: null }],
})
).json()) as ModelsResponse;
expect(third.models[0]!.credential).toBeUndefined();
// defaultModel not present in models -> 400.
const bad = await owner.put(`/api/projects/${projectId}/models`, {
defaultModel: { provider: "custom", modelId: "ghost" },
models: [{ provider: "custom", modelId: "m-1" }],
});
expect(bad.status).toBe(400);
});
});