forked from EduCraft/curriculum-project-hub
feat: secure organization provider credentials
This commit is contained in:
@@ -9,7 +9,7 @@ import {
|
||||
sessionCookieHeader,
|
||||
} from "../../src/admin/routes/authRoutes.js";
|
||||
import { OAUTH_STATE_COOKIE_NAME, signOAuthState } from "../../src/admin/auth/session.js";
|
||||
import { DEFAULT_ORG_ID, prisma, resetDb } from "./helpers.js";
|
||||
import { DEFAULT_ORG_ID, prisma, resetDb, testSecretEnvelope } from "./helpers.js";
|
||||
|
||||
const SESSION_SECRET = "integration-test-session-secret";
|
||||
const PUBLIC_BASE = "http://127.0.0.1:8788";
|
||||
@@ -23,6 +23,7 @@ async function buildApp(fetchImpl?: typeof fetch) {
|
||||
feishuAppId: "cli_test",
|
||||
feishuAppSecret: "secret_test",
|
||||
projectWorkspaceRoot: "/tmp/cph-test-workspaces",
|
||||
secretEnvelope: testSecretEnvelope,
|
||||
cookieSecure: false,
|
||||
...(fetchImpl !== undefined ? { fetchImpl } : {}),
|
||||
});
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
mintSessionToken,
|
||||
sessionCookieHeader,
|
||||
} from "../../src/admin/routes/authRoutes.js";
|
||||
import { DEFAULT_ORG_ID, prisma, resetDb } from "./helpers.js";
|
||||
import { DEFAULT_ORG_ID, prisma, resetDb, testSecretEnvelope } from "./helpers.js";
|
||||
|
||||
const SESSION_SECRET = "integration-test-session-secret";
|
||||
const workspaceRoots: string[] = [];
|
||||
@@ -32,6 +32,7 @@ async function buildApp(workspaceRoot: string) {
|
||||
feishuAppId: "cli_test",
|
||||
feishuAppSecret: "secret_test",
|
||||
projectWorkspaceRoot: workspaceRoot,
|
||||
secretEnvelope: testSecretEnvelope,
|
||||
cookieSecure: false,
|
||||
});
|
||||
await app.ready();
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
mintSessionToken,
|
||||
sessionCookieHeader,
|
||||
} from "../../src/admin/routes/authRoutes.js";
|
||||
import { DEFAULT_ORG_ID, prisma, resetDb } from "./helpers.js";
|
||||
import { DEFAULT_ORG_ID, prisma, resetDb, testSecretEnvelope } from "./helpers.js";
|
||||
import { addOrgMember } from "../../src/org/members.js";
|
||||
import { addTeamMember, archiveTeam, createTeam, updateTeam } from "../../src/org/teams.js";
|
||||
|
||||
@@ -23,6 +23,7 @@ async function buildApp() {
|
||||
feishuAppId: "cli_test",
|
||||
feishuAppSecret: "secret_test",
|
||||
projectWorkspaceRoot: "/tmp/cph-test-ws",
|
||||
secretEnvelope: testSecretEnvelope,
|
||||
cookieSecure: false,
|
||||
});
|
||||
await app.ready();
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
import Fastify from "fastify";
|
||||
import { afterAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import { registerAdminPlugin } from "../../src/admin/plugin.js";
|
||||
import { ProviderConnectionService } from "../../src/connections/providerConnections.js";
|
||||
import { mintSessionToken, sessionCookieHeader } from "../../src/admin/routes/authRoutes.js";
|
||||
import { DEFAULT_ORG_ID, prisma, resetDb, testSecretEnvelope } from "./helpers.js";
|
||||
|
||||
const SESSION_SECRET = "integration-test-session-secret";
|
||||
const secrets = testSecretEnvelope;
|
||||
|
||||
async function buildApp() {
|
||||
const app = Fastify({ logger: false });
|
||||
await registerAdminPlugin(app, {
|
||||
prisma,
|
||||
sessionSecret: SESSION_SECRET,
|
||||
publicBaseUrl: "http://127.0.0.1:8788",
|
||||
feishuAppId: "cli_test",
|
||||
feishuAppSecret: "secret_test",
|
||||
projectWorkspaceRoot: "/tmp/cph-test-workspaces",
|
||||
secretEnvelope: secrets,
|
||||
providerReadinessProbe: async () => {},
|
||||
cookieSecure: false,
|
||||
});
|
||||
await app.ready();
|
||||
return app;
|
||||
}
|
||||
|
||||
async function seedUser(role: "OWNER" | "ADMIN" | "MEMBER"): Promise<string> {
|
||||
const id = `u-${role.toLowerCase()}`;
|
||||
const openId = `ou_${role.toLowerCase()}`;
|
||||
await prisma.user.create({ data: { id, feishuOpenId: openId, displayName: id } });
|
||||
await prisma.organizationMembership.create({
|
||||
data: { organizationId: DEFAULT_ORG_ID, userId: id, role },
|
||||
});
|
||||
return mintSessionToken({ userId: id, feishuOpenId: openId }, SESSION_SECRET);
|
||||
}
|
||||
|
||||
describe("Organization BYOK provider connections", () => {
|
||||
beforeEach(async () => {
|
||||
await resetDb();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
it("stores write-only credentials in an encrypted immutable version", async () => {
|
||||
const token = await seedUser("OWNER");
|
||||
const app = await buildApp();
|
||||
const credential = {
|
||||
baseUrl: "https://provider.example/api",
|
||||
authToken: "org-a-auth-token-never-return",
|
||||
anthropicApiKey: "org-a-api-key-never-return",
|
||||
};
|
||||
try {
|
||||
const response = await app.inject({
|
||||
method: "PUT",
|
||||
url: "/api/org/test-default/provider-connections/openrouter",
|
||||
headers: {
|
||||
cookie: sessionCookieHeader(token),
|
||||
"content-type": "application/json",
|
||||
},
|
||||
payload: credential,
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(201);
|
||||
expect(response.json()).toMatchObject({
|
||||
providerId: "openrouter",
|
||||
mode: "BYOK",
|
||||
status: "ACTIVE",
|
||||
activeVersion: 1,
|
||||
keyId: "test-active",
|
||||
});
|
||||
expect(response.body).not.toContain(credential.authToken);
|
||||
expect(response.body).not.toContain(credential.anthropicApiKey);
|
||||
expect(response.body).not.toContain(credential.baseUrl);
|
||||
|
||||
const persisted = await prisma.$queryRaw<Array<{
|
||||
envelope: unknown;
|
||||
activeSecretVersionId: string;
|
||||
}>>`
|
||||
SELECT v."envelope", c."activeSecretVersionId"
|
||||
FROM "ProviderCredentialVersion" v
|
||||
JOIN "OrganizationProviderConnection" c ON c."id" = v."connectionId"
|
||||
WHERE c."organizationId" = ${DEFAULT_ORG_ID} AND c."providerId" = 'openrouter'
|
||||
`;
|
||||
expect(persisted).toHaveLength(1);
|
||||
const serialized = JSON.stringify(persisted);
|
||||
expect(serialized).not.toContain(credential.authToken);
|
||||
expect(serialized).not.toContain(credential.anthropicApiKey);
|
||||
expect(serialized).not.toContain(credential.baseUrl);
|
||||
expect(persisted[0]?.activeSecretVersionId).toBeTruthy();
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("rotates by appending a version and never exposes old or new plaintext", async () => {
|
||||
const token = await seedUser("ADMIN");
|
||||
const app = await buildApp();
|
||||
const cookie = sessionCookieHeader(token);
|
||||
try {
|
||||
const first = await app.inject({
|
||||
method: "PUT",
|
||||
url: "/api/org/test-default/provider-connections/openrouter",
|
||||
headers: { cookie, "content-type": "application/json" },
|
||||
payload: { baseUrl: "https://one.example", authToken: "first-secret" },
|
||||
});
|
||||
const second = await app.inject({
|
||||
method: "PUT",
|
||||
url: "/api/org/test-default/provider-connections/openrouter",
|
||||
headers: { cookie, "content-type": "application/json" },
|
||||
payload: { baseUrl: "https://two.example", authToken: "second-secret" },
|
||||
});
|
||||
|
||||
expect(first.statusCode).toBe(201);
|
||||
expect(second.statusCode).toBe(200);
|
||||
expect(second.json()).toMatchObject({ activeVersion: 2, status: "ACTIVE" });
|
||||
const versions = await prisma.$queryRaw<Array<{ version: number; retiredAt: Date | null }>>`
|
||||
SELECT v."version", v."retiredAt"
|
||||
FROM "ProviderCredentialVersion" v
|
||||
JOIN "OrganizationProviderConnection" c ON c."id" = v."connectionId"
|
||||
WHERE c."organizationId" = ${DEFAULT_ORG_ID}
|
||||
ORDER BY v."version"
|
||||
`;
|
||||
expect(versions).toEqual([
|
||||
{ version: 1, retiredAt: expect.any(Date) },
|
||||
{ version: 2, retiredAt: null },
|
||||
]);
|
||||
|
||||
const read = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/org/test-default/provider-connections",
|
||||
headers: { cookie },
|
||||
});
|
||||
expect(read.statusCode).toBe(200);
|
||||
expect(read.json()).toEqual({ connections: [expect.objectContaining({ activeVersion: 2 })] });
|
||||
expect(read.body).not.toContain("first-secret");
|
||||
expect(read.body).not.toContain("second-secret");
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects members and refuses to overwrite a platform-managed connection", async () => {
|
||||
const memberToken = await seedUser("MEMBER");
|
||||
const app = await buildApp();
|
||||
try {
|
||||
const denied = await app.inject({
|
||||
method: "PUT",
|
||||
url: "/api/org/test-default/provider-connections/openrouter",
|
||||
headers: {
|
||||
cookie: sessionCookieHeader(memberToken),
|
||||
"content-type": "application/json",
|
||||
},
|
||||
payload: { baseUrl: "https://provider.example", authToken: "denied-secret" },
|
||||
});
|
||||
expect(denied.statusCode).toBe(403);
|
||||
const service = new ProviderConnectionService(prisma, secrets, async () => {});
|
||||
await expect(service.rotateByok({
|
||||
organizationId: DEFAULT_ORG_ID,
|
||||
providerId: "another-provider",
|
||||
actorUserId: "u-member",
|
||||
baseUrl: "https://provider.example",
|
||||
authToken: "service-bypass-secret",
|
||||
})).rejects.toThrow("requires an active Organization OWNER or ADMIN");
|
||||
await expect(prisma.organizationProviderConnection.count({
|
||||
where: { organizationId: DEFAULT_ORG_ID, providerId: "another-provider" },
|
||||
})).resolves.toBe(0);
|
||||
|
||||
await prisma.$executeRaw`
|
||||
INSERT INTO "OrganizationProviderConnection"
|
||||
("id", "organizationId", "providerId", "mode", "status", "createdAt", "updatedAt")
|
||||
VALUES
|
||||
('platform-connection', ${DEFAULT_ORG_ID}, 'openrouter', 'PLATFORM_MANAGED', 'DRAFT', NOW(), NOW())
|
||||
`;
|
||||
const ownerToken = await seedUser("OWNER");
|
||||
const refused = await app.inject({
|
||||
method: "PUT",
|
||||
url: "/api/org/test-default/provider-connections/openrouter",
|
||||
headers: {
|
||||
cookie: sessionCookieHeader(ownerToken),
|
||||
"content-type": "application/json",
|
||||
},
|
||||
payload: { baseUrl: "https://provider.example", authToken: "takeover-secret" },
|
||||
});
|
||||
expect(refused.statusCode).toBe(403);
|
||||
expect(refused.json()).toMatchObject({
|
||||
error: { code: "forbidden" },
|
||||
});
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not activate or persist a credential that fails readiness", async () => {
|
||||
await seedUser("ADMIN");
|
||||
const service = new ProviderConnectionService(prisma, secrets, async () => {
|
||||
throw new Error("provider credential readiness check failed: status 401");
|
||||
});
|
||||
|
||||
await expect(service.rotateByok({
|
||||
organizationId: DEFAULT_ORG_ID,
|
||||
providerId: "openrouter",
|
||||
actorUserId: "u-admin",
|
||||
baseUrl: "https://provider.example/api",
|
||||
authToken: "rejected-secret",
|
||||
})).rejects.toThrow("readiness check failed");
|
||||
await expect(prisma.organizationProviderConnection.count({
|
||||
where: { organizationId: DEFAULT_ORG_ID, providerId: "openrouter" },
|
||||
})).resolves.toBe(0);
|
||||
await expect(prisma.providerCredentialVersion.count()).resolves.toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -125,10 +125,10 @@ describe("real Claude SDK sandbox boundary", () => {
|
||||
workspaceDir: workspace,
|
||||
},
|
||||
systemPrompt: "Use the Bash tool exactly once, then report completion.",
|
||||
providerEnv: {
|
||||
providerProxyEnv: {
|
||||
ANTHROPIC_BASE_URL: stub.baseUrl,
|
||||
ANTHROPIC_AUTH_TOKEN: "provider-secret",
|
||||
ANTHROPIC_API_KEY: "provider-api-secret",
|
||||
ANTHROPIC_AUTH_TOKEN: "run-proxy-capability",
|
||||
ANTHROPIC_API_KEY: "",
|
||||
},
|
||||
tools: ["bash"],
|
||||
maxTurns: 3,
|
||||
|
||||
@@ -4,6 +4,15 @@ import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
DEFAULT_ORG_ID,
|
||||
prisma,
|
||||
resetDb,
|
||||
testSecretEnvelope,
|
||||
TEST_SECRET_KEY,
|
||||
TEST_SECRET_KEY_ID,
|
||||
} from "./helpers.js";
|
||||
import { ProviderConnectionService } from "../../src/connections/providerConnections.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const TEST_DATABASE_URL = "postgresql://paradigm:paradigm@127.0.0.1:5432/cph_hub_test";
|
||||
@@ -16,6 +25,7 @@ describe("deployment preflight CLI", () => {
|
||||
let marker: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetDb();
|
||||
root = await mkdtemp(join(tmpdir(), "cph-deployment-preflight-"));
|
||||
baseDir = join(root, "deploy");
|
||||
hubDir = join(baseDir, "current", "hub");
|
||||
@@ -93,6 +103,23 @@ describe("deployment preflight CLI", () => {
|
||||
expect(result.stderr).toContain("PostgreSQL authenticated query failed for DATABASE_URL");
|
||||
});
|
||||
|
||||
it("rejects a structurally valid keyring that cannot authenticate stored envelopes", async () => {
|
||||
await prismaActorForEnvelope();
|
||||
const workspaceRoot = join(persistentDir, "workspaces");
|
||||
const fixture = await createFixture({
|
||||
workspaceRoot,
|
||||
marker,
|
||||
keyringKey: Buffer.alloc(32, "wrong"),
|
||||
});
|
||||
|
||||
const result = await runCli(fixture.args);
|
||||
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.stderr).toContain("stored provider envelope verification failed");
|
||||
expect(result.stderr).toContain("secret envelope authentication failed");
|
||||
expect(result.stderr).not.toContain("stored-envelope-secret");
|
||||
});
|
||||
|
||||
it("loads the actual Hub dependency graph and queries PostgreSQL in the runtime probe", async () => {
|
||||
const result = await execFileAsync(
|
||||
resolve("node_modules/.bin/tsx"),
|
||||
@@ -138,6 +165,7 @@ describe("deployment preflight CLI", () => {
|
||||
marker: string;
|
||||
pgIsReadyScript?: string;
|
||||
databaseUrl?: string;
|
||||
keyringKey?: Buffer;
|
||||
}): Promise<{ args: string[] }> {
|
||||
const binDir = join(root, "bin");
|
||||
const cphBin = join(binDir, "cph");
|
||||
@@ -147,6 +175,7 @@ describe("deployment preflight CLI", () => {
|
||||
const runuserBin = join(binDir, "runuser");
|
||||
const setprivBin = join(binDir, "setpriv");
|
||||
const envFile = join(root, "platform.env");
|
||||
const keyringFile = join(root, "secret-keyring.json");
|
||||
await Promise.all([
|
||||
mkdir(binDir, { recursive: true }),
|
||||
mkdir(join(hubDir, "dist"), { recursive: true }),
|
||||
@@ -180,15 +209,17 @@ describe("deployment preflight CLI", () => {
|
||||
`const { appendFileSync } = require("node:fs");\nappendFileSync(${JSON.stringify(options.marker)}, "service-prisma\\n");\n`,
|
||||
),
|
||||
writeFile(join(hubDir, "prisma", "schema.prisma"), "// fixture\n"),
|
||||
writeFile(keyringFile, JSON.stringify({
|
||||
version: 1,
|
||||
activeKeyId: TEST_SECRET_KEY_ID,
|
||||
keys: { [TEST_SECRET_KEY_ID]: (options.keyringKey ?? TEST_SECRET_KEY).toString("base64") },
|
||||
}), { mode: 0o600 }),
|
||||
]);
|
||||
await writeFile(
|
||||
envFile,
|
||||
[
|
||||
"NODE_ENV=production",
|
||||
`DATABASE_URL=${options.databaseUrl ?? TEST_DATABASE_URL}`,
|
||||
"ANTHROPIC_BASE_URL=https://openrouter.ai/api",
|
||||
"ANTHROPIC_AUTH_TOKEN=provider-token",
|
||||
"ANTHROPIC_API_KEY=",
|
||||
"FEISHU_APP_ID=cli_app_id",
|
||||
"FEISHU_APP_SECRET=feishu-app-secret",
|
||||
"FEISHU_BOT_OPEN_ID=ou_bot",
|
||||
@@ -207,6 +238,8 @@ describe("deployment preflight CLI", () => {
|
||||
args: [
|
||||
"--env-file",
|
||||
envFile,
|
||||
"--keyring-file",
|
||||
keyringFile,
|
||||
"--node-bin",
|
||||
process.execPath,
|
||||
"--runuser-bin",
|
||||
@@ -236,6 +269,20 @@ describe("deployment preflight CLI", () => {
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async function prismaActorForEnvelope(): Promise<void> {
|
||||
await prisma.user.create({ data: { id: "preflight-admin", feishuOpenId: "ou_preflight", displayName: "Preflight" } });
|
||||
await prisma.organizationMembership.create({
|
||||
data: { organizationId: DEFAULT_ORG_ID, userId: "preflight-admin", role: "ADMIN" },
|
||||
});
|
||||
await new ProviderConnectionService(prisma, testSecretEnvelope, async () => {}).rotateByok({
|
||||
organizationId: DEFAULT_ORG_ID,
|
||||
providerId: "openrouter",
|
||||
actorUserId: "preflight-admin",
|
||||
baseUrl: "https://provider.example/api",
|
||||
authToken: "stored-envelope-secret",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
async function executable(path: string, contents: string): Promise<void> {
|
||||
|
||||
@@ -17,9 +17,16 @@ import type {
|
||||
} from "@ai-sdk/provider";
|
||||
import type { FeishuRuntime } from "../../src/feishu/client.js";
|
||||
import type { ModelFactory } from "../../src/agent/runner.js";
|
||||
import { LocalSecretEnvelope } from "../../src/security/secretEnvelope.js";
|
||||
|
||||
export const TEST_DATABASE_URL = "postgresql://paradigm:paradigm@127.0.0.1:5432/cph_hub_test";
|
||||
export const DEFAULT_ORG_ID = "org_test_default";
|
||||
export const TEST_SECRET_KEY_ID = "test-active";
|
||||
export const TEST_SECRET_KEY = Buffer.alloc(32, "k");
|
||||
export const testSecretEnvelope = new LocalSecretEnvelope({
|
||||
activeKeyId: TEST_SECRET_KEY_ID,
|
||||
keys: new Map([[TEST_SECRET_KEY_ID, TEST_SECRET_KEY]]),
|
||||
});
|
||||
|
||||
export const prisma = new PrismaClient({
|
||||
datasources: { db: { url: TEST_DATABASE_URL } },
|
||||
@@ -29,6 +36,8 @@ export const prisma = new PrismaClient({
|
||||
export async function resetDb(): Promise<void> {
|
||||
const tables = [
|
||||
"FeishuEventReceipt",
|
||||
"ProviderCredentialVersion",
|
||||
"OrganizationProviderConnection",
|
||||
"AgentFileChange",
|
||||
"AgentMessage",
|
||||
"AuditEntry",
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import { ProviderConnectionService, verifyStoredProviderEnvelopes } from "../../src/connections/providerConnections.js";
|
||||
import { LocalSecretEnvelope, loadLocalSecretKeyringFile, type SecretEnvelopeV1 } from "../../src/security/secretEnvelope.js";
|
||||
import { LOCAL_KEK_ROTATION_ADVISORY_LOCK, rotateLocalKek } from "../../src/security/localKekRotation.js";
|
||||
import { DEFAULT_ORG_ID, prisma, resetDb } from "./helpers.js";
|
||||
|
||||
const temporaryDirectories: string[] = [];
|
||||
|
||||
describe("local KEK rotation", () => {
|
||||
beforeEach(async () => {
|
||||
await resetDb();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })));
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
it("atomically retains the old KEK, rewraps DEKs, and authenticates every envelope", async () => {
|
||||
const oldKey = Buffer.alloc(32, "o");
|
||||
const oldSecrets = new LocalSecretEnvelope({
|
||||
activeKeyId: "local-old",
|
||||
keys: new Map([["local-old", oldKey]]),
|
||||
});
|
||||
await prisma.user.create({ data: { id: "rotation-admin", feishuOpenId: "ou_rotation", displayName: "Rotation" } });
|
||||
await prisma.organizationMembership.create({
|
||||
data: { organizationId: DEFAULT_ORG_ID, userId: "rotation-admin", role: "OWNER" },
|
||||
});
|
||||
await new ProviderConnectionService(prisma, oldSecrets, async () => {}).rotateByok({
|
||||
organizationId: DEFAULT_ORG_ID,
|
||||
providerId: "openrouter",
|
||||
actorUserId: "rotation-admin",
|
||||
baseUrl: "https://provider.example/api",
|
||||
authToken: "credential-stays-encrypted",
|
||||
});
|
||||
const before = await prisma.providerCredentialVersion.findFirstOrThrow({
|
||||
include: { connection: true },
|
||||
});
|
||||
const beforeEnvelope = before.envelope as unknown as SecretEnvelopeV1;
|
||||
const directory = await mkdtemp(join(tmpdir(), "cph-kek-rotation-"));
|
||||
temporaryDirectories.push(directory);
|
||||
const keyringFile = join(directory, "keyring.json");
|
||||
await writeFile(keyringFile, JSON.stringify({
|
||||
version: 1,
|
||||
activeKeyId: "local-old",
|
||||
keys: { "local-old": oldKey.toString("base64") },
|
||||
}), { mode: 0o600 });
|
||||
const expectedOwner = {
|
||||
uid: process.getuid?.() ?? 0,
|
||||
gid: process.getgid?.() ?? 0,
|
||||
};
|
||||
if (expectedOwner.uid !== 0 || expectedOwner.gid !== 0) {
|
||||
await expect(rotateLocalKek({
|
||||
keyringFile,
|
||||
prisma,
|
||||
generatedKey: Buffer.alloc(32, "x"),
|
||||
})).rejects.toThrow("must be owned by 0:0");
|
||||
}
|
||||
|
||||
let releaseLock!: () => void;
|
||||
const releaseLockPromise = new Promise<void>((resolve) => {
|
||||
releaseLock = resolve;
|
||||
});
|
||||
let lockAcquired!: () => void;
|
||||
const lockAcquiredPromise = new Promise<void>((resolve) => {
|
||||
lockAcquired = resolve;
|
||||
});
|
||||
const lockHolder = prisma.$transaction(async (tx) => {
|
||||
await tx.$queryRaw<Array<{ readonly locked: string }>>`
|
||||
SELECT pg_advisory_xact_lock(${LOCAL_KEK_ROTATION_ADVISORY_LOCK})::text AS "locked"
|
||||
`;
|
||||
lockAcquired();
|
||||
await releaseLockPromise;
|
||||
}, { timeout: 30_000 });
|
||||
await lockAcquiredPromise;
|
||||
try {
|
||||
await expect(rotateLocalKek({
|
||||
keyringFile,
|
||||
prisma,
|
||||
generatedKey: Buffer.alloc(32, "x"),
|
||||
expectedOwner,
|
||||
})).rejects.toThrow("rotation lock is already held");
|
||||
} finally {
|
||||
releaseLock();
|
||||
await lockHolder;
|
||||
}
|
||||
|
||||
const result = await rotateLocalKek({
|
||||
keyringFile,
|
||||
prisma,
|
||||
now: new Date("2026-07-10T12:34:56.000Z"),
|
||||
generatedKey: Buffer.alloc(32, "n"),
|
||||
expectedOwner,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
previousActiveKeyId: "local-old",
|
||||
activeKeyId: expect.stringMatching(/^local-20260710T123456Z-/),
|
||||
rewrapped: 1,
|
||||
verified: 1,
|
||||
});
|
||||
const after = await prisma.providerCredentialVersion.findUniqueOrThrow({ where: { id: before.id } });
|
||||
const afterEnvelope = after.envelope as unknown as SecretEnvelopeV1;
|
||||
expect(after.keyId).toBe(result.activeKeyId);
|
||||
expect(afterEnvelope.payload).toEqual(beforeEnvelope.payload);
|
||||
expect(afterEnvelope.wrappedDek).not.toEqual(beforeEnvelope.wrappedDek);
|
||||
const keyringSource = await readFile(keyringFile, "utf8");
|
||||
expect(keyringSource).toContain("local-old");
|
||||
expect(keyringSource).toContain(result.activeKeyId);
|
||||
expect(keyringSource).not.toContain("credential-stays-encrypted");
|
||||
|
||||
const rotatedSecrets = new LocalSecretEnvelope(await loadLocalSecretKeyringFile(keyringFile));
|
||||
await expect(verifyStoredProviderEnvelopes(prisma, rotatedSecrets)).resolves.toBe(1);
|
||||
expect(() => oldSecrets.decryptJson({
|
||||
purpose: "provider-connection",
|
||||
organizationId: before.connection.organizationId,
|
||||
connectionId: before.connection.id,
|
||||
secretVersionId: before.id,
|
||||
}, afterEnvelope)).toThrow("secret envelope key is unavailable");
|
||||
await expect(prisma.auditEntry.count({
|
||||
where: { action: "provider_secret.kek_rewrapped", organizationId: DEFAULT_ORG_ID },
|
||||
})).resolves.toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
import { afterAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import { ProviderConnectionService } from "../../src/connections/providerConnections.js";
|
||||
import { DatabaseRuntimeSettings } from "../../src/settings/runtime.js";
|
||||
import type { ProviderUpstreamCredential } from "../../src/connections/providerProxy.js";
|
||||
import { DEFAULT_ORG_ID, prisma, resetDb, seedTestOrganization, testSecretEnvelope } from "./helpers.js";
|
||||
|
||||
const secrets = testSecretEnvelope;
|
||||
|
||||
describe("project-scoped provider runtime settings", () => {
|
||||
beforeEach(async () => {
|
||||
await resetDb();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
it("resolves two projects only through their own Organization connection", async () => {
|
||||
await seedTestOrganization("org_other", "other");
|
||||
await Promise.all([
|
||||
prisma.user.create({ data: { id: "user-a", feishuOpenId: "ou_a", displayName: "A" } }),
|
||||
prisma.user.create({ data: { id: "user-b", feishuOpenId: "ou_b", displayName: "B" } }),
|
||||
prisma.project.create({
|
||||
data: {
|
||||
id: "project-a",
|
||||
organizationId: DEFAULT_ORG_ID,
|
||||
name: "A",
|
||||
workspaceDir: "/tmp/project-a",
|
||||
},
|
||||
}),
|
||||
prisma.project.create({
|
||||
data: {
|
||||
id: "project-b",
|
||||
organizationId: "org_other",
|
||||
name: "B",
|
||||
workspaceDir: "/tmp/project-b",
|
||||
},
|
||||
}),
|
||||
]);
|
||||
await Promise.all([
|
||||
prisma.organizationMembership.create({
|
||||
data: { organizationId: DEFAULT_ORG_ID, userId: "user-a", role: "ADMIN" },
|
||||
}),
|
||||
prisma.organizationMembership.create({
|
||||
data: { organizationId: "org_other", userId: "user-b", role: "OWNER" },
|
||||
}),
|
||||
]);
|
||||
const service = new ProviderConnectionService(prisma, secrets, async () => {});
|
||||
await service.rotateByok({
|
||||
organizationId: DEFAULT_ORG_ID,
|
||||
providerId: "openrouter",
|
||||
actorUserId: "user-a",
|
||||
baseUrl: "https://provider-a.example/api",
|
||||
authToken: "provider-a-token",
|
||||
anthropicApiKey: "provider-a-api-key",
|
||||
});
|
||||
await service.rotateByok({
|
||||
organizationId: "org_other",
|
||||
providerId: "openrouter",
|
||||
actorUserId: "user-b",
|
||||
baseUrl: "https://provider-b.example/api",
|
||||
authToken: "provider-b-token",
|
||||
});
|
||||
const leasedCredentials: ProviderUpstreamCredential[] = [];
|
||||
const settings = new DatabaseRuntimeSettings(prisma, secrets, {
|
||||
ANTHROPIC_AUTH_TOKEN: "forbidden-global-token",
|
||||
ANTHROPIC_BASE_URL: "https://forbidden-global.example",
|
||||
}, async (credential) => {
|
||||
leasedCredentials.push(credential);
|
||||
return {
|
||||
sdkEnv: {
|
||||
ANTHROPIC_BASE_URL: "http://127.0.0.1:12345",
|
||||
ANTHROPIC_AUTH_TOKEN: `run-capability-${leasedCredentials.length}`,
|
||||
ANTHROPIC_API_KEY: "",
|
||||
},
|
||||
sensitiveValues: [`run-capability-${leasedCredentials.length}`],
|
||||
async close() {},
|
||||
};
|
||||
});
|
||||
|
||||
const providerA = await settings.provider("openrouter", { projectId: "project-a" });
|
||||
const providerB = await settings.provider("openrouter", { projectId: "project-b" });
|
||||
expect(JSON.stringify([providerA, providerB])).not.toContain("provider-a-token");
|
||||
expect(JSON.stringify([providerA, providerB])).not.toContain("provider-b-token");
|
||||
|
||||
const leaseA = await providerA.openAgentLease({ runId: "run-a" });
|
||||
const leaseB = await providerB.openAgentLease({ runId: "run-b" });
|
||||
expect(leaseA.sdkEnv).toMatchObject({
|
||||
ANTHROPIC_BASE_URL: "http://127.0.0.1:12345",
|
||||
ANTHROPIC_AUTH_TOKEN: "run-capability-1",
|
||||
ANTHROPIC_API_KEY: "",
|
||||
});
|
||||
expect(leaseB.sdkEnv.ANTHROPIC_AUTH_TOKEN).toBe("run-capability-2");
|
||||
expect(leasedCredentials).toEqual([
|
||||
{
|
||||
baseUrl: "https://provider-a.example/api",
|
||||
authToken: "provider-a-token",
|
||||
anthropicApiKey: "provider-a-api-key",
|
||||
},
|
||||
{
|
||||
baseUrl: "https://provider-b.example/api",
|
||||
authToken: "provider-b-token",
|
||||
anthropicApiKey: "",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("fails closed without project scope or for missing, inactive, and corrupt connections", async () => {
|
||||
await prisma.user.create({ data: { id: "user-a", feishuOpenId: "ou_a", displayName: "A" } });
|
||||
await prisma.organizationMembership.create({
|
||||
data: { organizationId: DEFAULT_ORG_ID, userId: "user-a", role: "ADMIN" },
|
||||
});
|
||||
await prisma.project.create({
|
||||
data: {
|
||||
id: "project-a",
|
||||
organizationId: DEFAULT_ORG_ID,
|
||||
name: "A",
|
||||
workspaceDir: "/tmp/project-a",
|
||||
},
|
||||
});
|
||||
const settings = new DatabaseRuntimeSettings(prisma, secrets, {
|
||||
ANTHROPIC_AUTH_TOKEN: "must-not-be-a-fallback",
|
||||
});
|
||||
|
||||
await expect(settings.provider("openrouter")).rejects.toThrow("projectId is required");
|
||||
await expect(settings.provider("openrouter", { projectId: "project-a" })).rejects.toThrow(
|
||||
"active provider connection not found",
|
||||
);
|
||||
|
||||
const service = new ProviderConnectionService(prisma, secrets, async () => {});
|
||||
await service.rotateByok({
|
||||
organizationId: DEFAULT_ORG_ID,
|
||||
providerId: "openrouter",
|
||||
actorUserId: "user-a",
|
||||
baseUrl: "https://provider-a.example/api",
|
||||
authToken: "provider-a-token",
|
||||
});
|
||||
await prisma.organization.update({
|
||||
where: { id: DEFAULT_ORG_ID },
|
||||
data: { status: "SUSPENDED" },
|
||||
});
|
||||
await expect(settings.provider("openrouter", { projectId: "project-a" })).rejects.toThrow(
|
||||
`organization ${DEFAULT_ORG_ID} is SUSPENDED`,
|
||||
);
|
||||
|
||||
await prisma.organization.update({ where: { id: DEFAULT_ORG_ID }, data: { status: "ACTIVE" } });
|
||||
const connection = await prisma.organizationProviderConnection.findUniqueOrThrow({
|
||||
where: {
|
||||
organizationId_providerId: { organizationId: DEFAULT_ORG_ID, providerId: "openrouter" },
|
||||
},
|
||||
});
|
||||
const version = await prisma.providerCredentialVersion.findUniqueOrThrow({
|
||||
where: { id: connection.activeSecretVersionId ?? "missing" },
|
||||
});
|
||||
const envelope = structuredClone(version.envelope) as {
|
||||
payload: { ciphertext: string };
|
||||
};
|
||||
const bytes = Buffer.from(envelope.payload.ciphertext, "base64");
|
||||
bytes[0] = (bytes[0] ?? 0) ^ 1;
|
||||
envelope.payload.ciphertext = bytes.toString("base64");
|
||||
await prisma.providerCredentialVersion.update({
|
||||
where: { id: version.id },
|
||||
data: { envelope },
|
||||
});
|
||||
const corruptProvider = await settings.provider("openrouter", { projectId: "project-a" });
|
||||
await expect(corruptProvider.openAgentLease({ runId: "run-corrupt" })).rejects.toThrow(
|
||||
"secret envelope authentication failed",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,10 @@
|
||||
import { createServer } from "node:net";
|
||||
import { once } from "node:events";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { startHub } from "../../src/hub.js";
|
||||
import { TEST_DATABASE_URL } from "./helpers.js";
|
||||
import { resetDb, TEST_DATABASE_URL, TEST_SECRET_KEY, TEST_SECRET_KEY_ID } from "./helpers.js";
|
||||
|
||||
const ENV_KEYS = [
|
||||
"DATABASE_URL",
|
||||
@@ -15,6 +17,7 @@ const ENV_KEYS = [
|
||||
"HUB_SESSION_SECRET",
|
||||
"HUB_PUBLIC_BASE_URL",
|
||||
"HUB_FEISHU_LISTENER_ENABLED",
|
||||
"HUB_SECRET_KEYRING_FILE",
|
||||
"HOST",
|
||||
"PORT",
|
||||
] as const;
|
||||
@@ -30,16 +33,23 @@ describe("Hub startup", () => {
|
||||
});
|
||||
|
||||
it("rejects startup before the Feishu listener when the HTTP bind fails", async () => {
|
||||
await resetDb();
|
||||
const blocker = createServer();
|
||||
blocker.listen(0, "127.0.0.1");
|
||||
await once(blocker, "listening");
|
||||
const address = blocker.address();
|
||||
if (address === null || typeof address === "string") throw new Error("expected an INET test listener");
|
||||
|
||||
const keyringDirectory = await mkdtemp(join(tmpdir(), "cph-hub-start-keyring-"));
|
||||
const keyringFile = join(keyringDirectory, "keyring.json");
|
||||
await writeFile(keyringFile, JSON.stringify({
|
||||
version: 1,
|
||||
activeKeyId: TEST_SECRET_KEY_ID,
|
||||
keys: { [TEST_SECRET_KEY_ID]: TEST_SECRET_KEY.toString("base64") },
|
||||
}), { mode: 0o600 });
|
||||
|
||||
Object.assign(process.env, {
|
||||
DATABASE_URL: TEST_DATABASE_URL,
|
||||
ANTHROPIC_AUTH_TOKEN: "test-provider-token",
|
||||
ANTHROPIC_API_KEY: "",
|
||||
FEISHU_APP_ID: "test-app",
|
||||
FEISHU_APP_SECRET: "test-secret",
|
||||
FEISHU_BOT_OPEN_ID: "ou_test_bot",
|
||||
@@ -47,15 +57,18 @@ describe("Hub startup", () => {
|
||||
HUB_SESSION_SECRET: "integration-session-secret-with-32-bytes",
|
||||
HUB_PUBLIC_BASE_URL: "https://hub.example.test",
|
||||
HUB_FEISHU_LISTENER_ENABLED: "false",
|
||||
HUB_SECRET_KEYRING_FILE: keyringFile,
|
||||
HOST: "127.0.0.1",
|
||||
PORT: String(address.port),
|
||||
});
|
||||
|
||||
try {
|
||||
const { startHub } = await import("../../src/hub.js");
|
||||
await expect(startHub()).rejects.toMatchObject({ code: "EADDRINUSE" });
|
||||
} finally {
|
||||
blocker.close();
|
||||
await once(blocker, "close");
|
||||
await rm(keyringDirectory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,13 +30,16 @@ function makeTestSettings(models: InMemoryModelRegistry): RuntimeSettings {
|
||||
async provider(providerId) {
|
||||
return {
|
||||
id: providerId,
|
||||
baseUrl: "https://openrouter.ai/api",
|
||||
authToken: "test-token",
|
||||
anthropicApiKey: "",
|
||||
sdkEnv: {
|
||||
ANTHROPIC_BASE_URL: "https://openrouter.ai/api",
|
||||
ANTHROPIC_AUTH_TOKEN: "test-token",
|
||||
ANTHROPIC_API_KEY: "",
|
||||
async openAgentLease() {
|
||||
return {
|
||||
sdkEnv: {
|
||||
ANTHROPIC_BASE_URL: "http://127.0.0.1:12345",
|
||||
ANTHROPIC_AUTH_TOKEN: "test-run-capability",
|
||||
ANTHROPIC_API_KEY: "",
|
||||
},
|
||||
sensitiveValues: ["test-run-capability"],
|
||||
async close() {},
|
||||
};
|
||||
},
|
||||
};
|
||||
},
|
||||
@@ -153,9 +156,9 @@ describe("trigger full lifecycle (integration)", () => {
|
||||
});
|
||||
expect(rt.sentTexts.some((text) => text.includes("mock response") && text.includes("本次成本: $0.0023"))).toBe(true);
|
||||
expect(runAgentCalls).toHaveLength(1);
|
||||
expect(runAgentCalls[0]?.providerEnv).toMatchObject({
|
||||
ANTHROPIC_BASE_URL: "https://openrouter.ai/api",
|
||||
ANTHROPIC_AUTH_TOKEN: "test-token",
|
||||
expect(runAgentCalls[0]?.providerProxyEnv).toMatchObject({
|
||||
ANTHROPIC_BASE_URL: "http://127.0.0.1:12345",
|
||||
ANTHROPIC_AUTH_TOKEN: "test-run-capability",
|
||||
ANTHROPIC_API_KEY: "",
|
||||
});
|
||||
expect(runAgentCalls[0]?.maxTurns).toBe(7);
|
||||
|
||||
@@ -11,14 +11,14 @@ describe("agent subprocess security policy", () => {
|
||||
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
it("passes only provider and safe runtime variables and protects provider credentials from tools", async () => {
|
||||
it("passes only the run proxy capability and safe runtime variables and protects the capability from tools", async () => {
|
||||
const { workspaceRoot, workspace } = await makeWorkspace();
|
||||
const policy = await createAgentSecurityPolicy({
|
||||
workspaceRoot,
|
||||
workspaceDir: workspace,
|
||||
providerEnv: {
|
||||
ANTHROPIC_BASE_URL: "https://openrouter.ai/api",
|
||||
ANTHROPIC_AUTH_TOKEN: "provider-secret",
|
||||
providerProxyEnv: {
|
||||
ANTHROPIC_BASE_URL: "http://127.0.0.1:43123",
|
||||
ANTHROPIC_AUTH_TOKEN: "run-proxy-capability",
|
||||
ANTHROPIC_API_KEY: "",
|
||||
},
|
||||
hostEnv: {
|
||||
@@ -37,8 +37,8 @@ describe("agent subprocess security policy", () => {
|
||||
PATH: "/usr/local/bin:/usr/bin:/bin",
|
||||
LANG: "C.UTF-8",
|
||||
CPH_BIN: "/usr/local/bin/cph",
|
||||
ANTHROPIC_BASE_URL: "https://openrouter.ai/api",
|
||||
ANTHROPIC_AUTH_TOKEN: "provider-secret",
|
||||
ANTHROPIC_BASE_URL: "http://127.0.0.1:43123",
|
||||
ANTHROPIC_AUTH_TOKEN: "run-proxy-capability",
|
||||
ANTHROPIC_API_KEY: "",
|
||||
});
|
||||
expect(policy.env).not.toHaveProperty("DATABASE_URL");
|
||||
@@ -73,8 +73,8 @@ describe("agent subprocess security policy", () => {
|
||||
await expect(createAgentSecurityPolicy({
|
||||
workspaceRoot,
|
||||
workspaceDir: workspace,
|
||||
providerEnv: {
|
||||
ANTHROPIC_AUTH_TOKEN: "provider-secret",
|
||||
providerProxyEnv: {
|
||||
ANTHROPIC_AUTH_TOKEN: "run-proxy-capability",
|
||||
DATABASE_URL: "must-not-cross-boundary",
|
||||
},
|
||||
hostEnv: { PATH: "/usr/bin:/bin" },
|
||||
@@ -109,7 +109,7 @@ describe("agent subprocess security policy", () => {
|
||||
await expect(createAgentSecurityPolicy({
|
||||
workspaceRoot,
|
||||
workspaceDir: linked,
|
||||
providerEnv: { ANTHROPIC_AUTH_TOKEN: "provider-secret" },
|
||||
providerProxyEnv: { ANTHROPIC_AUTH_TOKEN: "run-proxy-capability" },
|
||||
hostEnv: { PATH: "/usr/bin:/bin" },
|
||||
})).rejects.toThrow("project workspace contains a symlink");
|
||||
});
|
||||
@@ -124,7 +124,7 @@ describe("agent subprocess security policy", () => {
|
||||
await expect(createAgentSecurityPolicy({
|
||||
workspaceRoot,
|
||||
workspaceDir: linked,
|
||||
providerEnv: { ANTHROPIC_AUTH_TOKEN: "provider-secret" },
|
||||
providerProxyEnv: { ANTHROPIC_AUTH_TOKEN: "run-proxy-capability" },
|
||||
hostEnv: { PATH: "/usr/bin:/bin" },
|
||||
})).rejects.toThrow("symlink");
|
||||
});
|
||||
|
||||
@@ -8,9 +8,6 @@ import {
|
||||
const VALID_ENV = {
|
||||
NODE_ENV: "production",
|
||||
DATABASE_URL: "postgresql://hub:secret@127.0.0.1:5432/hub",
|
||||
ANTHROPIC_BASE_URL: "https://openrouter.ai/api",
|
||||
ANTHROPIC_AUTH_TOKEN: "provider-token",
|
||||
ANTHROPIC_API_KEY: "",
|
||||
FEISHU_APP_ID: "cli_app_id",
|
||||
FEISHU_APP_SECRET: "feishu-app-secret",
|
||||
FEISHU_BOT_OPEN_ID: "ou_bot",
|
||||
@@ -82,7 +79,6 @@ describe("validateDeploymentPreflight", () => {
|
||||
input({
|
||||
env: {
|
||||
...VALID_ENV,
|
||||
ANTHROPIC_AUTH_TOKEN: "",
|
||||
FEISHU_APP_SECRET: "",
|
||||
HUB_PUBLIC_BASE_URL: "http://hub.example.com",
|
||||
HUB_SESSION_SECRET: "short",
|
||||
@@ -93,7 +89,6 @@ describe("validateDeploymentPreflight", () => {
|
||||
expect(error).toBeInstanceOf(DeploymentPreflightError);
|
||||
expect((error as Error).message).toMatchInlineSnapshot(`
|
||||
"deployment preflight failed:
|
||||
- ANTHROPIC_AUTH_TOKEN is required
|
||||
- FEISHU_APP_SECRET is required
|
||||
- HUB_PUBLIC_BASE_URL must use https
|
||||
- HUB_SESSION_SECRET must contain at least 32 characters"
|
||||
@@ -107,11 +102,11 @@ describe("validateDeploymentPreflight", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a non-empty ANTHROPIC_API_KEY that would conflict with auth-token mode", () => {
|
||||
it("rejects legacy process-global provider settings instead of silently ignoring them", () => {
|
||||
expect(() =>
|
||||
validateDeploymentPreflight(
|
||||
input({ env: { ...VALID_ENV, ANTHROPIC_API_KEY: "conflicting-api-key" } }),
|
||||
input({ env: { ...VALID_ENV, ANTHROPIC_AUTH_TOKEN: "global-secret" } }),
|
||||
),
|
||||
).toThrow("ANTHROPIC_API_KEY must be present and empty");
|
||||
).toThrow("ANTHROPIC_AUTH_TOKEN must not be set");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -212,10 +212,13 @@ function mockSettings(): RuntimeSettings {
|
||||
async provider(providerId) {
|
||||
return {
|
||||
id: providerId,
|
||||
baseUrl: "https://example.invalid",
|
||||
authToken: "test-token",
|
||||
anthropicApiKey: "",
|
||||
sdkEnv: {},
|
||||
async openAgentLease() {
|
||||
return {
|
||||
sdkEnv: {},
|
||||
sensitiveValues: [],
|
||||
async close() {},
|
||||
};
|
||||
},
|
||||
};
|
||||
},
|
||||
async modelRegistry() {
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { createServer } from "node:http";
|
||||
import { once } from "node:events";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { openProviderProxyLease, type AgentProviderLease } from "../../src/connections/providerProxy.js";
|
||||
|
||||
const leases: AgentProviderLease[] = [];
|
||||
const upstreamServers: ReturnType<typeof createServer>[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(leases.splice(0).map((lease) => lease.close()));
|
||||
await Promise.all(upstreamServers.splice(0).map(async (server) => {
|
||||
server.closeAllConnections();
|
||||
if (server.listening) {
|
||||
server.close();
|
||||
await once(server, "close");
|
||||
}
|
||||
}));
|
||||
});
|
||||
|
||||
describe("run-scoped provider proxy", () => {
|
||||
it("keeps the Organization provider credential out of the Agent environment", async () => {
|
||||
const observed: Array<{ authorization: string | undefined; apiKey: string | undefined; body: string }> = [];
|
||||
const upstream = createServer((request, response) => {
|
||||
const chunks: Buffer[] = [];
|
||||
request.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||
request.on("end", () => {
|
||||
observed.push({
|
||||
authorization: request.headers.authorization,
|
||||
apiKey: header(request.headers["x-api-key"]),
|
||||
body: Buffer.concat(chunks).toString("utf8"),
|
||||
});
|
||||
response.writeHead(200, { "content-type": "application/json" });
|
||||
response.end(JSON.stringify({ ok: true }));
|
||||
});
|
||||
});
|
||||
upstreamServers.push(upstream);
|
||||
upstream.listen(0, "127.0.0.1");
|
||||
await once(upstream, "listening");
|
||||
const address = upstream.address();
|
||||
if (address === null || typeof address === "string") throw new Error("expected upstream TCP address");
|
||||
|
||||
const lease = await openProviderProxyLease({
|
||||
baseUrl: `http://127.0.0.1:${address.port}/api`,
|
||||
authToken: "customer-provider-auth-token",
|
||||
anthropicApiKey: "customer-provider-api-key",
|
||||
});
|
||||
leases.push(lease);
|
||||
|
||||
const serializedAgentEnv = JSON.stringify(lease.sdkEnv);
|
||||
expect(serializedAgentEnv).not.toContain("customer-provider-auth-token");
|
||||
expect(serializedAgentEnv).not.toContain("customer-provider-api-key");
|
||||
expect(serializedAgentEnv).not.toContain(String(address.port));
|
||||
expect(lease.sdkEnv).toMatchObject({
|
||||
ANTHROPIC_BASE_URL: expect.stringMatching(/^http:\/\/127\.0\.0\.1:\d+$/),
|
||||
ANTHROPIC_AUTH_TOKEN: expect.any(String),
|
||||
ANTHROPIC_API_KEY: "",
|
||||
});
|
||||
|
||||
const response = await fetch(`${lease.sdkEnv.ANTHROPIC_BASE_URL}/v1/messages`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: `Bearer ${lease.sdkEnv.ANTHROPIC_AUTH_TOKEN}`,
|
||||
"content-type": "application/json",
|
||||
"anthropic-version": "2023-06-01",
|
||||
},
|
||||
body: JSON.stringify({ model: "test" }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
await expect(response.json()).resolves.toEqual({ ok: true });
|
||||
expect(observed).toEqual([{
|
||||
authorization: "Bearer customer-provider-auth-token",
|
||||
apiKey: "customer-provider-api-key",
|
||||
body: JSON.stringify({ model: "test" }),
|
||||
}]);
|
||||
});
|
||||
|
||||
it("rejects requests without the run capability before contacting upstream", async () => {
|
||||
let upstreamRequests = 0;
|
||||
const diagnostics: unknown[] = [];
|
||||
const upstream = createServer((_request, response) => {
|
||||
upstreamRequests += 1;
|
||||
response.end("unexpected");
|
||||
});
|
||||
upstreamServers.push(upstream);
|
||||
upstream.listen(0, "127.0.0.1");
|
||||
await once(upstream, "listening");
|
||||
const address = upstream.address();
|
||||
if (address === null || typeof address === "string") throw new Error("expected upstream TCP address");
|
||||
const lease = await openProviderProxyLease(
|
||||
{
|
||||
baseUrl: `http://127.0.0.1:${address.port}`,
|
||||
authToken: "customer-secret",
|
||||
anthropicApiKey: "",
|
||||
},
|
||||
{ onDiagnostic: (diagnostic) => diagnostics.push(diagnostic) },
|
||||
);
|
||||
leases.push(lease);
|
||||
|
||||
const response = await fetch(`${lease.sdkEnv.ANTHROPIC_BASE_URL}/v1/messages`, {
|
||||
method: "POST",
|
||||
body: "{}",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(upstreamRequests).toBe(0);
|
||||
expect(diagnostics).toEqual([{ code: "provider_proxy_unauthorized", category: "authorization" }]);
|
||||
});
|
||||
|
||||
it("does not forward provider credentials across an upstream redirect", async () => {
|
||||
let redirectedRequests = 0;
|
||||
const diagnostics: unknown[] = [];
|
||||
const redirectTarget = createServer((_request, response) => {
|
||||
redirectedRequests += 1;
|
||||
response.end("leaked");
|
||||
});
|
||||
upstreamServers.push(redirectTarget);
|
||||
redirectTarget.listen(0, "127.0.0.1");
|
||||
await once(redirectTarget, "listening");
|
||||
const targetAddress = redirectTarget.address();
|
||||
if (targetAddress === null || typeof targetAddress === "string") throw new Error("expected redirect target address");
|
||||
|
||||
const upstream = createServer((_request, response) => {
|
||||
response.writeHead(307, { location: `http://127.0.0.1:${targetAddress.port}/capture` });
|
||||
response.end();
|
||||
});
|
||||
upstreamServers.push(upstream);
|
||||
upstream.listen(0, "127.0.0.1");
|
||||
await once(upstream, "listening");
|
||||
const upstreamAddress = upstream.address();
|
||||
if (upstreamAddress === null || typeof upstreamAddress === "string") throw new Error("expected upstream address");
|
||||
const lease = await openProviderProxyLease(
|
||||
{
|
||||
baseUrl: `http://127.0.0.1:${upstreamAddress.port}`,
|
||||
authToken: "customer-secret",
|
||||
anthropicApiKey: "",
|
||||
},
|
||||
{ onDiagnostic: (diagnostic) => diagnostics.push(diagnostic) },
|
||||
);
|
||||
leases.push(lease);
|
||||
|
||||
const response = await fetch(`${lease.sdkEnv.ANTHROPIC_BASE_URL}/v1/messages`, {
|
||||
headers: { authorization: `Bearer ${lease.sdkEnv.ANTHROPIC_AUTH_TOKEN}` },
|
||||
redirect: "manual",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(502);
|
||||
await expect(response.text()).resolves.toBe("provider redirect refused");
|
||||
expect(redirectedRequests).toBe(0);
|
||||
expect(diagnostics).toEqual([{ code: "provider_proxy_redirect_refused", category: "redirect" }]);
|
||||
});
|
||||
});
|
||||
|
||||
function header(value: string | string[] | undefined): string | undefined {
|
||||
return Array.isArray(value) ? value[0] : value;
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { createServer } from "node:http";
|
||||
import { once } from "node:events";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { ProviderReadinessError, probeOpenRouterCredential } from "../../src/connections/providerReadiness.js";
|
||||
|
||||
const servers: ReturnType<typeof createServer>[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(servers.splice(0).map(async (server) => {
|
||||
server.closeAllConnections();
|
||||
if (server.listening) {
|
||||
server.close();
|
||||
await once(server, "close");
|
||||
}
|
||||
}));
|
||||
});
|
||||
|
||||
describe("provider credential readiness", () => {
|
||||
it("authenticates the OpenRouter key endpoint before activation", async () => {
|
||||
const requests: Array<{ url: string | undefined; authorization: string | undefined }> = [];
|
||||
const server = createServer((request, response) => {
|
||||
requests.push({ url: request.url, authorization: request.headers.authorization });
|
||||
response.writeHead(200, { "content-type": "application/json" });
|
||||
response.end('{"data":{"label":"test"}}');
|
||||
});
|
||||
servers.push(server);
|
||||
server.listen(0, "127.0.0.1");
|
||||
await once(server, "listening");
|
||||
const address = server.address();
|
||||
if (address === null || typeof address === "string") throw new Error("expected TCP address");
|
||||
|
||||
await expect(probeOpenRouterCredential({
|
||||
providerId: "openrouter",
|
||||
baseUrl: `http://127.0.0.1:${address.port}/api`,
|
||||
authToken: "readiness-secret",
|
||||
anthropicApiKey: "",
|
||||
})).resolves.toBeUndefined();
|
||||
expect(requests).toEqual([{
|
||||
url: "/api/v1/key",
|
||||
authorization: "Bearer readiness-secret",
|
||||
}]);
|
||||
});
|
||||
|
||||
it("fails closed on rejected credentials without exposing them in the error", async () => {
|
||||
const server = createServer((_request, response) => {
|
||||
response.writeHead(401, { "content-type": "application/json" });
|
||||
response.end('{"error":"token rejected"}');
|
||||
});
|
||||
servers.push(server);
|
||||
server.listen(0, "127.0.0.1");
|
||||
await once(server, "listening");
|
||||
const address = server.address();
|
||||
if (address === null || typeof address === "string") throw new Error("expected TCP address");
|
||||
|
||||
let message = "";
|
||||
try {
|
||||
await probeOpenRouterCredential({
|
||||
providerId: "openrouter",
|
||||
baseUrl: `http://127.0.0.1:${address.port}`,
|
||||
authToken: "never-return-readiness-secret",
|
||||
anthropicApiKey: "",
|
||||
});
|
||||
} catch (error) {
|
||||
message = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
expect(message).toBe("provider credential readiness check failed: status 401");
|
||||
expect(message).not.toContain("never-return-readiness-secret");
|
||||
});
|
||||
|
||||
it("does not follow redirects with the provider credential", async () => {
|
||||
let redirected = 0;
|
||||
const target = createServer((_request, response) => {
|
||||
redirected += 1;
|
||||
response.end("leaked");
|
||||
});
|
||||
servers.push(target);
|
||||
target.listen(0, "127.0.0.1");
|
||||
await once(target, "listening");
|
||||
const targetAddress = target.address();
|
||||
if (targetAddress === null || typeof targetAddress === "string") throw new Error("expected target address");
|
||||
const provider = createServer((_request, response) => {
|
||||
response.writeHead(307, { location: `http://127.0.0.1:${targetAddress.port}/capture` });
|
||||
response.end();
|
||||
});
|
||||
servers.push(provider);
|
||||
provider.listen(0, "127.0.0.1");
|
||||
await once(provider, "listening");
|
||||
const providerAddress = provider.address();
|
||||
if (providerAddress === null || typeof providerAddress === "string") throw new Error("expected provider address");
|
||||
|
||||
await expect(probeOpenRouterCredential({
|
||||
providerId: "openrouter",
|
||||
baseUrl: `http://127.0.0.1:${providerAddress.port}`,
|
||||
authToken: "redirect-secret",
|
||||
anthropicApiKey: "",
|
||||
})).rejects.toThrow("status 307");
|
||||
expect(redirected).toBe(0);
|
||||
});
|
||||
|
||||
it("preserves a redacted network category for observability", async () => {
|
||||
const server = createServer();
|
||||
server.listen(0, "127.0.0.1");
|
||||
await once(server, "listening");
|
||||
const address = server.address();
|
||||
if (address === null || typeof address === "string") throw new Error("expected provider address");
|
||||
server.close();
|
||||
await once(server, "close");
|
||||
|
||||
let failure: unknown;
|
||||
try {
|
||||
await probeOpenRouterCredential({
|
||||
providerId: "openrouter",
|
||||
baseUrl: `http://127.0.0.1:${address.port}`,
|
||||
authToken: "network-secret",
|
||||
anthropicApiKey: "",
|
||||
});
|
||||
} catch (error) {
|
||||
failure = error;
|
||||
}
|
||||
expect(failure).toBeInstanceOf(ProviderReadinessError);
|
||||
expect(failure).toMatchObject({
|
||||
code: "provider_readiness_unreachable",
|
||||
category: "connection",
|
||||
message: "provider credential readiness check could not reach provider",
|
||||
});
|
||||
expect(JSON.stringify(failure)).not.toContain("network-secret");
|
||||
});
|
||||
});
|
||||
@@ -194,7 +194,7 @@ describe("runAgent", () => {
|
||||
expect(result.costUsd).toBe(0.0042);
|
||||
});
|
||||
|
||||
it("passes only provider and safe runtime env without mutating process env", async () => {
|
||||
it("passes only the loopback provider proxy capability and safe runtime env without mutating process env", async () => {
|
||||
process.env["DATABASE_URL"] = "postgresql://platform-secret";
|
||||
process.env["FEISHU_APP_SECRET"] = "feishu-secret";
|
||||
process.env["HUB_SESSION_SECRET"] = "session-secret";
|
||||
@@ -205,9 +205,9 @@ describe("runAgent", () => {
|
||||
model: undefined,
|
||||
project: { projectId: "p", boundChatId: "c", workspaceRoot, workspaceDir: workspace },
|
||||
systemPrompt: undefined,
|
||||
providerEnv: {
|
||||
ANTHROPIC_BASE_URL: "https://openrouter.ai/api",
|
||||
ANTHROPIC_AUTH_TOKEN: "test-token",
|
||||
providerProxyEnv: {
|
||||
ANTHROPIC_BASE_URL: "http://127.0.0.1:43123",
|
||||
ANTHROPIC_AUTH_TOKEN: "run-proxy-capability",
|
||||
ANTHROPIC_API_KEY: "",
|
||||
},
|
||||
runId: "run-1",
|
||||
@@ -219,8 +219,8 @@ describe("runAgent", () => {
|
||||
expect(call).toMatchObject({
|
||||
options: {
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: "https://openrouter.ai/api",
|
||||
ANTHROPIC_AUTH_TOKEN: "test-token",
|
||||
ANTHROPIC_BASE_URL: "http://127.0.0.1:43123",
|
||||
ANTHROPIC_AUTH_TOKEN: "run-proxy-capability",
|
||||
ANTHROPIC_API_KEY: "",
|
||||
},
|
||||
sandbox: {
|
||||
|
||||
@@ -15,44 +15,16 @@ describe("runtime settings", () => {
|
||||
expect((await settings.modelRegistry()).resolve(undefined, "draft")).toBe("z-ai/glm-4.7");
|
||||
});
|
||||
|
||||
it("returns provider SDK env without requiring global process env mutation", async () => {
|
||||
it("refuses process-global provider credentials even when they are present", async () => {
|
||||
const settings = createEnvRuntimeSettings({
|
||||
ANTHROPIC_BASE_URL: "https://example.test/api",
|
||||
ANTHROPIC_AUTH_TOKEN: "openrouter-token",
|
||||
ANTHROPIC_API_KEY: "explicit-api-key",
|
||||
});
|
||||
|
||||
await expect(settings.provider("openrouter")).resolves.toMatchObject({
|
||||
id: "openrouter",
|
||||
baseUrl: "https://example.test/api",
|
||||
authToken: "openrouter-token",
|
||||
anthropicApiKey: "explicit-api-key",
|
||||
sdkEnv: {
|
||||
ANTHROPIC_BASE_URL: "https://example.test/api",
|
||||
ANTHROPIC_AUTH_TOKEN: "openrouter-token",
|
||||
ANTHROPIC_API_KEY: "explicit-api-key",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults Anthropic API key to empty for OpenRouter's Anthropic skin", async () => {
|
||||
const settings = createEnvRuntimeSettings({
|
||||
ANTHROPIC_AUTH_TOKEN: "openrouter-token",
|
||||
});
|
||||
|
||||
await expect(settings.provider("openrouter")).resolves.toMatchObject({
|
||||
baseUrl: "https://openrouter.ai/api",
|
||||
anthropicApiKey: "",
|
||||
sdkEnv: {
|
||||
ANTHROPIC_API_KEY: "",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("requires an auth token for the OpenRouter provider", async () => {
|
||||
const settings = createEnvRuntimeSettings({});
|
||||
|
||||
await expect(settings.provider("openrouter")).rejects.toThrow("missing required runtime setting: ANTHROPIC_AUTH_TOKEN");
|
||||
await expect(settings.provider("openrouter")).rejects.toThrow(
|
||||
"process-global provider credentials are disabled",
|
||||
);
|
||||
});
|
||||
|
||||
it("honors HUB_AGENT_MAX_TURNS as a runtime run policy", async () => {
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
LocalSecretEnvelope,
|
||||
loadLocalSecretKeyring,
|
||||
type SecretBinding,
|
||||
} from "../../src/security/secretEnvelope.js";
|
||||
|
||||
const createdDirectories: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(createdDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
describe("local secret envelope", () => {
|
||||
it("encrypts with a per-secret DEK and authenticates the complete binding", async () => {
|
||||
const keyring = await keyringFile({ active: key("a"), previous: key("b") }, "active");
|
||||
const secrets = new LocalSecretEnvelope(await loadLocalSecretKeyring({
|
||||
NODE_ENV: "test",
|
||||
HUB_SECRET_KEYRING_FILE: keyring,
|
||||
}));
|
||||
const binding = secretBinding();
|
||||
const plaintext = {
|
||||
schemaVersion: 1,
|
||||
baseUrl: "https://provider.example/api",
|
||||
authToken: "org-a-secret-token",
|
||||
anthropicApiKey: "org-a-api-key",
|
||||
};
|
||||
|
||||
const envelope = secrets.encryptJson(binding, plaintext);
|
||||
|
||||
expect(envelope).toMatchObject({
|
||||
version: 1,
|
||||
algorithm: "AES-256-GCM",
|
||||
keyId: "active",
|
||||
});
|
||||
expect(JSON.stringify(envelope)).not.toContain(plaintext.authToken);
|
||||
expect(envelope.wrappedDek.ciphertext).not.toBe(envelope.payload.ciphertext);
|
||||
expect(secrets.decryptJson(binding, envelope)).toEqual(plaintext);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["organization", { organizationId: "org-b" }],
|
||||
["connection", { connectionId: "connection-b" }],
|
||||
["version", { secretVersionId: "secret-version-b" }],
|
||||
["purpose", { purpose: "feishu-application" }],
|
||||
])("rejects ciphertext substituted across %s scope", async (_label, changed) => {
|
||||
const keyring = await keyringFile({ active: key("a") }, "active");
|
||||
const secrets = new LocalSecretEnvelope(await loadLocalSecretKeyring({
|
||||
NODE_ENV: "test",
|
||||
HUB_SECRET_KEYRING_FILE: keyring,
|
||||
}));
|
||||
const binding = secretBinding();
|
||||
const envelope = secrets.encryptJson(binding, { token: "never-log-this-token" });
|
||||
|
||||
expect(() => secrets.decryptJson({ ...binding, ...changed }, envelope)).toThrow(
|
||||
"secret envelope authentication failed",
|
||||
);
|
||||
});
|
||||
|
||||
it("fails closed for a missing KEK or corrupted ciphertext without including plaintext", async () => {
|
||||
const encryptingKeyring = await keyringFile({ encrypting: key("a") }, "encrypting");
|
||||
const decryptingKeyring = await keyringFile({ other: key("b") }, "other");
|
||||
const encrypting = new LocalSecretEnvelope(await loadLocalSecretKeyring({
|
||||
NODE_ENV: "test",
|
||||
HUB_SECRET_KEYRING_FILE: encryptingKeyring,
|
||||
}));
|
||||
const decrypting = new LocalSecretEnvelope(await loadLocalSecretKeyring({
|
||||
NODE_ENV: "test",
|
||||
HUB_SECRET_KEYRING_FILE: decryptingKeyring,
|
||||
}));
|
||||
const binding = secretBinding();
|
||||
const envelope = encrypting.encryptJson(binding, { token: "never-log-this-token" });
|
||||
|
||||
expect(() => decrypting.decryptJson(binding, envelope)).toThrow("secret envelope key is unavailable");
|
||||
|
||||
const corrupted = structuredClone(envelope);
|
||||
corrupted.payload.ciphertext = flipFirstByte(corrupted.payload.ciphertext);
|
||||
let message = "";
|
||||
try {
|
||||
encrypting.decryptJson(binding, corrupted);
|
||||
} catch (error) {
|
||||
message = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
expect(message).toBe("secret envelope authentication failed");
|
||||
expect(message).not.toContain("never-log-this-token");
|
||||
});
|
||||
|
||||
it("rewraps only the DEK when the active KEK rotates", async () => {
|
||||
const firstFile = await keyringFile({ old: key("a") }, "old");
|
||||
const rotatedFile = await keyringFile({ old: key("a"), current: key("c") }, "current");
|
||||
const first = new LocalSecretEnvelope(await loadLocalSecretKeyring({
|
||||
NODE_ENV: "test",
|
||||
HUB_SECRET_KEYRING_FILE: firstFile,
|
||||
}));
|
||||
const rotated = new LocalSecretEnvelope(await loadLocalSecretKeyring({
|
||||
NODE_ENV: "test",
|
||||
HUB_SECRET_KEYRING_FILE: rotatedFile,
|
||||
}));
|
||||
const binding = secretBinding();
|
||||
const envelope = first.encryptJson(binding, { token: "rotatable" });
|
||||
|
||||
const rewrapped = rotated.rewrap(binding, envelope);
|
||||
|
||||
expect(rewrapped.keyId).toBe("current");
|
||||
expect(rewrapped.payload).toEqual(envelope.payload);
|
||||
expect(rewrapped.wrappedDek).not.toEqual(envelope.wrappedDek);
|
||||
expect(rotated.decryptJson(binding, rewrapped)).toEqual({ token: "rotatable" });
|
||||
});
|
||||
|
||||
it("loads production keys only from the fixed systemd credential", async () => {
|
||||
const credentialsDirectory = await temporaryDirectory();
|
||||
await writeKeyring(path.join(credentialsDirectory, "cph-secret-keyring"), { active: key("a") }, "active");
|
||||
|
||||
await expect(loadLocalSecretKeyring({
|
||||
NODE_ENV: "production",
|
||||
CREDENTIALS_DIRECTORY: credentialsDirectory,
|
||||
})).resolves.toMatchObject({ activeKeyId: "active" });
|
||||
|
||||
await expect(loadLocalSecretKeyring({
|
||||
NODE_ENV: "production",
|
||||
CREDENTIALS_DIRECTORY: credentialsDirectory,
|
||||
HUB_SECRET_KEYRING_FILE: "/tmp/untrusted-keyring.json",
|
||||
})).rejects.toThrow("production secret keyring must use the systemd credential");
|
||||
});
|
||||
|
||||
it("rejects absent, malformed, or incomplete keyrings", async () => {
|
||||
await expect(loadLocalSecretKeyring({ NODE_ENV: "test" })).rejects.toThrow(
|
||||
"missing required secret keyring setting",
|
||||
);
|
||||
|
||||
const directory = await temporaryDirectory();
|
||||
const malformed = path.join(directory, "malformed.json");
|
||||
await writeFile(malformed, "not json", "utf8");
|
||||
await expect(loadLocalSecretKeyring({
|
||||
NODE_ENV: "test",
|
||||
HUB_SECRET_KEYRING_FILE: malformed,
|
||||
})).rejects.toThrow("invalid local secret keyring");
|
||||
|
||||
const missingActive = await keyringFile({ old: key("a") }, "current");
|
||||
await expect(loadLocalSecretKeyring({
|
||||
NODE_ENV: "test",
|
||||
HUB_SECRET_KEYRING_FILE: missingActive,
|
||||
})).rejects.toThrow("active key is unavailable");
|
||||
});
|
||||
});
|
||||
|
||||
function secretBinding(): SecretBinding {
|
||||
return {
|
||||
purpose: "provider-connection",
|
||||
organizationId: "org-a",
|
||||
connectionId: "connection-a",
|
||||
secretVersionId: "secret-version-a",
|
||||
};
|
||||
}
|
||||
|
||||
function key(character: string): string {
|
||||
return Buffer.alloc(32, character).toString("base64");
|
||||
}
|
||||
|
||||
async function keyringFile(keys: Readonly<Record<string, string>>, activeKeyId: string): Promise<string> {
|
||||
const directory = await temporaryDirectory();
|
||||
const file = path.join(directory, "keyring.json");
|
||||
await writeKeyring(file, keys, activeKeyId);
|
||||
return file;
|
||||
}
|
||||
|
||||
async function temporaryDirectory(): Promise<string> {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "cph-secret-envelope-"));
|
||||
createdDirectories.push(directory);
|
||||
return directory;
|
||||
}
|
||||
|
||||
async function writeKeyring(
|
||||
file: string,
|
||||
keys: Readonly<Record<string, string>>,
|
||||
activeKeyId: string,
|
||||
): Promise<void> {
|
||||
await writeFile(file, JSON.stringify({ version: 1, activeKeyId, keys }), { encoding: "utf8", mode: 0o600 });
|
||||
}
|
||||
|
||||
function flipFirstByte(value: string): string {
|
||||
const bytes = Buffer.from(value, "base64");
|
||||
bytes[0] = (bytes[0] ?? 0) ^ 1;
|
||||
return bytes.toString("base64");
|
||||
}
|
||||
Reference in New Issue
Block a user