102 lines
5.6 KiB
TypeScript
102 lines
5.6 KiB
TypeScript
import { randomBytes } from "node:crypto";
|
|
import { mkdtemp, readFile, stat } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import path from "node:path";
|
|
import { describe, expect, it } from "vitest";
|
|
import { EncryptedFileCredentialVault, InMemoryModelConnectionStore, modelCredentialIdentity } from "../src/index.js";
|
|
|
|
const model = {
|
|
providerId: "training",
|
|
api: "openai-responses" as const,
|
|
baseUrl: "https://8.8.8.8/v1",
|
|
modelId: "training-model",
|
|
supportsImages: true,
|
|
};
|
|
|
|
describe("EncryptedFileCredentialVault", () => {
|
|
it("persists only authenticated ciphertext and decrypts after restart", async () => {
|
|
const root = await mkdtemp(path.join(tmpdir(), "studio-vault-"));
|
|
const filePath = path.join(root, "credentials.enc.json");
|
|
const masterKey = randomBytes(32).toString("base64");
|
|
const identity = modelCredentialIdentity(model);
|
|
const first = new EncryptedFileCredentialVault(filePath, masterKey);
|
|
await first.set(identity, "secret-model-key");
|
|
const raw = await readFile(filePath, "utf8");
|
|
expect(raw).not.toContain("secret-model-key");
|
|
expect((await stat(filePath)).mode & 0o777).toBe(0o600);
|
|
const restarted = new EncryptedFileCredentialVault(filePath, masterKey);
|
|
await expect(restarted.get(identity)).resolves.toBe("secret-model-key");
|
|
});
|
|
|
|
it("restores a credential for a new Web Session and preserves it when the old Session is removed", async () => {
|
|
const root = await mkdtemp(path.join(tmpdir(), "studio-vault-"));
|
|
const vault = new EncryptedFileCredentialVault(path.join(root, "credentials.enc.json"), randomBytes(32).toString("base64"));
|
|
const first = new InMemoryModelConnectionStore({}, vault);
|
|
await first.configure("session-one", { ...model, apiKey: "persistent-secret" });
|
|
expect(first.getRedacted("session-one")).toMatchObject({ keyConfigured: true, credentialStorage: "encrypted" });
|
|
await first.remove("session-one");
|
|
const restarted = new InMemoryModelConnectionStore({}, vault);
|
|
await restarted.configure("session-two", model);
|
|
expect(restarted.getRedacted("session-two")).toMatchObject({ keyConfigured: true, keyHint: "••••cret", credentialStorage: "encrypted" });
|
|
});
|
|
|
|
it("deletes the persisted credential only through Clear Credentials", async () => {
|
|
const root = await mkdtemp(path.join(tmpdir(), "studio-vault-"));
|
|
const vault = new EncryptedFileCredentialVault(path.join(root, "credentials.enc.json"), randomBytes(32).toString("base64"));
|
|
const store = new InMemoryModelConnectionStore({}, vault);
|
|
await store.configure("session-one", { ...model, apiKey: "persistent-secret" });
|
|
await store.clearCredentials("session-one");
|
|
const restarted = new InMemoryModelConnectionStore({}, vault);
|
|
await restarted.configure("session-two", model);
|
|
expect(restarted.getRedacted("session-two")).toMatchObject({ keyConfigured: false });
|
|
});
|
|
|
|
it("fails closed for an invalid or different master key", async () => {
|
|
expect(() => new EncryptedFileCredentialVault("unused", "not-a-32-byte-key")).toThrow(/32-byte key/);
|
|
const root = await mkdtemp(path.join(tmpdir(), "studio-vault-"));
|
|
const filePath = path.join(root, "credentials.enc.json");
|
|
const identity = modelCredentialIdentity(model);
|
|
const first = new EncryptedFileCredentialVault(filePath, randomBytes(32).toString("base64"));
|
|
await first.set(identity, "secret-model-key");
|
|
const wrong = new EncryptedFileCredentialVault(filePath, randomBytes(32).toString("base64"));
|
|
await expect(wrong.get(identity)).rejects.toMatchObject({ code: "CREDENTIAL_DECRYPTION_FAILED" });
|
|
});
|
|
|
|
it("loads a strict environment default and restores its encrypted credential", async () => {
|
|
const root = await mkdtemp(path.join(tmpdir(), "studio-vault-"));
|
|
const vault = new EncryptedFileCredentialVault(path.join(root, "credentials.enc.json"), randomBytes(32).toString("base64"));
|
|
await vault.set(modelCredentialIdentity(model), "environment-default-secret");
|
|
const store = new InMemoryModelConnectionStore({
|
|
AGENT_PROVIDER: model.providerId,
|
|
AGENT_MODEL: model.modelId,
|
|
LLM_API: model.api,
|
|
LLM_BASE_URL: model.baseUrl,
|
|
AGENT_DISPLAY_NAME: "Training Model",
|
|
AGENT_SUPPORTS_IMAGES: "true",
|
|
}, vault);
|
|
await store.initialize();
|
|
expect(store.getRedacted("fresh-session")).toMatchObject({ source: "environment", modelId: model.modelId, displayName: "Training Model", supportsImages: true, keyConfigured: true, credentialStorage: "encrypted" });
|
|
});
|
|
|
|
it("uses an environment API key without returning the raw credential", async () => {
|
|
const store = new InMemoryModelConnectionStore({
|
|
AGENT_PROVIDER: model.providerId,
|
|
AGENT_MODEL: model.modelId,
|
|
LLM_API: model.api,
|
|
LLM_BASE_URL: model.baseUrl,
|
|
OPENAI_API_KEY: "environment-api-secret",
|
|
});
|
|
await store.initialize();
|
|
const redacted = store.getRedacted("fresh-session");
|
|
expect(redacted).toMatchObject({ source: "environment", keyConfigured: true, keyHint: "••••cret", credentialStorage: "environment" });
|
|
expect(JSON.stringify(redacted)).not.toContain("environment-api-secret");
|
|
});
|
|
|
|
it("fails startup for partial or malformed environment defaults", async () => {
|
|
const partial = new InMemoryModelConnectionStore({ AGENT_PROVIDER: "openai" });
|
|
await expect(partial.initialize()).rejects.toMatchObject({ code: "INVALID_ENV_MODEL_CONFIG" });
|
|
const malformed = new InMemoryModelConnectionStore({ AGENT_PROVIDER: "openai", AGENT_MODEL: "model", AGENT_SUPPORTS_IMAGES: "yes" });
|
|
await expect(malformed.initialize()).rejects.toMatchObject({ code: "INVALID_ENV_MODEL_CONFIG" });
|
|
});
|
|
});
|