Files
emdash-patch-imageupload/packages/core/tests/unit/config/secrets.test.ts
kunthawat 2d1be52177 Emdash source with visual editor image upload fix
Fixes:
1. media.ts: wrap placeholder generation in try-catch
2. toolbar.ts: check r.ok, display error message in popover
2026-05-03 10:44:54 +07:00

376 lines
14 KiB
TypeScript

import type { Kysely } from "kysely";
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import {
EmDashSecretsError,
IP_SALT_OPTION_KEY,
PREVIEW_SECRET_OPTION_KEY,
_clearSecretsCacheForTesting,
fingerprintKey,
generateEncryptionKey,
parseEncryptionKeys,
resolveSecrets,
resolveSecretsCached,
validateEncryptionKeyAtStartup,
} from "../../../src/config/secrets.js";
import { OptionsRepository } from "../../../src/database/repositories/options.js";
import type { Database } from "../../../src/database/types.js";
import { setupTestDatabase, teardownTestDatabase } from "../../utils/test-db.js";
describe("config/secrets", () => {
let db: Kysely<Database>;
beforeEach(async () => {
db = await setupTestDatabase();
_clearSecretsCacheForTesting();
});
afterEach(async () => {
_clearSecretsCacheForTesting();
await teardownTestDatabase(db);
});
describe("generateEncryptionKey", () => {
it("emits the v1 prefix and a base64url body", () => {
const key = generateEncryptionKey();
expect(key.startsWith("emdash_enc_v1_")).toBe(true);
const body = key.slice("emdash_enc_v1_".length);
expect(body).toHaveLength(43);
expect(body).toMatch(/^[A-Za-z0-9_-]+$/);
});
it("produces unique values across calls", () => {
const a = generateEncryptionKey();
const b = generateEncryptionKey();
expect(a).not.toBe(b);
});
});
describe("parseEncryptionKeys", () => {
it("returns null for unset / empty input", async () => {
expect(await parseEncryptionKeys(undefined)).toBeNull();
expect(await parseEncryptionKeys("")).toBeNull();
expect(await parseEncryptionKeys(",,,")).toBeNull();
expect(await parseEncryptionKeys(" ")).toBeNull();
});
it("parses a single valid key into a {kid, key, raw} entry", async () => {
const raw = generateEncryptionKey();
const parsed = await parseEncryptionKeys(raw);
expect(parsed).toHaveLength(1);
expect(parsed?.[0]?.raw).toBe(raw);
expect(parsed?.[0]?.key).toBeInstanceOf(Uint8Array);
expect(parsed?.[0]?.key.byteLength).toBe(32);
expect(parsed?.[0]?.kid).toMatch(/^[0-9a-f]{8}$/);
});
it("kid is stable across repeated calls and matches fingerprintKey()", async () => {
// Kid is derived from decoded key bytes (canonicality is enforced
// upstream, so raw <-> bytes is 1:1). Repeated parses of the same
// canonical raw string must yield the same kid, and the standalone
// fingerprintKey() helper must agree with parseEncryptionKeys().
const raw = generateEncryptionKey();
const a = await parseEncryptionKeys(raw);
const b = await parseEncryptionKeys(raw);
expect(a?.[0]?.kid).toBe(b?.[0]?.kid);
expect(await fingerprintKey(raw)).toBe(a?.[0]?.kid);
});
it("parses comma-separated multi-key with whitespace tolerance", async () => {
const a = generateEncryptionKey();
const b = generateEncryptionKey();
const parsed = await parseEncryptionKeys(` ${a} , ${b} ,`);
expect(parsed).toHaveLength(2);
expect(parsed?.[0]?.raw).toBe(a);
expect(parsed?.[1]?.raw).toBe(b);
});
it("dedupes keys with the same kid (paste mistakes)", async () => {
const raw = generateEncryptionKey();
const parsed = await parseEncryptionKeys(`${raw},${raw}`);
expect(parsed).toHaveLength(1);
});
it("throws on a malformed prefix", async () => {
await expect(parseEncryptionKeys("not_a_key")).rejects.toBeInstanceOf(EmDashSecretsError);
});
it("throws on a malformed body (too short)", async () => {
await expect(parseEncryptionKeys("emdash_enc_v1_tooShort")).rejects.toBeInstanceOf(
EmDashSecretsError,
);
});
it("throws on a malformed body (non base64url chars)", async () => {
// 43 chars with a bad character
const bad = "emdash_enc_v1_" + "!".repeat(43);
await expect(parseEncryptionKeys(bad)).rejects.toBeInstanceOf(EmDashSecretsError);
});
it("throws if any entry in a multi-key list is malformed", async () => {
const good = generateEncryptionKey();
await expect(parseEncryptionKeys(`${good},not_a_key`)).rejects.toBeInstanceOf(
EmDashSecretsError,
);
});
it("rejects non-canonical base64url so kid stays stable per key material", async () => {
// 32-byte keys encode to 43 base64url chars. The 43rd char encodes
// 4 bits of which only the high 2 are meaningful — the low 2 bits
// must be zero in canonical encoding. Letters whose 6-bit value
// has zero low-2-bits: A(0), E(4), I(8), M(12), Q(16), U(20),
// Y(24), c(28), g(32), k(36), o(40), s(44), w(48), 0(52), 4(56),
// 8(60). Anything else in the last position encodes bits that
// canonical encoding would set to zero — same decoded bytes,
// different raw string. We reject these to keep kid 1:1 with
// key material.
//
// "A".repeat(43) is canonical (all-zero key). Replace the last
// char with "B" (value 1) to get a non-canonical string with
// the same decoded bytes.
const canonical = `emdash_enc_v1_${"A".repeat(43)}`;
const nonCanonical = `emdash_enc_v1_${"A".repeat(42)}B`;
// Both have the right shape so we know we're testing the
// canonical check, not the regex.
expect(canonical).toMatch(/^emdash_enc_v1_[A-Za-z0-9_-]{43}$/);
expect(nonCanonical).toMatch(/^emdash_enc_v1_[A-Za-z0-9_-]{43}$/);
// Canonical form parses fine.
await expect(parseEncryptionKeys(canonical)).resolves.toBeTruthy();
// Non-canonical form is rejected.
await expect(parseEncryptionKeys(nonCanonical)).rejects.toThrow(EmDashSecretsError);
});
});
describe("resolveSecrets", () => {
it("default path generates and persists IP salt + preview secret", async () => {
const result = await resolveSecrets({ db, env: {} });
expect(result.ipSaltSource).toBe("db");
expect(result.previewSecretSource).toBe("db");
expect(result.ipSalt.length).toBeGreaterThan(0);
expect(result.previewSecret.length).toBeGreaterThan(0);
const repo = new OptionsRepository(db);
expect(await repo.get<string>(IP_SALT_OPTION_KEY)).toBe(result.ipSalt);
expect(await repo.get<string>(PREVIEW_SECRET_OPTION_KEY)).toBe(result.previewSecret);
});
it("does not consult EMDASH_ENCRYPTION_KEY (a malformed key cannot break preview/comments)", async () => {
// Regression: previously a malformed EMDASH_ENCRYPTION_KEY was
// parsed inside resolveSecrets and the throw propagated through
// request-context middleware as a 500 to anonymous visitors with
// stale `?_preview=` URLs. The key is validated separately at
// startup now; resolveSecrets must not gate on it.
const result = await resolveSecrets({
db,
env: { EMDASH_ENCRYPTION_KEY: "not_a_valid_key" },
});
expect(result.previewSecret.length).toBeGreaterThan(0);
expect(result.ipSalt.length).toBeGreaterThan(0);
});
it("env override wins for preview secret and ip salt", async () => {
const result = await resolveSecrets({
db,
env: {
EMDASH_PREVIEW_SECRET: "env-preview",
EMDASH_IP_SALT: "env-ip-salt",
},
});
expect(result.previewSecret).toBe("env-preview");
expect(result.previewSecretSource).toBe("env");
expect(result.ipSalt).toBe("env-ip-salt");
expect(result.ipSaltSource).toBe("env");
// And nothing was written to the options table on the env path.
const repo = new OptionsRepository(db);
expect(await repo.get<string>(IP_SALT_OPTION_KEY)).toBeNull();
expect(await repo.get<string>(PREVIEW_SECRET_OPTION_KEY)).toBeNull();
});
it("legacy PREVIEW_SECRET fallback works (unprefixed name)", async () => {
const result = await resolveSecrets({
db,
env: { PREVIEW_SECRET: "legacy-preview" },
});
expect(result.previewSecret).toBe("legacy-preview");
expect(result.previewSecretSource).toBe("env");
});
it("legacy EMDASH_AUTH_SECRET fallback for IP salt is honored", async () => {
// Prior code derived the IP salt from EMDASH_AUTH_SECRET. Existing
// installs that have only EMDASH_AUTH_SECRET set must keep the
// same salt — otherwise their existing IP-bucket rate-limit data
// rotates uselessly on upgrade.
const result = await resolveSecrets({
db,
env: { EMDASH_AUTH_SECRET: "legacy-auth" },
});
expect(result.ipSalt).toBe("legacy-auth");
expect(result.ipSaltSource).toBe("env");
});
it("EMDASH_IP_SALT wins over EMDASH_AUTH_SECRET fallback", async () => {
const result = await resolveSecrets({
db,
env: {
EMDASH_IP_SALT: "explicit-salt",
EMDASH_AUTH_SECRET: "legacy-auth",
},
});
expect(result.ipSalt).toBe("explicit-salt");
});
it("idempotent: repeated calls return the same DB-stored values", async () => {
const a = await resolveSecrets({ db, env: {} });
const b = await resolveSecrets({ db, env: {} });
expect(a.ipSalt).toBe(b.ipSalt);
expect(a.previewSecret).toBe(b.previewSecret);
});
it("repeated first-resolves are idempotent (sequential test of convergence)", async () => {
// Five sequential first-resolves on a fresh DB should converge.
// Note: better-sqlite3 is synchronous, so this doesn't exercise
// genuine cross-process concurrency. The cross-process atomicity
// is provided by `INSERT ... ON CONFLICT DO NOTHING` at the DB
// layer; see the lost-race test below for in-process coverage
// of the re-read path.
const promises: Promise<Awaited<ReturnType<typeof resolveSecrets>>>[] = [];
for (let i = 0; i < 5; i++) {
promises.push(resolveSecrets({ db, env: {} }));
}
const results = await Promise.all(promises);
const ipSalts = new Set(results.map((r) => r.ipSalt));
const previews = new Set(results.map((r) => r.previewSecret));
expect(ipSalts.size).toBe(1);
expect(previews.size).toBe(1);
});
it("returns the existing row when first-read finds one already populated", async () => {
// Pre-populate the row, then resolve. Exercises the early-return
// branch in `ensureGeneratedOption` where the first read hits.
const winnerSalt = "row-already-populated";
const repo = new OptionsRepository(db);
await repo.set(IP_SALT_OPTION_KEY, winnerSalt);
const result = await resolveSecrets({ db, env: {} });
expect(result.ipSalt).toBe(winnerSalt);
expect(result.ipSaltSource).toBe("db");
});
it("converges via lost-race re-read when setIfAbsent reports no insert", async () => {
// Simulate a genuine cross-process race: caller A reads (no row),
// caller B inserts the winner, caller A's setIfAbsent loses
// (returns false). A then re-reads and converges on B's value.
//
// We stub `setIfAbsent` to inject the "concurrent process won"
// behavior on the IP-salt key specifically (the resolver also
// does this for preview secret in parallel; we need to target
// one to assert).
const winnerSalt = "concurrent-process-won";
const realRepo = new OptionsRepository(db);
const stubRepo = Object.create(realRepo) as OptionsRepository;
stubRepo.setIfAbsent = async <T>(name: string, value: T) => {
if (name === IP_SALT_OPTION_KEY) {
// Simulate "winner" inserting first; tell our caller the
// insert didn't take so it falls through to the re-read.
await realRepo.set(name, winnerSalt);
return false;
}
return realRepo.setIfAbsent(name, value);
};
const result = await resolveSecrets({ db, env: {}, _repo: stubRepo });
expect(result.ipSalt).toBe(winnerSalt);
expect(result.ipSaltSource).toBe("db");
});
it("throws SECRET_PERSIST_FAILED when setIfAbsent loses but the row is empty after re-read", async () => {
// Pathological case: setIfAbsent says "didn't insert" but the
// row is still missing. This shouldn't happen in practice with
// a sane DB, but the resolver guards against it rather than
// looping forever or returning an empty string.
const realRepo = new OptionsRepository(db);
const stubRepo = Object.create(realRepo) as OptionsRepository;
stubRepo.setIfAbsent = async () => false; // Always claim no-op, no row appears.
await expect(resolveSecrets({ db, env: {}, _repo: stubRepo })).rejects.toThrow(
/SECRET_PERSIST_FAILED|Failed to persist/,
);
});
});
describe("validateEncryptionKeyAtStartup", () => {
it("returns true for an unset key", async () => {
expect(await validateEncryptionKeyAtStartup({})).toBe(true);
});
it("returns true for a valid key", async () => {
const key = generateEncryptionKey();
expect(await validateEncryptionKeyAtStartup({ EMDASH_ENCRYPTION_KEY: key })).toBe(true);
});
it("returns false (and does not throw) for a malformed key, logging an operator-facing message", async () => {
const errors: unknown[][] = [];
const original = console.error;
console.error = (...args: unknown[]) => {
errors.push(args);
};
try {
const result = await validateEncryptionKeyAtStartup({
EMDASH_ENCRYPTION_KEY: "not_a_valid_key",
});
expect(result).toBe(false);
expect(errors).toHaveLength(1);
expect(String(errors[0]?.[0])).toMatch(/EMDASH_ENCRYPTION_KEY is invalid/);
} finally {
console.error = original;
}
});
});
describe("fingerprintKey", () => {
it("agrees with parseEncryptionKeys on canonical input", async () => {
const raw = generateEncryptionKey();
const parsed = await parseEncryptionKeys(raw);
expect(await fingerprintKey(raw)).toBe(parsed?.[0]?.kid);
});
it("rejects non-canonical base64url (so the CLI can't print kids the runtime would refuse)", async () => {
const nonCanonical = `emdash_enc_v1_${"A".repeat(42)}B`;
await expect(fingerprintKey(nonCanonical)).rejects.toBeInstanceOf(EmDashSecretsError);
});
it("rejects a malformed prefix", async () => {
await expect(fingerprintKey("not_a_key")).rejects.toBeInstanceOf(EmDashSecretsError);
});
it("rejects bodies of the wrong length", async () => {
await expect(fingerprintKey("emdash_enc_v1_tooShort")).rejects.toBeInstanceOf(
EmDashSecretsError,
);
});
});
describe("resolveSecretsCached", () => {
it("memoizes per-db so multiple callers share one resolution promise", async () => {
// First caller starts the resolution; second caller piggybacks.
// We can verify they share a value (and the cache key is the db
// instance) by comparing against a freshly cleared cache.
const a = await resolveSecretsCached(db);
const b = await resolveSecretsCached(db);
expect(a).toBe(b);
_clearSecretsCacheForTesting();
const c = await resolveSecretsCached(db);
// Different cache entry, but same persisted DB values.
expect(c).not.toBe(a);
expect(c.ipSalt).toBe(a.ipSalt);
expect(c.previewSecret).toBe(a.previewSecret);
});
});
});