Files
2026-07-13 13:39:12 +08:00

361 lines
12 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { createCipheriv, randomBytes, scryptSync, createHash } from "crypto";
// Test helper to manually create legacy-encrypted values
function createLegacyEncrypted(plaintext: string, secret: string): string {
const ALGORITHM = "aes-256-gcm";
const IV_LENGTH = 16;
const KEY_LENGTH = 32;
const PREFIX = "enc:v1:";
// OLD dynamic salt derivation (the bug)
const dynamicSalt = createHash("sha256").update(secret).digest().slice(0, 16);
const legacyKey = scryptSync(secret, dynamicSalt, KEY_LENGTH);
const iv = randomBytes(IV_LENGTH);
const cipher = createCipheriv(ALGORITHM, legacyKey, iv);
let encrypted = cipher.update(plaintext, "utf8", "hex");
encrypted += cipher.final("hex");
const authTag = cipher.getAuthTag().toString("hex");
return `${PREFIX}${iv.toString("hex")}:${encrypted}:${authTag}`;
}
describe("encryption module", () => {
beforeEach(() => {
// Clear all env vars and reset modules before each test
vi.unstubAllEnvs();
vi.resetModules();
});
afterEach(() => {
vi.unstubAllEnvs();
vi.resetModules();
});
describe("encrypt/decrypt roundtrip with static key (PRIMARY path)", () => {
it("should encrypt and decrypt a value successfully", async () => {
vi.stubEnv("STORAGE_ENCRYPTION_KEY", "test-secret-key-12345");
vi.resetModules();
const { encrypt, decrypt } = await import("@/lib/db/encryption");
const plaintext = "my-secret-api-key";
const encrypted = encrypt(plaintext);
expect(encrypted).toBeDefined();
expect(encrypted).toMatch(/^enc:v1:/);
expect(encrypted).not.toBe(plaintext);
const decrypted = decrypt(encrypted!);
expect(decrypted).toBe(plaintext);
});
it("should handle multiple encrypt/decrypt cycles", async () => {
vi.stubEnv("STORAGE_ENCRYPTION_KEY", "test-secret-key-12345");
vi.resetModules();
const { encrypt, decrypt } = await import("@/lib/db/encryption");
const values = ["token1", "token2", "token3"];
const encrypted = values.map((v) => encrypt(v));
const decrypted = encrypted.map((e) => decrypt(e!));
expect(decrypted).toEqual(values);
});
});
describe("migrateLegacyEncryptedString() behavior", () => {
it("should upgrade legacy encrypted tokens to static key tokens", async () => {
const secret = "test-secret-key-12345";
const plaintext = "legacy-token";
const legacyEncrypted = createLegacyEncrypted(plaintext, secret);
vi.stubEnv("STORAGE_ENCRYPTION_KEY", secret);
vi.resetModules();
const { migrateLegacyEncryptedString, decrypt } = await import("@/lib/db/encryption");
const result = migrateLegacyEncryptedString(legacyEncrypted);
expect(result.updated).toBe(true);
expect(result.value).not.toBe(legacyEncrypted);
expect(result.value).toMatch(/^enc:v1:/);
const decrypted = decrypt(result.value);
expect(decrypted).toBe(plaintext);
});
it("should NOT upgrade static-key encrypted tokens", async () => {
vi.stubEnv("STORAGE_ENCRYPTION_KEY", "test-secret-key-12345");
vi.resetModules();
const { encrypt, migrateLegacyEncryptedString } = await import("@/lib/db/encryption");
const plaintext = "modern-token";
const encrypted = encrypt(plaintext);
const result = migrateLegacyEncryptedString(encrypted!);
expect(result.updated).toBe(false);
expect(result.value).toBe(encrypted);
});
});
describe("passthrough mode: no STORAGE_ENCRYPTION_KEY set → plaintext stored", () => {
it("should return plaintext when encryption key is not set", async () => {
// No STORAGE_ENCRYPTION_KEY set
vi.resetModules();
const { encrypt, decrypt, isEncryptionEnabled } = await import("@/lib/db/encryption");
expect(isEncryptionEnabled()).toBe(false);
const plaintext = "my-api-key";
const encrypted = encrypt(plaintext);
expect(encrypted).toBe(plaintext);
const decrypted = decrypt(plaintext);
expect(decrypted).toBe(plaintext);
});
it("should handle null and undefined in passthrough mode", async () => {
vi.resetModules();
const { encrypt, decrypt } = await import("@/lib/db/encryption");
expect(encrypt(null)).toBeNull();
expect(encrypt(undefined)).toBeUndefined();
expect(decrypt(null)).toBeNull();
expect(decrypt(undefined)).toBeUndefined();
});
});
describe("encryptConnectionFields / decryptConnectionFields helpers", () => {
it("should encrypt all credential fields in a connection object", async () => {
vi.stubEnv("STORAGE_ENCRYPTION_KEY", "test-secret-key-12345");
vi.resetModules();
const { encryptConnectionFields } = await import("@/lib/db/encryption");
const conn = {
id: "conn-123",
apiKey: "plain-api-key",
accessToken: "plain-access-token",
refreshToken: "plain-refresh-token",
idToken: "plain-id-token",
};
const encrypted = encryptConnectionFields(conn);
expect(encrypted.id).toBe("conn-123");
expect(encrypted.apiKey).toMatch(/^enc:v1:/);
expect(encrypted.accessToken).toMatch(/^enc:v1:/);
expect(encrypted.refreshToken).toMatch(/^enc:v1:/);
expect(encrypted.idToken).toMatch(/^enc:v1:/);
});
it("should decrypt all credential fields in a connection object", async () => {
vi.stubEnv("STORAGE_ENCRYPTION_KEY", "test-secret-key-12345");
vi.resetModules();
const { encryptConnectionFields, decryptConnectionFields } =
await import("@/lib/db/encryption");
const conn = {
id: "conn-123",
apiKey: "plain-api-key",
accessToken: "plain-access-token",
refreshToken: "plain-refresh-token",
idToken: "plain-id-token",
};
const encrypted = encryptConnectionFields({ ...conn });
const decrypted = decryptConnectionFields(encrypted);
expect(decrypted.id).toBe("conn-123");
expect(decrypted.apiKey).toBe("plain-api-key");
expect(decrypted.accessToken).toBe("plain-access-token");
expect(decrypted.refreshToken).toBe("plain-refresh-token");
expect(decrypted.idToken).toBe("plain-id-token");
});
it("should handle null and undefined connection objects", async () => {
vi.stubEnv("STORAGE_ENCRYPTION_KEY", "test-secret-key-12345");
vi.resetModules();
const { encryptConnectionFields, decryptConnectionFields } =
await import("@/lib/db/encryption");
expect(encryptConnectionFields(null)).toBeNull();
expect(encryptConnectionFields(undefined)).toBeUndefined();
expect(decryptConnectionFields(null)).toBeNull();
expect(decryptConnectionFields(undefined)).toBeUndefined();
});
it("should handle connection objects with missing fields", async () => {
vi.stubEnv("STORAGE_ENCRYPTION_KEY", "test-secret-key-12345");
vi.resetModules();
const { encryptConnectionFields, decryptConnectionFields } =
await import("@/lib/db/encryption");
const conn: import("@/lib/db/encryption").ConnectionFields = {
id: "conn-123",
apiKey: "plain-api-key",
// accessToken, refreshToken, idToken missing
};
const encrypted = encryptConnectionFields({ ...conn });
expect(encrypted.apiKey).toMatch(/^enc:v1:/);
expect(encrypted.accessToken).toBeUndefined();
const decrypted = decryptConnectionFields(encrypted);
expect(decrypted.apiKey).toBe("plain-api-key");
expect(decrypted.accessToken).toBeUndefined();
});
});
describe("edge cases: null/undefined inputs, already-encrypted, malformed ciphertext", () => {
it("should handle null and undefined inputs", async () => {
vi.stubEnv("STORAGE_ENCRYPTION_KEY", "test-secret-key-12345");
vi.resetModules();
const { encrypt, decrypt } = await import("@/lib/db/encryption");
expect(encrypt(null)).toBeNull();
expect(encrypt(undefined)).toBeUndefined();
expect(decrypt(null)).toBeNull();
expect(decrypt(undefined)).toBeUndefined();
});
it("should not double-encrypt already-encrypted values", async () => {
vi.stubEnv("STORAGE_ENCRYPTION_KEY", "test-secret-key-12345");
vi.resetModules();
const { encrypt } = await import("@/lib/db/encryption");
const plaintext = "my-secret";
const encrypted = encrypt(plaintext);
expect(encrypted).toMatch(/^enc:v1:/);
// Try to encrypt again
const doubleEncrypted = encrypt(encrypted!);
expect(doubleEncrypted).toBe(encrypted);
});
it("should return null for malformed ciphertext (missing parts)", async () => {
vi.stubEnv("STORAGE_ENCRYPTION_KEY", "test-secret-key-12345");
vi.resetModules();
const { decrypt } = await import("@/lib/db/encryption");
const malformed = "enc:v1:onlyonepart";
const result = decrypt(malformed);
expect(result).toBeNull();
});
it("should return null for malformed ciphertext (invalid hex)", async () => {
vi.stubEnv("STORAGE_ENCRYPTION_KEY", "test-secret-key-12345");
vi.resetModules();
const { decrypt } = await import("@/lib/db/encryption");
const malformed = "enc:v1:notvalidhex:notvalidhex:notvalidhex";
const result = decrypt(malformed);
expect(result).toBeNull();
});
it("should return null for ciphertext with wrong auth tag", async () => {
vi.stubEnv("STORAGE_ENCRYPTION_KEY", "test-secret-key-12345");
vi.resetModules();
const { encrypt, decrypt } = await import("@/lib/db/encryption");
const plaintext = "my-secret";
const encrypted = encrypt(plaintext);
// Tamper with the auth tag
const parts = encrypted!.split(":");
parts[parts.length - 1] = "0000000000000000000000000000000000000000000000000000000000000000";
const tampered = parts.join(":");
const result = decrypt(tampered);
expect(result).toBeNull();
});
it("should return plaintext unchanged if not encrypted (legacy plaintext)", async () => {
vi.stubEnv("STORAGE_ENCRYPTION_KEY", "test-secret-key-12345");
vi.resetModules();
const { decrypt } = await import("@/lib/db/encryption");
const plaintext = "not-encrypted-value";
const result = decrypt(plaintext);
expect(result).toBe(plaintext);
});
it("should return null when trying to decrypt without encryption key", async () => {
vi.stubEnv("STORAGE_ENCRYPTION_KEY", "test-secret-key-12345");
vi.resetModules();
const { encrypt } = await import("@/lib/db/encryption");
const plaintext = "my-secret";
const encrypted = encrypt(plaintext);
// Now remove the key and try to decrypt
vi.unstubAllEnvs();
vi.resetModules();
const { decrypt } = await import("@/lib/db/encryption");
const result = decrypt(encrypted!);
expect(result).toBeNull();
});
});
describe("validateEncryptionConfig() removed as dead code (#5364)", () => {
// The unused `validateEncryptionConfig` export was removed in #5364 (no
// production caller). Guard that it stays gone, mirroring the sibling
// node:test assertion in db-encryption.test.ts.
it("no longer exports validateEncryptionConfig", async () => {
vi.resetModules();
const mod = await import("@/lib/db/encryption");
expect("validateEncryptionConfig" in mod).toBe(false);
});
});
describe("isEncryptionEnabled() accuracy", () => {
it("should return false when no key is set", async () => {
vi.resetModules();
const { isEncryptionEnabled } = await import("@/lib/db/encryption");
expect(isEncryptionEnabled()).toBe(false);
});
it("should return true when key is set", async () => {
vi.stubEnv("STORAGE_ENCRYPTION_KEY", "test-secret-key-12345");
vi.resetModules();
const { isEncryptionEnabled } = await import("@/lib/db/encryption");
expect(isEncryptionEnabled()).toBe(true);
});
it("should return true even when key is invalid (just checks presence)", async () => {
vi.stubEnv("STORAGE_ENCRYPTION_KEY", "");
vi.resetModules();
const { isEncryptionEnabled } = await import("@/lib/db/encryption");
// isEncryptionEnabled only checks if the env var is truthy
expect(isEncryptionEnabled()).toBe(false);
});
});
});