Files
penguin-harness/packages/server/test/vault.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

163 lines
6.8 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.
/**
* Integration tests for the Vault environment variable routes (Agent-level
* agent_state/.vault.toml): GET masks values (plaintext
* is never sent), PUT is owner-only, whole-table replace semantics (omitting
* value keeps the original, an absent key is deleted, a new key must supply a
* value), 400 on key/shape validation, 404 for a nonexistent Agent, and vaults
* of different Agents are independent of each other.
*/
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type { ProjectCreateResponse, VaultResponse } from "../src/api/types.js";
import { apiClient, createTestApp, provisionUser } from "./helpers.js";
import type { TestApp } from "./helpers.js";
describe("vault api", () => {
let t: TestApp;
let owner: ReturnType<typeof apiClient>;
let member: ReturnType<typeof apiClient>;
let outsider: ReturnType<typeof apiClient>;
let projectId: string;
let vaultPath: 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-vault", name: "vault 项目" })
).json()) as ProjectCreateResponse;
projectId = created.project.projectId;
vaultPath = `/api/projects/${projectId}/agents/default_agent/vault`;
const add = await owner.post(`/api/projects/${projectId}/members`, { userId: "member_b" });
expect(add.status).toBe(201);
});
afterEach(async () => {
await t.cleanup();
});
it("GET 掩码且明文不下发;member 可读、外人 404;空 vault 返回空表", async () => {
// Not configured yet: an empty table.
const empty = (await (await owner.get(vaultPath)).json()) as VaultResponse;
expect(empty.entries).toEqual([]);
const put = await owner.put(vaultPath, {
entries: [
{ key: "OPENAI_API_KEY", value: "sk-vault-secret-123456" },
{ key: "SHORT", value: "sk-11-chars" },
],
});
expect(put.status).toBe(200);
// The PUT response likewise contains only masked values.
expect(JSON.stringify(await put.json())).not.toContain("sk-vault-secret-123456");
const res = await member.get(vaultPath);
expect(res.status).toBe(200);
const body = (await res.json()) as VaultResponse;
expect(body.entries).toEqual([
{ key: "OPENAI_API_KEY", valueMasked: "sk-v…3456" },
{ key: "SHORT", valueMasked: "***" }, // ≤12 chars are masked entirely (first4…last4 would expose over half)
]);
expect(JSON.stringify(body)).not.toContain("sk-vault-secret-123456");
expect(JSON.stringify(body)).not.toContain("sk-11-chars");
expect((await outsider.get(vaultPath)).status).toBe(404);
});
it("PUT 仅 owner:member 403、外人 404", async () => {
expect((await member.put(vaultPath, { entries: [{ key: "K", value: "v" }] })).status).toBe(403);
expect((await outsider.put(vaultPath, { entries: [{ key: "K", value: "v" }] })).status).toBe(
404,
);
});
it("整表替换:value 省略保留原值、缺席的键删除、新键缺 value 400", async () => {
await owner.put(vaultPath, {
entries: [
{ key: "KEEP_ME", value: "keep-secret-000111" },
{ key: "DROP_ME", value: "drop-secret" },
],
});
// KEEP_ME only sends back the key name (keeping the original value); DROP_ME is absent (deleted).
const second = (await (
await owner.put(vaultPath, { entries: [{ key: "KEEP_ME" }] })
).json()) as VaultResponse;
expect(second.entries).toEqual([{ key: "KEEP_ME", valueMasked: "keep…0111" }]);
// A new key without value → 400 (there's no original value to keep).
const bad = await owner.put(vaultPath, {
entries: [{ key: "KEEP_ME" }, { key: "BRAND_NEW" }],
});
expect(bad.status).toBe(400);
// Clear the whole table.
const cleared = (await (await owner.put(vaultPath, { entries: [] })).json()) as VaultResponse;
expect(cleared.entries).toEqual([]);
});
it("vault 更新不动 models/credential 等其他配置", async () => {
await owner.put(`/api/projects/${projectId}/models`, {
defaultModel: { provider: "custom", modelId: "m-1" },
models: [{ provider: "custom", modelId: "m-1", apiKey: "sk-model-key-999888" }],
});
await owner.put(vaultPath, { entries: [{ key: "K1", value: "v1-secret" }] });
const models = await owner.get(`/api/projects/${projectId}/models`);
const modelsBody = (await models.json()) as { models: { credential?: unknown }[] };
expect(modelsBody.models).toHaveLength(1);
expect(modelsBody.models[0]!.credential).toBeTruthy();
});
it("Agent 级隔离:不同 Agent 的 vault 互相独立;不存在的 Agent 404", async () => {
await owner.put(vaultPath, { entries: [{ key: "ONLY_DEFAULT", value: "v-default-1" }] });
// Another Agent in the same Project: empty table, and writes don't affect each other.
expect(
(await owner.post(`/api/projects/${projectId}/agents`, { agentId: "other_agent" })).status,
).toBe(201);
const otherPath = `/api/projects/${projectId}/agents/other_agent/vault`;
const empty = (await (await owner.get(otherPath)).json()) as VaultResponse;
expect(empty.entries).toEqual([]);
await owner.put(otherPath, { entries: [{ key: "ONLY_OTHER", value: "v-other-1" }] });
const def = (await (await owner.get(vaultPath)).json()) as VaultResponse;
expect(def.entries.map((e) => e.key)).toEqual(["ONLY_DEFAULT"]);
// Agent doesn't exist (including traversal-style ids blocked by requireValidId) → 404.
expect((await owner.get(`/api/projects/${projectId}/agents/no-such-agent/vault`)).status).toBe(
404,
);
expect(
(
await owner.put(`/api/projects/${projectId}/agents/no-such-agent/vault`, {
entries: [{ key: "K", value: "v" }],
})
).status,
).toBe(404);
});
it("键名与请求体形状校验 400", async () => {
const cases: unknown[] = [
{ entries: [{ key: "1BAD", value: "v" }] }, // starts with a digit
{ entries: [{ key: "BAD-DASH", value: "v" }] }, // hyphen
{ entries: [{ key: "BAD KEY", value: "v" }] }, // space
{ entries: [{ key: "OK_KEY", value: "" }] }, // empty value
{
entries: [
{ key: "DUP", value: "a" },
{ key: "DUP", value: "b" },
],
}, // duplicate key
{ entries: [{ key: "BIG", value: "x".repeat(8193) }] }, // value too long (>8192, guards against exec E2BIG)
{ entries: "nope" }, // entries is not an array
{ entries: [42] }, // entry is not an object
];
for (const body of cases) {
expect((await owner.put(vaultPath, body)).status).toBe(400);
}
});
});