fix: resolve analytics tracking issues, provider alias mapping, and fallback calculation

This commit is contained in:
diegosouzapw
2026-05-05 01:17:52 -03:00
parent bf96e704ff
commit afe4b19588
12 changed files with 125 additions and 381 deletions

View File

@@ -633,6 +633,11 @@ export class AntigravityExecutor extends BaseExecutor {
);
const finalHeaders = serializedRequest.headers;
log?.debug?.(
"TELEMETRY",
`[Antigravity] Execute - URL: ${url}, Model: ${model}, Target: ${(transformedBody as any)?.model || "unknown"}, RetryAttempt: ${retryAttemptsByUrl[urlIndex]}`
);
const response = await fetch(url, {
method: "POST",
headers: finalHeaders,
@@ -640,6 +645,13 @@ export class AntigravityExecutor extends BaseExecutor {
signal,
});
if (!response.ok) {
log?.warn?.(
"TELEMETRY",
`[Antigravity] Error Response - URL: ${url}, Status: ${response.status}, Model: ${model}`
);
}
// Parse retry time for 429/503 responses
let retryMs = null;
@@ -977,6 +989,10 @@ export class AntigravityExecutor extends BaseExecutor {
};
} catch (error) {
lastError = error;
log?.error?.(
"TELEMETRY",
`[Antigravity] Network/Fetch Error - URL: ${url}, Model: ${model}, Error: ${error instanceof Error ? error.message : String(error)}`
);
if (urlIndex + 1 < fallbackCount) {
log?.debug?.("RETRY", `Error on ${url}, trying fallback ${urlIndex + 1}`);
continue;

View File

@@ -228,6 +228,19 @@ export class DefaultExecutor extends BaseExecutor {
buildHeaders(credentials, stream = true) {
const headers = { "Content-Type": "application/json", ...this.config.headers };
// Allow per-provider User-Agent override via environment variable.
const providerId = this.config?.id || this.provider;
if (providerId) {
const envKey = `${providerId.toUpperCase().replace(/[^A-Z0-9]/g, "_")}_USER_AGENT`;
const envUA = process.env[envKey]?.trim();
if (envUA) {
headers["User-Agent"] = envUA;
if ("user-agent" in headers) {
headers["user-agent"] = envUA;
}
}
}
// T07: resolve extra keys round-robin locally since DefaultExecutor overrides BaseExecutor buildHeaders
const extraKeys =
(credentials.providerSpecificData?.extraApiKeys as string[] | undefined) ?? [];
@@ -409,7 +422,7 @@ export class DefaultExecutor extends BaseExecutor {
// #1961: Map max_tokens -> max_completion_tokens for recent OpenAI models
if (getTargetFormat(this.provider, credentials?.providerSpecificData) === "openai") {
const isRecentOpenAI = /^(o1|o3|gpt-5)/i.test(model);
const isRecentOpenAI = /^(o1|o3|o4|gpt-5)/i.test(model);
if (isRecentOpenAI && withDefaults && typeof withDefaults === "object") {
const defaultsRecord = withDefaults as Record<string, unknown>;
if ("max_tokens" in defaultsRecord) {

View File

@@ -20,6 +20,7 @@ import {
} from "../services/modelStrip.ts";
import { resolveModelAlias } from "../services/modelDeprecation.ts";
import { getUnsupportedParams } from "../config/providerRegistry.ts";
import { supportsMaxTokens } from "@/lib/modelCapabilities.ts";
import {
buildErrorBody,
createErrorResult,
@@ -2494,6 +2495,17 @@ export async function handleChatCore({
}
}
// Rename max_tokens to max_completion_tokens if not supported (#1961)
if (!supportsMaxTokens({ provider, model })) {
if (translatedBody.max_tokens !== undefined) {
if (translatedBody.max_completion_tokens === undefined) {
translatedBody.max_completion_tokens = translatedBody.max_tokens;
}
delete translatedBody.max_tokens;
log?.debug?.("PARAMS", `Renamed max_tokens to max_completion_tokens for ${model}`);
}
}
// OpenAI's `store` parameter is not supported by most compatible providers and breaks them
if (provider !== "openai" && "store" in translatedBody) {
delete translatedBody.store;

View File

@@ -139,12 +139,18 @@ export function getNextFamilyFallback(
currentModel: string,
triedModels: Set<string>
): string | null {
const family = MODEL_FAMILIES[currentModel];
const parsed = parseModel(currentModel);
const bareModel = parsed.model || currentModel;
const prefix =
parsed.provider || parsed.providerAlias ? `${parsed.provider || parsed.providerAlias}/` : "";
const family = MODEL_FAMILIES[bareModel];
if (!family) return null;
for (const candidate of family) {
if (!triedModels.has(candidate)) {
return candidate;
const fullCandidate = `${prefix}${candidate}`;
if (!triedModels.has(fullCandidate)) {
return fullCandidate;
}
}
@@ -155,16 +161,23 @@ export function getNextFamilyFallback(
* Check if a model belongs to any registered family.
*/
export function isInModelFamily(model: string): boolean {
return model in MODEL_FAMILIES;
const parsed = parseModel(model);
const bareModel = parsed.model || model;
return bareModel in MODEL_FAMILIES;
}
/**
* Get all members of a model's family (including itself).
*/
export function getModelFamily(model: string): string[] {
const family = MODEL_FAMILIES[model];
const parsed = parseModel(model);
const bareModel = parsed.model || model;
const prefix =
parsed.provider || parsed.providerAlias ? `${parsed.provider || parsed.providerAlias}/` : "";
const family = MODEL_FAMILIES[bareModel];
if (!family) return [model];
return [model, ...family];
return [model, ...family.map((c) => `${prefix}${c}`)];
}
/**

View File

@@ -312,17 +312,12 @@ export function openaiToClaudeRequest(model, body, stream) {
}
}
// System with Claude Code prompt and cache_control
const claudeCodePrompt = { type: "text", text: CLAUDE_SYSTEM_PROMPT };
// System messages and cache_control
if (systemParts.length > 0) {
const systemText = systemParts.join("\n");
result.system = [
claudeCodePrompt,
{ type: "text", text: systemText, cache_control: { type: "ephemeral", ttl: "1h" } },
];
} else {
result.system = [claudeCodePrompt];
}
// Thinking configuration
@@ -552,16 +547,6 @@ function tryParseJSON(str) {
function openaiToClaudeRequestForAntigravity(model, body, stream) {
const result = openaiToClaudeRequest(model, body, stream);
// Remove Claude Code system prompt, keep only user's system messages
if (result.system && Array.isArray(result.system)) {
result.system = result.system.filter(
(block) => !block.text || !block.text.includes("You are Claude Code")
);
if (result.system.length === 0) {
delete result.system;
}
}
// Strip prefix from tool names for Antigravity (doesn't use Claude OAuth)
if (result.tools && Array.isArray(result.tools)) {
result.tools = result.tools.map((tool) => {

View File

@@ -229,6 +229,13 @@ function convertMessages(messages, tools, model) {
// If last message in history is userInputMessage, use it as currentMessage
if (history.length > 0 && history[history.length - 1].userInputMessage) {
currentMessage = history.pop();
} else if (!currentMessage) {
currentMessage = {
userInputMessage: {
content: "Continue",
modelId: model,
},
};
}
const firstHistoryItem = history[0];

View File

@@ -74,18 +74,32 @@ function resolveModelPricing(
const pLower = (providerRaw || "").toLowerCase();
let providerPricing = findKeyInsensitive(pricingByProvider, pLower);
if (!providerPricing) {
// providerAliasMap maps ID -> ALIAS. So if pLower is "codex", alias is "cx".
const alias = providerAliasMap[pLower];
if (alias) {
providerPricing = findKeyInsensitive(pricingByProvider, alias);
} else {
const np = pLower.replace(/-cn$/, "");
if (np && np !== pLower) {
providerPricing = findKeyInsensitive(pricingByProvider, np);
}
}
if (!providerPricing) {
// In case pLower was ALIAS and we want to try the ID (reverse search values)
for (const [id, alias] of Object.entries(providerAliasMap)) {
if (alias.toLowerCase() === pLower) {
providerPricing = findKeyInsensitive(pricingByProvider, id);
if (providerPricing) break;
}
}
}
if (!providerPricing) {
const np = pLower.replace(/-cn$/, "");
if (np && np !== pLower) {
providerPricing = findKeyInsensitive(pricingByProvider, np);
}
}
// Hardcoded known fallbacks
if (!providerPricing) {
if (pLower === "antigravity") providerPricing = findKeyInsensitive(pricingByProvider, "ag");
@@ -481,7 +495,7 @@ export async function GET(request: Request) {
WHEN requested_model IS NOT NULL
AND requested_model != ''
AND model IS NOT NULL
AND LOWER(requested_model) != LOWER(model)
AND LOWER(CASE WHEN instr(requested_model, '/') > 0 THEN substr(requested_model, instr(requested_model, '/') + 1) ELSE requested_model END) != LOWER(model)
AND (combo_name IS NULL OR combo_name = '')
THEN 1 ELSE 0 END
) as fallbacks

View File

@@ -34,13 +34,6 @@ const PREFIX = "enc:v1:";
const STATIC_SALT = "omniroute-field-encryption-v1";
let _staticKey: Buffer | null = null;
let _legacyDynamicKey: Buffer | null = null;
// Module-level migration flag. Safe in Node.js because:
// 1. Node.js is single-threaded — no concurrent access race conditions
// 2. decrypt() is synchronous — no interleaving between flag-set and flag-read
// 3. Used as a "check-after-decrypt" signal, not a persistent state dependency
// 4. Same pattern as _staticKey/_legacyDynamicKey cache variables above
let _migrationNeeded = false;
/** Connection object with potentially encrypted credential fields. */
export interface ConnectionFields {
@@ -75,39 +68,6 @@ function getStaticKey(): Buffer | null {
return _staticKey;
}
/**
* Derive the LEGACY key using the old dynamic salt method.
* Used exclusively for fallback decryption of tokens encrypted by older versions.
*
* The old dynamic salt was: createHash("sha256").update(secret).digest().slice(0, 16)
* This produced a different derived key than the static salt, causing incompatibility.
*/
function getLegacyDynamicKey(): Buffer | null {
if (_legacyDynamicKey !== null) return _legacyDynamicKey;
const secret = process.env.STORAGE_ENCRYPTION_KEY;
if (!secret || typeof secret !== "string" || secret.trim().length === 0) return null;
// This is the OLD dynamic salt derivation that caused the bug
const dynamicSalt = createHash("sha256").update(secret).digest().slice(0, 16);
try {
_legacyDynamicKey = scryptSync(secret, dynamicSalt, KEY_LENGTH);
} catch {
return null;
}
return _legacyDynamicKey;
}
/** Check if any tokens were decrypted using the legacy key (indicating migration is needed). */
export function isMigrationNeeded(): boolean {
return _migrationNeeded;
}
/** Reset migration flag (call after migration is complete). */
export function resetMigrationFlag(): void {
_migrationNeeded = false;
}
/** Check if encryption is enabled. */
export function isEncryptionEnabled(): boolean {
return !!process.env.STORAGE_ENCRYPTION_KEY;
@@ -198,23 +158,11 @@ export function decrypt(ciphertext: string | null | undefined): string | null |
try {
// PRIMARY: Try static-salt key first (canonical derivation)
let decrypted = tryDecryptWithKey(staticKey);
const decrypted = tryDecryptWithKey(staticKey);
if (decrypted !== null) {
return decrypted;
}
// FALLBACK: Try legacy dynamic-salt key (backward compatibility)
const legacyKey = getLegacyDynamicKey();
if (legacyKey) {
const legacyDecrypted = tryDecryptWithKey(legacyKey);
if (legacyDecrypted !== null) {
// Flag for migration: this token was encrypted with the legacy key
// and should be re-encrypted with the static key on next write
_migrationNeeded = true;
return legacyDecrypted;
}
}
console.error(
`[Encryption] Decryption failed. Ciphertext prefix: ${ciphertext.slice(0, 30)}... ` +
`Auth tag validation likely failed.`

View File

@@ -1,10 +1,4 @@
import { normalizeComboStep } from "@/lib/combos/steps";
import {
decryptConnectionFields,
encryptConnectionFields,
isMigrationNeeded,
resetMigrationFlag,
} from "./encryption";
type SqliteDatabase = import("better-sqlite3").Database;
type JsonRecord = Record<string, unknown>;
@@ -13,8 +7,7 @@ export type DbHealthIssueType =
| "integrity_check_failed"
| "broken_reference"
| "stale_snapshot"
| "invalid_state"
| "legacy_encryption";
| "invalid_state";
export interface DbHealthIssue {
type: DbHealthIssueType;
@@ -393,83 +386,6 @@ function repairSchemaVersion(db: SqliteDatabase, expectedSchemaVersion: string):
.run(expectedSchemaVersion).changes;
}
function countLegacyEncryptedTokens(db: SqliteDatabase): number {
if (!hasRows(db, "provider_connections")) return 0;
const rows = db
.prepare("SELECT api_key, access_token, refresh_token, id_token FROM provider_connections")
.all() as Array<{
api_key?: string | null;
access_token?: string | null;
refresh_token?: string | null;
id_token?: string | null;
}>;
let legacyCount = 0;
for (const row of rows) {
resetMigrationFlag();
decryptConnectionFields({
apiKey: row.api_key,
accessToken: row.access_token,
refreshToken: row.refresh_token,
idToken: row.id_token,
});
if (isMigrationNeeded()) {
legacyCount += 1;
}
}
return legacyCount;
}
function repairLegacyEncryption(db: SqliteDatabase): number {
if (!hasRows(db, "provider_connections")) return 0;
const rows = db
.prepare("SELECT id, api_key, access_token, refresh_token, id_token FROM provider_connections")
.all() as Array<{
id: string;
api_key?: string | null;
access_token?: string | null;
refresh_token?: string | null;
id_token?: string | null;
}>;
const updateStmt = db.prepare(
"UPDATE provider_connections SET api_key = ?, access_token = ?, refresh_token = ?, id_token = ?, updated_at = ? WHERE id = ?"
);
let repaired = 0;
const now = new Date().toISOString();
for (const row of rows) {
resetMigrationFlag();
const camelRow = {
apiKey: row.api_key,
accessToken: row.access_token,
refreshToken: row.refresh_token,
idToken: row.id_token,
};
const decrypted = decryptConnectionFields(camelRow);
if (isMigrationNeeded()) {
const reEncrypted = encryptConnectionFields(decrypted);
updateStmt.run(
reEncrypted.apiKey || null,
reEncrypted.accessToken || null,
reEncrypted.refreshToken || null,
reEncrypted.idToken || null,
now,
row.id
);
repaired += 1;
}
}
return repaired;
}
export function runDbHealthCheck(
db: SqliteDatabase,
options: RunDbHealthCheckOptions = {}
@@ -621,21 +537,6 @@ export function runDbHealthCheck(
}
}
const legacyEncryptionCount = countLegacyEncryptedTokens(db);
if (legacyEncryptionCount > 0) {
issues.push({
type: "legacy_encryption",
table: "provider_connections",
description:
"Provider connections contain tokens encrypted with the legacy dynamic salt derivation.",
count: legacyEncryptionCount,
});
if (autoRepair) {
ensureBackupBeforeRepair();
repairedCount += repairLegacyEncryption(db);
}
}
return {
isHealthy: issues.length === 0,
issues,

View File

@@ -235,9 +235,17 @@ export async function getPricingForModel(provider: string, model: string) {
if (pricing[provider]?.[model]) return pricing[provider][model];
const { PROVIDER_ID_TO_ALIAS } = await import("@omniroute/open-sse/config/providerModels");
// Check if provider is an ID -> map to ALIAS
const alias = PROVIDER_ID_TO_ALIAS[provider];
if (alias && pricing[alias]) return pricing[alias][model] || null;
// Check if provider is an ALIAS -> map to ID (search values)
for (const [id, mappedAlias] of Object.entries(PROVIDER_ID_TO_ALIAS)) {
if (mappedAlias === provider && pricing[id]?.[model]) {
return pricing[id][model];
}
}
const np = provider?.replace(/-cn$/, "");
if (np && np !== provider && pricing[np]) return pricing[np][model] || null;

View File

@@ -18,6 +18,16 @@ const REASONING_UNSUPPORTED_PATTERNS = [
"antigravity/tab_",
];
const MAX_TOKENS_UNSUPPORTED_PATTERNS = [
"o1-preview",
"o1-mini",
"o1",
"o3-mini",
"o3",
"gpt-5.4",
"gpt-5.5",
];
type CapabilityInput =
| string
| {
@@ -36,6 +46,7 @@ export interface ResolvedModelCapabilities {
supportsThinking: boolean | null;
supportsTools: boolean | null;
supportsVision: boolean | null;
supportsMaxTokens: boolean;
attachment: boolean | null;
structuredOutput: boolean | null;
temperature: boolean | null;
@@ -144,6 +155,16 @@ function heuristicReasoning(modelStr: string): boolean {
return !blocked;
}
function heuristicMaxTokens(modelStr: string): boolean {
const normalized = String(modelStr || "").toLowerCase();
if (!normalized) return true;
const blocked = MAX_TOKENS_UNSUPPORTED_PATTERNS.some(
(pattern) =>
normalized === pattern || normalized.endsWith(`/${pattern}`) || normalized.includes(pattern)
);
return !blocked;
}
function getStaticSpec(modelId: string | null, rawModel: string | null): ModelSpec | undefined {
if (modelId) {
const byCanonical = getModelSpec(modelId);
@@ -222,6 +243,7 @@ export function getResolvedModelCapabilities(input: CapabilityInput): ResolvedMo
modalitiesInput,
modalitiesOutput
),
supportsMaxTokens: heuristicMaxTokens(lookupKey),
attachment: synced?.attachment ?? null,
structuredOutput: synced?.structured_output ?? null,
temperature: synced?.temperature ?? null,
@@ -259,6 +281,11 @@ export function supportsReasoning(input: CapabilityInput): boolean {
return getResolvedModelCapabilities(input).reasoning;
}
export function supportsMaxTokens(input: CapabilityInput): boolean {
if (typeof input === "string" && !String(input || "").trim()) return true;
return getResolvedModelCapabilities(input).supportsMaxTokens;
}
export function capMaxOutputTokens(input: CapabilityInput, requested?: number): number {
const cap = getResolvedModelCapabilities(input).maxOutputTokens;
return requested ? Math.min(requested, cap) : cap;

View File

@@ -66,94 +66,6 @@ describe("encryption module", () => {
});
});
describe("legacy fallback: decrypt values encrypted with dynamic-salt key", () => {
it("should decrypt legacy-encrypted value using fallback key", async () => {
const secret = "test-secret-key-12345";
const plaintext = "legacy-api-token";
// Manually create a legacy-encrypted value
const legacyEncrypted = createLegacyEncrypted(plaintext, secret);
vi.stubEnv("STORAGE_ENCRYPTION_KEY", secret);
vi.resetModules();
const { decrypt } = await import("@/lib/db/encryption");
const decrypted = decrypt(legacyEncrypted);
expect(decrypted).toBe(plaintext);
});
it("should handle multiple legacy-encrypted values", async () => {
const secret = "test-secret-key-12345";
const values = ["legacy1", "legacy2", "legacy3"];
const legacyEncrypted = values.map((v) => createLegacyEncrypted(v, secret));
vi.stubEnv("STORAGE_ENCRYPTION_KEY", secret);
vi.resetModules();
const { decrypt } = await import("@/lib/db/encryption");
const decrypted = legacyEncrypted.map((e) => decrypt(e));
expect(decrypted).toEqual(values);
});
});
describe("migration flag: after legacy decrypt, isMigrationNeeded() returns true", () => {
it("should set migration flag when decrypting legacy value", 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 { decrypt, isMigrationNeeded } = await import("@/lib/db/encryption");
expect(isMigrationNeeded()).toBe(false);
const decrypted = decrypt(legacyEncrypted);
expect(decrypted).toBe(plaintext);
expect(isMigrationNeeded()).toBe(true);
});
it("should NOT set migration flag when decrypting static-key value", async () => {
vi.stubEnv("STORAGE_ENCRYPTION_KEY", "test-secret-key-12345");
vi.resetModules();
const { encrypt, decrypt, isMigrationNeeded } = await import("@/lib/db/encryption");
const plaintext = "modern-token";
const encrypted = encrypt(plaintext);
expect(isMigrationNeeded()).toBe(false);
const decrypted = decrypt(encrypted!);
expect(decrypted).toBe(plaintext);
expect(isMigrationNeeded()).toBe(false);
});
});
describe("resetMigrationFlag() clears the flag", () => {
it("should reset migration flag after it was set", 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 { decrypt, isMigrationNeeded, resetMigrationFlag } =
await import("@/lib/db/encryption");
decrypt(legacyEncrypted);
expect(isMigrationNeeded()).toBe(true);
resetMigrationFlag();
expect(isMigrationNeeded()).toBe(false);
});
});
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
@@ -267,27 +179,6 @@ describe("encryption module", () => {
expect(decrypted.apiKey).toBe("plain-api-key");
expect(decrypted.accessToken).toBeUndefined();
});
it("should set migration flag when decrypting legacy-encrypted connection fields", async () => {
const secret = "test-secret-key-12345";
const legacyApiKey = createLegacyEncrypted("legacy-api-key", secret);
vi.stubEnv("STORAGE_ENCRYPTION_KEY", secret);
vi.resetModules();
const { decryptConnectionFields, isMigrationNeeded } = await import("@/lib/db/encryption");
const conn = {
id: "conn-123",
apiKey: legacyApiKey,
};
expect(isMigrationNeeded()).toBe(false);
const decrypted = decryptConnectionFields(conn);
expect(decrypted.apiKey).toBe("legacy-api-key");
expect(isMigrationNeeded()).toBe(true);
});
});
describe("edge cases: null/undefined inputs, already-encrypted, malformed ciphertext", () => {
@@ -463,95 +354,4 @@ describe("encryption module", () => {
expect(isEncryptionEnabled()).toBe(false);
});
});
describe("new encryptions always use static salt key", () => {
it("should encrypt with static key and decrypt without migration flag", async () => {
vi.stubEnv("STORAGE_ENCRYPTION_KEY", "test-secret-key-12345");
vi.resetModules();
const { encrypt, decrypt, isMigrationNeeded } = await import("@/lib/db/encryption");
const plaintext = "new-token";
const encrypted = encrypt(plaintext);
expect(isMigrationNeeded()).toBe(false);
const decrypted = decrypt(encrypted!);
expect(decrypted).toBe(plaintext);
expect(isMigrationNeeded()).toBe(false);
});
it("should verify multiple new encryptions use static key", async () => {
vi.stubEnv("STORAGE_ENCRYPTION_KEY", "test-secret-key-12345");
vi.resetModules();
const { encrypt, decrypt, isMigrationNeeded } = await import("@/lib/db/encryption");
const values = ["token1", "token2", "token3"];
const encrypted = values.map((v) => encrypt(v));
expect(isMigrationNeeded()).toBe(false);
const decrypted = encrypted.map((e) => decrypt(e!));
expect(decrypted).toEqual(values);
expect(isMigrationNeeded()).toBe(false);
});
});
describe("EXACT bug scenario: dynamic salt → legacy fallback → migration flag", () => {
it("should reproduce the exact bug: value encrypted with dynamic salt, decrypt recovers via legacy fallback, migration flag set", async () => {
const secret = "test-secret-key-12345";
const plaintext = "health-check-token";
// Simulate the bug: health-check thread encrypted with dynamic salt
const buggyEncrypted = createLegacyEncrypted(plaintext, secret);
// Main API tries to decrypt
vi.stubEnv("STORAGE_ENCRYPTION_KEY", secret);
vi.resetModules();
const { decrypt, isMigrationNeeded } = await import("@/lib/db/encryption");
expect(isMigrationNeeded()).toBe(false);
// Should recover via legacy fallback
const decrypted = decrypt(buggyEncrypted);
expect(decrypted).toBe(plaintext);
// Migration flag should be set
expect(isMigrationNeeded()).toBe(true);
});
it("should verify re-encryption after migration flag is set", async () => {
const secret = "test-secret-key-12345";
const plaintext = "health-check-token";
const legacyEncrypted = createLegacyEncrypted(plaintext, secret);
vi.stubEnv("STORAGE_ENCRYPTION_KEY", secret);
vi.resetModules();
const { decrypt, encrypt, isMigrationNeeded, resetMigrationFlag } =
await import("@/lib/db/encryption");
// Decrypt legacy value
const decrypted = decrypt(legacyEncrypted);
expect(decrypted).toBe(plaintext);
expect(isMigrationNeeded()).toBe(true);
// Re-encrypt with static key
const reEncrypted = encrypt(decrypted!);
expect(reEncrypted).toMatch(/^enc:v1:/);
expect(reEncrypted).not.toBe(legacyEncrypted);
// Reset migration flag
resetMigrationFlag();
expect(isMigrationNeeded()).toBe(false);
// Verify new encryption decrypts without migration flag
const finalDecrypted = decrypt(reEncrypted!);
expect(finalDecrypted).toBe(plaintext);
expect(isMigrationNeeded()).toBe(false);
});
});
});