NAS GUI upgrades that drop container environment variables could leave login working while AI and object-storage credentials could no longer be encrypted. Persist the encryption root on /data, generate one when an existing instance has no secret, and stop the add-service form from looking fillable when encryption is still unavailable. Fixes #370
154 lines
6.7 KiB
JavaScript
154 lines
6.7 KiB
JavaScript
import { describe, expect, test } from "bun:test";
|
|
import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import {
|
|
ensureSelfHostedCredentialSecrets,
|
|
hasSelfHostedCredentialSeed,
|
|
resolveSelfHostedSecretsPath,
|
|
SELF_HOSTED_SECRETS_FILE_NAME,
|
|
} from "./self-hosted-secrets.mjs";
|
|
import {
|
|
decryptAiCredential,
|
|
resolvePrimaryAiCredentialEncryptionKey,
|
|
} from "../apps/api/src/ai-service.ts";
|
|
import { encryptSecret } from "../apps/api/src/secret-encryption.ts";
|
|
|
|
const makeDataDirectory = async () => mkdtemp(`${tmpdir()}/edgeever-secrets-`);
|
|
|
|
describe("self-hosted credential secret persistence", () => {
|
|
test("names the secrets file beside SQLite, not inside it", () => {
|
|
expect(SELF_HOSTED_SECRETS_FILE_NAME).toBe("edgeever-secrets.json");
|
|
expect(resolveSelfHostedSecretsPath("/data")).toBe("/data/edgeever-secrets.json");
|
|
expect(resolveSelfHostedSecretsPath("/data")).not.toContain("sqlite");
|
|
});
|
|
|
|
test("persists the authentication secret and restores it after env vars disappear", async () => {
|
|
const dataDirectory = await makeDataDirectory();
|
|
try {
|
|
const first = await ensureSelfHostedCredentialSecrets(dataDirectory, {
|
|
EDGE_EVER_AUTH_PASSWORD: "instance-password",
|
|
});
|
|
const originalKey = resolvePrimaryAiCredentialEncryptionKey(first);
|
|
expect(originalKey).toBe("edgeever:ai-credentials:v1:instance-password");
|
|
expect(hasSelfHostedCredentialSeed(first)).toBe(true);
|
|
|
|
const stored = JSON.parse(await readFile(resolveSelfHostedSecretsPath(dataDirectory), "utf8"));
|
|
expect(stored).toEqual({
|
|
version: 1,
|
|
authPassword: "instance-password",
|
|
});
|
|
if (process.platform !== "win32") {
|
|
expect((await stat(resolveSelfHostedSecretsPath(dataDirectory))).mode & 0o777).toBe(0o600);
|
|
}
|
|
|
|
const restored = await ensureSelfHostedCredentialSecrets(dataDirectory, {});
|
|
expect(restored.EDGE_EVER_AUTH_PASSWORD).toBe("instance-password");
|
|
expect(resolvePrimaryAiCredentialEncryptionKey(restored)).toBe(originalKey);
|
|
|
|
const encrypted = await encryptSecret("provider-key", originalKey);
|
|
await expect(decryptAiCredential(encrypted, restored)).resolves.toBe("provider-key");
|
|
} finally {
|
|
await rm(dataDirectory, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("does not write a secrets file before the instance can start", async () => {
|
|
const dataDirectory = await makeDataDirectory();
|
|
try {
|
|
const environment = await ensureSelfHostedCredentialSecrets(dataDirectory, {});
|
|
expect(hasSelfHostedCredentialSeed(environment)).toBe(false);
|
|
await expect(readFile(resolveSelfHostedSecretsPath(dataDirectory), "utf8")).rejects.toMatchObject({
|
|
code: "ENOENT",
|
|
});
|
|
} finally {
|
|
await rm(dataDirectory, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("generates a dedicated credentials key when a running instance has no secret", async () => {
|
|
const dataDirectory = await makeDataDirectory();
|
|
try {
|
|
const environment = await ensureSelfHostedCredentialSecrets(dataDirectory, {}, { generateIfMissing: true });
|
|
expect(environment.EDGE_EVER_CREDENTIALS_ENCRYPTION_KEY).toMatch(/^[a-f0-9]{64}$/);
|
|
expect(resolvePrimaryAiCredentialEncryptionKey(environment)).toBe(
|
|
`edgeever:ai-credentials:v1:${environment.EDGE_EVER_CREDENTIALS_ENCRYPTION_KEY}`,
|
|
);
|
|
|
|
const second = await ensureSelfHostedCredentialSecrets(dataDirectory, {}, { generateIfMissing: true });
|
|
expect(second.EDGE_EVER_CREDENTIALS_ENCRYPTION_KEY).toBe(environment.EDGE_EVER_CREDENTIALS_ENCRYPTION_KEY);
|
|
} finally {
|
|
await rm(dataDirectory, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("keeps the original secret for decryption when environment values change", async () => {
|
|
const dataDirectory = await makeDataDirectory();
|
|
try {
|
|
await ensureSelfHostedCredentialSecrets(dataDirectory, {
|
|
EDGE_EVER_AUTH_PASSWORD: "original-password",
|
|
});
|
|
const rotated = await ensureSelfHostedCredentialSecrets(dataDirectory, {
|
|
EDGE_EVER_AUTH_PASSWORD: "rotated-password",
|
|
});
|
|
expect(rotated.EDGE_EVER_AUTH_PASSWORD).toBe("rotated-password");
|
|
expect(rotated.EDGE_EVER_AUTH_PASSWORD_FALLBACK).toBe("original-password");
|
|
|
|
const encrypted = await encryptSecret(
|
|
"provider-key",
|
|
resolvePrimaryAiCredentialEncryptionKey({ EDGE_EVER_AUTH_PASSWORD: "original-password" }),
|
|
);
|
|
await expect(decryptAiCredential(encrypted, rotated)).resolves.toBe("provider-key");
|
|
} finally {
|
|
await rm(dataDirectory, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("keeps a previous credentials key in the decrypt ring after rotation", async () => {
|
|
const dataDirectory = await makeDataDirectory();
|
|
try {
|
|
await ensureSelfHostedCredentialSecrets(dataDirectory, {
|
|
EDGE_EVER_CREDENTIALS_ENCRYPTION_KEY: "original-credentials-key",
|
|
});
|
|
const rotated = await ensureSelfHostedCredentialSecrets(dataDirectory, {
|
|
EDGE_EVER_CREDENTIALS_ENCRYPTION_KEY: "rotated-credentials-key",
|
|
});
|
|
expect(rotated.EDGE_EVER_CREDENTIALS_ENCRYPTION_KEY).toBe("rotated-credentials-key");
|
|
expect(rotated.EDGE_EVER_CREDENTIALS_ENCRYPTION_KEY_PREVIOUS).toBe("original-credentials-key");
|
|
|
|
const encrypted = await encryptSecret(
|
|
"provider-key",
|
|
resolvePrimaryAiCredentialEncryptionKey({
|
|
EDGE_EVER_CREDENTIALS_ENCRYPTION_KEY: "original-credentials-key",
|
|
}),
|
|
);
|
|
await expect(decryptAiCredential(encrypted, rotated)).resolves.toBe("provider-key");
|
|
} finally {
|
|
await rm(dataDirectory, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("refuses to replace a corrupt secrets file with a new key", async () => {
|
|
const dataDirectory = await makeDataDirectory();
|
|
try {
|
|
await writeFile(resolveSelfHostedSecretsPath(dataDirectory), "{not-json", "utf8");
|
|
await expect(ensureSelfHostedCredentialSecrets(dataDirectory, {
|
|
EDGE_EVER_AUTH_PASSWORD: "instance-password",
|
|
}, { generateIfMissing: true })).rejects.toThrow(/do not generate a replacement key/);
|
|
} finally {
|
|
await rm(dataDirectory, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("refuses an empty secrets file even if a new environment secret is present", async () => {
|
|
const dataDirectory = await makeDataDirectory();
|
|
try {
|
|
await writeFile(resolveSelfHostedSecretsPath(dataDirectory), `${JSON.stringify({ version: 1 })}\n`, "utf8");
|
|
await expect(ensureSelfHostedCredentialSecrets(dataDirectory, {
|
|
EDGE_EVER_AUTH_PASSWORD: "instance-password",
|
|
})).rejects.toThrow(/do not generate a replacement key/);
|
|
} finally {
|
|
await rm(dataDirectory, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|