45bfae6e94
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
183 lines
8.1 KiB
TypeScript
183 lines
8.1 KiB
TypeScript
/**
|
||
* 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);
|
||
});
|
||
});
|