From 74204911c7b4035596b149fe66ee3eff840c33a4 Mon Sep 17 00:00:00 2001 From: Xiangzhe <32761048+xz-dev@users.noreply.github.com> Date: Tue, 21 Jul 2026 02:56:47 +0800 Subject: [PATCH] fix(usage): harden account identity reconciliation (#7843) * fix(usage): harden account identity reconciliation * fix(db): renumber codex strong-identity migration 128 -> 129 release/v3.8.49 tip took slot 128 via #7839 (auto_candidate_overrides) after this branch forked; renumber the new migration and its test references. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --------- Co-authored-by: Diego Rodrigues de Sa e Souza Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --- src/lib/db/core.ts | 3 + src/lib/db/jsonMigration.ts | 7 +- ...29_usage_history_codex_strong_identity.sql | 62 +++ src/lib/db/providers.ts | 141 +++--- .../providers/usageIdentityReconciliation.ts | 87 ++++ src/lib/db/usageAnalytics.ts | 14 +- src/lib/oauth/utils/codexAuthImport.ts | 42 +- .../oauth/utils/codexConnectionSelection.ts | 62 +++ src/lib/usage/accountIdentity.ts | 8 +- ...odex-auth-import-userid-dedup-6301.test.ts | 413 ++++++++++++++++-- ...-migration-runner-account-identity.test.ts | 307 ++++++++++++- tests/unit/json-migration-combos.test.ts | 77 +++- .../usage-account-analytics-route.test.ts | 390 +++++++++++++++++ tests/unit/usage-account-identity.test.ts | 9 + .../unit/usage-analytics-route-extra.test.ts | 219 ---------- 15 files changed, 1452 insertions(+), 389 deletions(-) create mode 100644 src/lib/db/migrations/129_usage_history_codex_strong_identity.sql create mode 100644 src/lib/db/providers/usageIdentityReconciliation.ts create mode 100644 src/lib/oauth/utils/codexConnectionSelection.ts create mode 100644 tests/unit/usage-account-analytics-route.test.ts diff --git a/src/lib/db/core.ts b/src/lib/db/core.ts index ed69172172..d958ead15b 100644 --- a/src/lib/db/core.ts +++ b/src/lib/db/core.ts @@ -1184,6 +1184,9 @@ export function getDbInstance(): SqliteDatabase { `); runMigrations(db, { isNewDb }); + // Fresh installs need the same post-migration index guarantee as upgraded + // databases, including recovery from an interrupted migration 127 attempt. + ensureUsageHistoryAccountIndex(db); applyStoredDatabaseOptimizationSettings(db); diff --git a/src/lib/db/jsonMigration.ts b/src/lib/db/jsonMigration.ts index 92d14c6c25..c927cd582b 100644 --- a/src/lib/db/jsonMigration.ts +++ b/src/lib/db/jsonMigration.ts @@ -245,16 +245,17 @@ export function runJsonMigration( ) `); for (const row of data.usageHistory) { - const connection = importedConnections.get(row.connection_id); + const connectionId = row.connection_id ?? row.connectionId ?? null; + const connection = connectionId ? importedConnections.get(connectionId) : undefined; const fallbackIdentity = connection ? resolveUsageAccountIdentity(connection) - : resolveOrphanedUsageAccountIdentity(row.provider, row.connection_id); + : resolveOrphanedUsageAccountIdentity(row.provider, connectionId); const identity = resolveImportedUsageAccountIdentity(row, fallbackIdentity); insertUsageHistory.run({ id: row.id, provider: row.provider ?? null, model: row.model ?? null, - connection_id: row.connection_id ?? null, + connection_id: connectionId, account_key: identity.accountKey, account_label: identity.accountLabel, account_label_priority: identity.accountLabelPriority, diff --git a/src/lib/db/migrations/129_usage_history_codex_strong_identity.sql b/src/lib/db/migrations/129_usage_history_codex_strong_identity.sql new file mode 100644 index 0000000000..0d55660b11 --- /dev/null +++ b/src/lib/db/migrations/129_usage_history_codex_strong_identity.sql @@ -0,0 +1,62 @@ +-- Migration 128: Promote only provable Codex user/email snapshots to the +-- strong user identity written by current runtime code. +-- +-- Migration 127 has already shipped, so this follow-up must repair existing +-- databases rather than editing the old migration. A weak historical snapshot +-- may be promoted only when the account_key itself recorded the same +-- chatgptUserId that the still-present OAuth Codex connection reports today: +-- the embedded historical user ID is proof the snapshot belongs to that user +-- only when the current connection has no nonblank workspaceId. A current +-- workspace may differ from the historical workspace, so migration 128 must +-- never derive a workspace-qualified key from that current value. +-- Email equality is deliberately NOT required (email can change). Snapshots +-- with a current nonblank workspace, workspace/email snapshots, mismatched +-- users, and other unproven identities stay unchanged until runtime observes +-- the matching account again. +-- +-- Snapshots that are already strong, deleted/exported orphans, non-Codex, +-- non-OAuth, or malformed keep their historical identity. The statement is a +-- no-op when the strong key already matches, so reruns are idempotent. + +UPDATE usage_history +SET account_key = ( + SELECT json_array( + 'oauth', + 'codex', + 'user', + json_extract(c.provider_specific_data, '$.chatgptUserId') + ) + FROM provider_connections c + WHERE c.id = usage_history.connection_id +) +WHERE EXISTS ( + SELECT 1 + FROM provider_connections c + WHERE c.id = usage_history.connection_id + AND c.provider = 'codex' + AND c.auth_type = 'oauth' + AND json_valid(COALESCE(c.provider_specific_data, '')) + AND json_type(c.provider_specific_data, '$.chatgptUserId') = 'text' + AND json_extract(c.provider_specific_data, '$.chatgptUserId') <> '' + AND ( + json_type(c.provider_specific_data, '$.workspaceId') IS NULL + OR json_type(c.provider_specific_data, '$.workspaceId') <> 'text' + OR trim(json_extract(c.provider_specific_data, '$.workspaceId')) = '' + ) + AND json_valid(COALESCE(usage_history.account_key, '')) + AND json_type(usage_history.account_key) = 'array' + AND json_array_length(usage_history.account_key) = 6 + AND json_type(usage_history.account_key, '$[0]') = 'text' + AND json_extract(usage_history.account_key, '$[0]') = 'oauth' + AND json_type(usage_history.account_key, '$[1]') = 'text' + AND json_extract(usage_history.account_key, '$[1]') = 'codex' + AND json_type(usage_history.account_key, '$[2]') = 'text' + AND json_extract(usage_history.account_key, '$[2]') = 'user' + AND json_type(usage_history.account_key, '$[3]') = 'text' + AND json_extract(usage_history.account_key, '$[3]') = + json_extract(c.provider_specific_data, '$.chatgptUserId') + AND json_type(usage_history.account_key, '$[4]') = 'text' + AND json_extract(usage_history.account_key, '$[4]') = 'email' + AND json_type(usage_history.account_key, '$[5]') = 'text' + AND json_extract(usage_history.account_key, '$[5]') <> '' +); diff --git a/src/lib/db/providers.ts b/src/lib/db/providers.ts index 9199dd3a94..3c37e2040e 100644 --- a/src/lib/db/providers.ts +++ b/src/lib/db/providers.ts @@ -20,7 +20,8 @@ import { invalidateReasoningRoutingRuleCache } from "./reasoningRoutingRules"; import { normalizeProviderSpecificData } from "@/lib/providers/requestDefaults"; import { bumpProxyConfigGeneration } from "./settings"; import { webSessionCredentialKey, parseProviderSpecificData } from "./webSessionDedup"; -import { resolveUsageAccountIdentity } from "@/lib/usage/accountIdentity"; +import { pickCodexConnectionForUser } from "@/lib/oauth/utils/codexConnectionSelection"; +import { reconcileCodexUsageHistory } from "./providers/usageIdentityReconciliation"; import { withNullableMaxConcurrent, withNullableQuotaWindowThresholds, @@ -36,6 +37,8 @@ import { type JsonRecord = Record; +const CONNECTION_CREDENTIAL_FIELDS = ["apiKey", "accessToken", "refreshToken", "idToken"] as const; + interface StatementLike { all: (...params: unknown[]) => TRow[]; get: (...params: unknown[]) => TRow | undefined; @@ -197,7 +200,7 @@ export async function getRawProviderConnections( camelRow ); }); - } +} export function getProviderConnectionsCount(filter: JsonRecord = {}): number { const db = getDbInstance() as unknown as DbLike; @@ -287,59 +290,53 @@ export async function createProviderConnection(data: JsonRecord) { data.providerSpecificData ); - // Upsert check - // For Codex/OpenAI, a single email can have multiple workspaces (Team + Personal) - // We need to check for workspace uniqueness, not just email let existing: JsonRecord | null = null; - let legacyCodexWorkspaceMatch = false; + let promotedCodexIdentity = false; - if (data.authType === "oauth" && data.email) { - // For Codex, check for existing connection with same workspace - const providerSpecificData = toRecord(data.providerSpecificData); - const workspaceId = toStringOrNull(providerSpecificData.workspaceId); - if (data.provider === "codex" && workspaceId) { - // For Codex, check for existing connection with same workspace AND email - // A single workspace can have multiple users (Team/Business plans) - // We need both workspace + email uniqueness to allow multiple accounts - existing = - (db - .prepare( - "SELECT * FROM provider_connections WHERE provider = ? AND auth_type = 'oauth' AND json_extract(provider_specific_data, '$.workspaceId') = ? AND email = ?" - ) - .get(data.provider, workspaceId, data.email) as JsonRecord | undefined) || null; + const providerSpecificData = toRecord(data.providerSpecificData); + const workspaceId = toStringOrNull(providerSpecificData.workspaceId); + const chatgptUserId = toStringOrNull(providerSpecificData.chatgptUserId); - // If no match with workspace+email, also check workspace-only for backward compat - // (old connections without email should still be updated, not duplicated) - if (!existing) { + if (data.authType === "oauth" && data.provider === "codex" && chatgptUserId) { + const strongSql = workspaceId + ? "SELECT * FROM provider_connections WHERE provider = ? AND auth_type = 'oauth' AND json_extract(provider_specific_data, '$.workspaceId') = ? AND json_extract(provider_specific_data, '$.chatgptUserId') = ?" + : "SELECT * FROM provider_connections WHERE provider = ? AND auth_type = 'oauth' AND (json_extract(provider_specific_data, '$.workspaceId') IS NULL OR json_extract(provider_specific_data, '$.workspaceId') = '') AND json_extract(provider_specific_data, '$.chatgptUserId') = ?"; + existing = + ((workspaceId + ? db.prepare(strongSql).get(data.provider, workspaceId, chatgptUserId) + : db.prepare(strongSql).get(data.provider, chatgptUserId)) as JsonRecord | undefined) || + null; + + if (!existing && workspaceId) { + const workspaceMatches = db + .prepare( + `SELECT * FROM provider_connections + WHERE provider = ? AND auth_type = 'oauth' + AND json_extract(provider_specific_data, '$.workspaceId') = ? + ORDER BY created_at` + ) + .all(data.provider, workspaceId) as JsonRecord[]; + existing = pickCodexConnectionForUser( + workspaceMatches, + chatgptUserId, + toStringOrNull(data.email) + ); + promotedCodexIdentity = existing !== null; + } + } else if (data.authType === "oauth" && data.email) { + if (data.provider === "codex") { + if (workspaceId) { existing = (db .prepare( - "SELECT * FROM provider_connections WHERE provider = ? AND auth_type = 'oauth' AND json_extract(provider_specific_data, '$.workspaceId') = ? AND (email IS NULL OR email = '')" + `SELECT * FROM provider_connections + WHERE provider = ? AND auth_type = 'oauth' + AND json_extract(provider_specific_data, '$.workspaceId') = ? + AND email = ? + LIMIT 1` ) - .get(data.provider, workspaceId) as JsonRecord | undefined) || null; - legacyCodexWorkspaceMatch = existing !== null; + .get(data.provider, workspaceId, data.email) as JsonRecord | undefined) || null; } - // For Codex with workspaceId, don't fall back to email-only check - // This allows creating new connections for different workspaces - } else if (data.provider === "codex") { - // Codex without a workspaceId — do NOT fall through to the generic - // bare-email dedup below. Codex never sets providerSpecificData.username, - // so that path's disambiguation is a no-op and two distinct Codex logins - // sharing an email (but missing a verifiable workspace/account id) would - // silently collapse into one row, overwriting the first login's token - // pair. Require a matching chatgptUserId (a stable per-account id from - // the JWT) before merging; otherwise treat this as a new connection. - const chatgptUserId = toStringOrNull(providerSpecificData.chatgptUserId); - if (chatgptUserId) { - existing = - (db - .prepare( - "SELECT * FROM provider_connections WHERE provider = ? AND auth_type = 'oauth' AND json_extract(provider_specific_data, '$.chatgptUserId') = ? AND email = ?" - ) - .get(data.provider, chatgptUserId, data.email) as JsonRecord | undefined) || null; - } - // No chatgptUserId on the incoming row (or no existing match) — leave - // `existing` null so a new connection row is inserted. } else { // For other providers (or Codex without workspaceId), match on email — // disambiguated by providerSpecificData.username when present on both @@ -415,38 +412,29 @@ export async function createProviderConnection(data: JsonRecord) { if (existing) { const existingId = toStringOrNull(existing.id); if (!existingId) return null; - const merged: JsonRecord = { ...toRecord(rowToCamel(existing)), ...data, updatedAt: now }; + const rawExisting = toRecord(rowToCamel(existing)); + const decryptedExisting = decryptConnectionFields({ ...rawExisting }); + const merged: JsonRecord = { ...decryptedExisting, ...data, updatedAt: now }; merged.providerSpecificData = normalizeProviderSpecificData( toStringOrNull(merged.provider), merged.providerSpecificData ); + const persistence: JsonRecord = { ...merged }; + for (const field of CONNECTION_CREDENTIAL_FIELDS) { + if (!Object.hasOwn(data, field)) { + persistence[field] = rawExisting[field]; + } + } db.transaction(() => { - if (legacyCodexWorkspaceMatch) { - const oldIdentity = resolveUsageAccountIdentity(existing); - const newIdentity = resolveUsageAccountIdentity(merged); - db.prepare( - `UPDATE usage_history - SET account_key = @newAccountKey, - account_label = CASE - WHEN @newLabelPriority > COALESCE(account_label_priority, 0) - THEN @newLabel - ELSE account_label - END, - account_label_priority = MAX( - COALESCE(account_label_priority, 0), - @newLabelPriority - ) - WHERE connection_id = @connectionId - AND account_key = @oldAccountKey` - ).run({ + if (promotedCodexIdentity) { + reconcileCodexUsageHistory(db, { connectionId: existingId, - oldAccountKey: oldIdentity.accountKey, - newAccountKey: newIdentity.accountKey, - newLabel: newIdentity.accountLabel, - newLabelPriority: newIdentity.accountLabelPriority, + existing, + merged, + matchedExistingCodexByWorkspace: true, }); } - _updateConnectionRow(db, existingId, merged); + _updateConnectionRow(db, existingId, encryptConnectionFields(persistence)); })(); backupDbFile("pre-write"); return withNullableRateLimitOverrides( @@ -763,7 +751,16 @@ export async function updateProviderConnection(id: string, data: JsonRecord) { if ("rateLimitOverrides" in merged) { merged.rateLimitOverrides = sanitizeRateLimitOverrides(merged.rateLimitOverrides); } - _updateConnectionRow(db, id, encryptConnectionFields({ ...merged })); + const existingRecord = toRecord(existing); + + db.transaction(() => { + reconcileCodexUsageHistory(db, { + connectionId: id, + existing: existingRecord, + merged, + }); + _updateConnectionRow(db, id, encryptConnectionFields({ ...merged })); + })(); backupDbFile("pre-write"); invalidateDbCache("connections"); // Bust connections read cache bumpProxyConfigGeneration(); diff --git a/src/lib/db/providers/usageIdentityReconciliation.ts b/src/lib/db/providers/usageIdentityReconciliation.ts new file mode 100644 index 0000000000..3c5dcf95bc --- /dev/null +++ b/src/lib/db/providers/usageIdentityReconciliation.ts @@ -0,0 +1,87 @@ +import { resolveUsageAccountIdentity } from "@/lib/usage/accountIdentity"; +import { parseProviderSpecificData } from "../webSessionDedup"; +import { toStringOrNull } from "./columns"; + +type JsonRecord = Record; + +interface StatementLike { + run: (...params: unknown[]) => { changes?: number }; +} + +interface DbLike { + prepare: (sql: string) => StatementLike; +} + +function nonEmptyString(value: unknown): string | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed || null; +} + +function isSafeCodexIdentityEnrichment(existing: JsonRecord, merged: JsonRecord): boolean { + const oldProviderData = parseProviderSpecificData(existing.provider_specific_data) || {}; + const newProviderData = parseProviderSpecificData(merged.providerSpecificData) || {}; + const oldUserId = nonEmptyString(oldProviderData.chatgptUserId); + const newUserId = nonEmptyString(newProviderData.chatgptUserId); + const oldWorkspaceId = nonEmptyString(oldProviderData.workspaceId); + const newWorkspaceId = nonEmptyString(newProviderData.workspaceId); + const oldEmail = nonEmptyString(existing.email); + const newEmail = nonEmptyString(merged.email); + + return ( + toStringOrNull(existing.provider) === "codex" && + toStringOrNull(existing.auth_type) === "oauth" && + toStringOrNull(merged.provider) === "codex" && + toStringOrNull(merged.authType) === "oauth" && + oldUserId === null && + newUserId !== null && + (oldWorkspaceId === null || oldWorkspaceId === newWorkspaceId) && + (oldEmail === null || oldEmail === newEmail) + ); +} + +/** + * Reconcile usage-history identity after a Codex connection gains its stable + * user identity. The caller invokes this inside the same transaction as the + * provider row update, so history and connection identity cannot diverge. + */ +export function reconcileCodexUsageHistory( + db: DbLike, + input: { + connectionId: string; + existing: JsonRecord; + merged: JsonRecord; + matchedExistingCodexByWorkspace?: boolean; + } +): void { + const oldIdentity = resolveUsageAccountIdentity(input.existing); + const newIdentity = resolveUsageAccountIdentity(input.merged); + const identityChanged = oldIdentity.accountKey !== newIdentity.accountKey; + const permitted = + input.matchedExistingCodexByWorkspace === true || + isSafeCodexIdentityEnrichment(input.existing, input.merged); + + if (!identityChanged || !permitted) return; + + db.prepare( + `UPDATE usage_history + SET account_key = @newAccountKey, + account_label = CASE + WHEN @newLabelPriority > COALESCE(account_label_priority, 0) + THEN @newLabel + ELSE account_label + END, + account_label_priority = MAX( + COALESCE(account_label_priority, 0), + @newLabelPriority + ) + WHERE connection_id = @connectionId + AND account_key = @oldAccountKey` + ).run({ + connectionId: input.connectionId, + oldAccountKey: oldIdentity.accountKey, + newAccountKey: newIdentity.accountKey, + newLabel: newIdentity.accountLabel, + newLabelPriority: newIdentity.accountLabelPriority, + }); +} diff --git a/src/lib/db/usageAnalytics.ts b/src/lib/db/usageAnalytics.ts index 5bd7847215..3e66795425 100644 --- a/src/lib/db/usageAnalytics.ts +++ b/src/lib/db/usageAnalytics.ts @@ -342,7 +342,7 @@ export function getAccountCostRows(whereClause: string, params: AnalyticsParams) SELECT COALESCE( NULLIF(usage_history.account_key, ''), - 'connection:' || COALESCE(LOWER(usage_history.provider), 'unknown') || ':' || COALESCE(usage_history.connection_id, 'unknown') + 'connection:' || COALESCE(LOWER(usage_history.provider), 'unknown') || ':' || COALESCE(NULLIF(TRIM(usage_history.connection_id), ''), 'unknown') ) as resolved_account_key, usage_history.provider, usage_history.model, @@ -403,7 +403,7 @@ export function getAccountUsageRows( WITH account_events AS ( SELECT usage_history.*, - COALESCE(NULLIF(usage_history.account_key, ''), 'connection:' || COALESCE(LOWER(usage_history.provider), 'unknown') || ':' || COALESCE(usage_history.connection_id, 'unknown')) as resolved_account_key + COALESCE(NULLIF(usage_history.account_key, ''), 'connection:' || COALESCE(LOWER(usage_history.provider), 'unknown') || ':' || COALESCE(NULLIF(TRIM(usage_history.connection_id), ''), 'unknown')) as resolved_account_key FROM usage_history ${whereClause} ), @@ -416,10 +416,10 @@ export function getAccountUsageRows( SELECT stable_account_keys.account_key, ( - SELECT usage_history.account_label + SELECT TRIM(usage_history.account_label) FROM usage_history WHERE usage_history.account_key = stable_account_keys.account_key - AND NULLIF(usage_history.account_label, '') IS NOT NULL + AND NULLIF(TRIM(usage_history.account_label), '') IS NOT NULL ORDER BY COALESCE(usage_history.account_label_priority, 0) DESC, usage_history.timestamp DESC, usage_history.id DESC @@ -432,7 +432,7 @@ export function getAccountUsageRows( FROM ( SELECT account_events.resolved_account_key as account_key, - account_events.account_label, + TRIM(account_events.account_label) as account_label, ROW_NUMBER() OVER ( PARTITION BY account_events.resolved_account_key ORDER BY COALESCE(account_events.account_label_priority, 0) DESC, @@ -441,7 +441,7 @@ export function getAccountUsageRows( ) as label_rank FROM account_events WHERE (account_events.account_key IS NULL OR account_events.account_key = '') - AND NULLIF(account_events.account_label, '') IS NOT NULL + AND NULLIF(TRIM(account_events.account_label), '') IS NOT NULL ) WHERE label_rank = 1 ), @@ -452,7 +452,7 @@ export function getAccountUsageRows( ) SELECT account_events.resolved_account_key as accountKey, - COALESCE(selected_labels.account_label, account_events.connection_id, 'unknown') as account, + COALESCE(NULLIF(TRIM(selected_labels.account_label), ''), NULLIF(TRIM(account_events.connection_id), ''), 'unknown') as account, COUNT(account_events.id) as requests, COALESCE(SUM(account_events.tokens_input), 0) as promptTokens, COALESCE(SUM(account_events.tokens_output), 0) as completionTokens, diff --git a/src/lib/oauth/utils/codexAuthImport.ts b/src/lib/oauth/utils/codexAuthImport.ts index 22ff670750..3ac22e09ca 100644 --- a/src/lib/oauth/utils/codexAuthImport.ts +++ b/src/lib/oauth/utils/codexAuthImport.ts @@ -4,6 +4,7 @@ import { updateProviderConnection, } from "@/lib/localDb"; import { CodexAuthFileError } from "@/lib/oauth/utils/codexAuthFile"; +import { pickCodexConnectionForUser } from "@/lib/oauth/utils/codexConnectionSelection"; type JsonRecord = Record; @@ -179,7 +180,11 @@ export async function createConnectionFromAuthFile( parsed: ParsedCodexAuth, options: CreateConnectionOptions ): Promise<{ connection: JsonRecord; created: boolean }> { - const existing = await findExistingCodexConnection(parsed.accountId, parsed.userId); + const existing = await findExistingCodexConnection( + parsed.accountId, + parsed.userId, + options.email || parsed.email || null + ); if (existing) { if (!options.overwriteExisting) { @@ -259,36 +264,25 @@ export async function createConnectionFromAuthFile( // Dedup key is the workspace/account id AND the per-user id. Two distinct users in the // same workspace share an accountId but have different userIds, so they must NOT collide // (#6301). Backward-compat: connections imported before the chatgptUserId field existed -// carry no stored userId — when NONE of the workspace matches has a stored userId we fall -// back to the legacy accountId-only match so genuinely-same accounts still dedup. -// From the connections already matched on workspace/account id, pick the one that -// belongs to the incoming user. A different user in the same workspace is NOT a -// duplicate — but only refuse to dedup when some stored connection actually records a -// (different) userId; if none do, they are legacy records and we dedup with the first. -function pickCodexConnectionForUser( - workspaceMatches: JsonRecord[], - userId: string -): JsonRecord | null { - const exact = workspaceMatches.find( - (c) => toNonEmptyString(toRecord(c.providerSpecificData).chatgptUserId) === userId - ); - if (exact) return exact; - const anyHasStoredUserId = workspaceMatches.some( - (c) => toNonEmptyString(toRecord(c.providerSpecificData).chatgptUserId) !== null - ); - return anyHasStoredUserId ? null : workspaceMatches[0]; -} - +// carry no stored userId — when NONE of the workspace matches has a stored userId we +// promote the legacy row with a compatible email, or an email-less legacy row. From the +// connections already matched on workspace/account id, pick the one that belongs to the +// incoming user. A different user in the same workspace is NOT a duplicate — refuse to +// dedup when some stored connection actually records a different userId. async function findExistingCodexConnection( accountId: string, - userId: string | null + userId: string | null, + email: string | null ): Promise { - const connections = await getProviderConnections({ provider: "codex" }); + const connections = await getProviderConnections({ + provider: "codex", + authType: "oauth", + }); const workspaceMatches = (connections as JsonRecord[]).filter( (c) => toNonEmptyString(toRecord(c.providerSpecificData).workspaceId) === accountId ); if (workspaceMatches.length === 0) return null; // No incoming userId → legacy accountId-only dedup with the first workspace match. if (!userId) return workspaceMatches[0]; - return pickCodexConnectionForUser(workspaceMatches, userId); + return pickCodexConnectionForUser(workspaceMatches, userId, email); } diff --git a/src/lib/oauth/utils/codexConnectionSelection.ts b/src/lib/oauth/utils/codexConnectionSelection.ts new file mode 100644 index 0000000000..326b167a4b --- /dev/null +++ b/src/lib/oauth/utils/codexConnectionSelection.ts @@ -0,0 +1,62 @@ +type JsonRecord = Record; + +type CodexConnectionIdentity = { + workspaceId: string | null; + userId: string | null; + email: string | null; +}; + +function toRecord(value: unknown): JsonRecord { + if (value && typeof value === "object" && !Array.isArray(value)) return value as JsonRecord; + if (typeof value !== "string" || !value) return {}; + try { + const parsed = JSON.parse(value); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? (parsed as JsonRecord) + : {}; + } catch { + return {}; + } +} + +function nonEmptyString(value: unknown): string | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed || null; +} + +function readIdentity(connection: JsonRecord): CodexConnectionIdentity { + const providerSpecificData = toRecord( + connection.providerSpecificData ?? connection.provider_specific_data + ); + return { + workspaceId: nonEmptyString(providerSpecificData.workspaceId), + userId: nonEmptyString(providerSpecificData.chatgptUserId), + email: nonEmptyString(connection.email), + }; +} + +/** Select the existing Codex row that may represent an incoming strong user identity. */ +export function pickCodexConnectionForUser( + workspaceMatches: JsonRecord[], + userId: string, + email: string | null +): JsonRecord | null { + const identities = workspaceMatches.map((connection) => ({ + connection, + identity: readIdentity(connection), + })); + const exact = identities.find(({ identity }) => identity.userId === userId); + if (exact) return exact.connection; + if (identities.some(({ identity }) => identity.userId !== null)) return null; + + const normalizedEmail = nonEmptyString(email); + const compatibleEmail = normalizedEmail + ? identities.find(({ identity }) => identity.email === normalizedEmail) + : null; + return ( + compatibleEmail?.connection || + identities.find(({ identity }) => identity.email === null)?.connection || + null + ); +} diff --git a/src/lib/usage/accountIdentity.ts b/src/lib/usage/accountIdentity.ts index 79f8d8d900..8990f09b6b 100644 --- a/src/lib/usage/accountIdentity.ts +++ b/src/lib/usage/accountIdentity.ts @@ -67,10 +67,12 @@ export function resolveUsageAccountIdentity( const username = identityString(providerSpecificData.username); let identityParts: string[]; - if (authType === "oauth" && provider === "codex" && workspaceId && email) { + if (authType === "oauth" && provider === "codex" && workspaceId && chatgptUserId) { + identityParts = ["oauth", provider, "workspace", workspaceId, "user", chatgptUserId]; + } else if (authType === "oauth" && provider === "codex" && chatgptUserId) { + identityParts = ["oauth", provider, "user", chatgptUserId]; + } else if (authType === "oauth" && provider === "codex" && workspaceId && email) { identityParts = ["oauth", provider, "workspace", workspaceId, "email", email]; - } else if (authType === "oauth" && provider === "codex" && chatgptUserId && email) { - identityParts = ["oauth", provider, "user", chatgptUserId, "email", email]; } else if (authType === "oauth" && provider !== "codex" && email) { identityParts = username ? ["oauth", provider, "email", email, "username", username] diff --git a/tests/unit/codex-auth-import-userid-dedup-6301.test.ts b/tests/unit/codex-auth-import-userid-dedup-6301.test.ts index 39406d90d8..9f5046ed4d 100644 --- a/tests/unit/codex-auth-import-userid-dedup-6301.test.ts +++ b/tests/unit/codex-auth-import-userid-dedup-6301.test.ts @@ -3,6 +3,7 @@ import assert from "node:assert/strict"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { execFileSync } from "node:child_process"; // #6301: importing a DISTINCT Codex/ChatGPT OAuth auth.json is falsely detected as // "already exists" when it shares the same account/workspace id but has a different @@ -10,11 +11,11 @@ import path from "node:path"; const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-codex-userid-dedup-")); process.env.DATA_DIR = TEST_DATA_DIR; +process.env.STORAGE_ENCRYPTION_KEY = "codex-import-userid-dedup-test-key"; const core = await import("../../src/lib/db/core.ts"); -const { parseAndValidateCodexAuth, createConnectionFromAuthFile } = await import( - "../../src/lib/oauth/utils/codexAuthImport.ts" -); +const { parseAndValidateCodexAuth, createConnectionFromAuthFile } = + await import("../../src/lib/oauth/utils/codexAuthImport.ts"); type JsonRecord = Record; @@ -25,6 +26,24 @@ function buildJwt(payload: JsonRecord): string { } // Build a Codex CLI auth.json sharing accountId but with a caller-chosen chatgpt_user_id. +function encryptWithStorageKey(key: string, values: string[]): string[] { + const script = ` + import { encrypt } from "./src/lib/db/encryption.ts"; + console.log(JSON.stringify(${JSON.stringify(values)}.map((value) => encrypt(value)))); + `; + return JSON.parse( + execFileSync( + process.execPath, + ["--import", "tsx/esm", "--input-type=module", "--eval", script], + { + cwd: process.cwd(), + env: { ...process.env, STORAGE_ENCRYPTION_KEY: key }, + encoding: "utf8", + } + ) + ) as string[]; +} + function buildAuthFile(accountId: string, userId: string, email: string): JsonRecord { const idToken = buildJwt({ email, @@ -84,6 +103,42 @@ test("parseAndValidateCodexAuth extracts userId from chatgpt_user_id claim", () assert.equal(parsed.userId, "user-alice"); }); +test("Codex auth import ignores matching non-OAuth connections", async () => { + const providersDb = await import("../../src/lib/db/providers.ts"); + const parsed = parseAndValidateCodexAuth( + buildAuthFile("acct-shared", "user-alice", "alice@example.com") + ); + const nonOauth = await providersDb.createProviderConnection({ + provider: "codex", + authType: "access_token", + accessToken: "website-access-token", + email: parsed.email, + providerSpecificData: { + workspaceId: parsed.accountId, + chatgptUserId: parsed.userId, + }, + }); + + const imported = await createConnectionFromAuthFile(parsed, {}); + + assert.equal(imported.created, true); + assert.notEqual(imported.connection.id, nonOauth.id); + const untouched = await providersDb.getProviderConnectionById(nonOauth.id as string); + assert.equal(untouched?.authType, "access_token"); + assert.equal(untouched?.accessToken, "website-access-token"); + + const rawOauth = core + .getDbInstance() + .prepare( + `SELECT access_token, refresh_token, id_token + FROM provider_connections WHERE id = ?` + ) + .get(imported.connection.id) as Record; + assert.match(rawOauth.access_token, /^enc:v1:/); + assert.match(rawOauth.refresh_token, /^enc:v1:/); + assert.match(rawOauth.id_token, /^enc:v1:/); +}); + test("#6301: same workspace, DIFFERENT user → both imports create a new connection", async () => { const alice = parseAndValidateCodexAuth( buildAuthFile("acct-shared", "user-alice", "alice@example.com") @@ -92,10 +147,6 @@ test("#6301: same workspace, DIFFERENT user → both imports create a new connec buildAuthFile("acct-shared", "user-bob", "bob@example.com") ); - // Sanity: same account id, distinct user id. - assert.equal(alice.accountId, bob.accountId); - assert.notEqual(alice.userId, bob.userId); - const first = await createConnectionFromAuthFile(alice, {}); assert.equal(first.created, true); @@ -105,46 +156,342 @@ test("#6301: same workspace, DIFFERENT user → both imports create a new connec assert.notEqual((second.connection as JsonRecord).id, (first.connection as JsonRecord).id); }); -test("same workspace AND same user → still deduped (update, not create)", async () => { - const alice1 = parseAndValidateCodexAuth( - buildAuthFile("acct-shared", "user-alice", "alice@example.com") +test("same workspace AND same user stays one connection when the email changes", async () => { + const first = await createConnectionFromAuthFile( + parseAndValidateCodexAuth(buildAuthFile("acct-shared", "user-alice", "old@example.com")), + {} + ); + const second = await createConnectionFromAuthFile( + parseAndValidateCodexAuth(buildAuthFile("acct-shared", "user-alice", "new@example.com")), + { overwriteExisting: true } ); - const first = await createConnectionFromAuthFile(alice1, {}); - assert.equal(first.created, true); - // Re-import the same identity with overwrite → dedup to the existing connection. - const alice2 = parseAndValidateCodexAuth( - buildAuthFile("acct-shared", "user-alice", "alice@example.com") - ); - const second = await createConnectionFromAuthFile(alice2, { overwriteExisting: true }); assert.equal(second.created, false); assert.equal((second.connection as JsonRecord).id, (first.connection as JsonRecord).id); + assert.equal((second.connection as JsonRecord).email, "new@example.com"); }); -test("backward-compat: legacy connection without stored userId still dedups by accountId", async () => { +test("an email-less legacy workspace can promote when a user ID appears", async () => { const providersDb = await import("../../src/lib/db/providers.ts"); - - // Simulate a connection imported before the chatgptUserId field existed. const legacy = await providersDb.createProviderConnection({ provider: "codex", authType: "oauth", - name: "Legacy Codex", - accessToken: "at-legacy", - refreshToken: "rt-legacy", - idToken: "id-legacy", - isActive: true, - testStatus: "active", + providerSpecificData: { workspaceId: "acct-shared" }, + }); + const promoted = await providersDb.createProviderConnection({ + provider: "codex", + authType: "oauth", + providerSpecificData: { workspaceId: "acct-shared", chatgptUserId: "user-alice" }, + }); + + assert.equal(promoted.id, legacy.id); +}); + +test("same workspace and email but different users remain separate", async () => { + const first = await createConnectionFromAuthFile( + parseAndValidateCodexAuth(buildAuthFile("acct-shared", "user-alice", "shared@example.com")), + {} + ); + const second = await createConnectionFromAuthFile( + parseAndValidateCodexAuth(buildAuthFile("acct-shared", "user-bob", "shared@example.com")), + {} + ); + + assert.equal(second.created, true); + assert.notEqual((second.connection as JsonRecord).id, (first.connection as JsonRecord).id); +}); + +test("Codex auth import promotes the compatible email legacy row and leaves its peer untouched", async () => { + const providersDb = await import("../../src/lib/db/providers.ts"); + const usageHistory = await import("../../src/lib/usage/usageHistory.ts"); + const alice = await providersDb.createProviderConnection({ + provider: "codex", + authType: "oauth", + email: "alice@example.com", + displayName: "Alice history", + providerSpecificData: { workspaceId: "acct-shared" }, + }); + const bob = await providersDb.createProviderConnection({ + provider: "codex", + authType: "oauth", + email: "bob@example.com", + displayName: "Bob history", + providerSpecificData: { workspaceId: "acct-shared" }, + }); + await usageHistory.saveRequestUsage({ + provider: "codex", + model: "gpt-5.5", + connectionId: alice.id as string, + tokens: { input: 10, output: 5 }, + timestamp: "2026-01-01T00:00:00.000Z", + }); + await usageHistory.saveRequestUsage({ + provider: "codex", + model: "gpt-5.5", + connectionId: bob.id as string, + tokens: { input: 20, output: 10 }, + timestamp: "2026-01-02T00:00:00.000Z", + }); + + const imported = await createConnectionFromAuthFile( + parseAndValidateCodexAuth(buildAuthFile("acct-shared", "user-bob", "bob@example.com")), + { overwriteExisting: true } + ); + await usageHistory.saveRequestUsage({ + provider: "codex", + model: "gpt-5.5", + connectionId: imported.connection.id as string, + tokens: { input: 30, output: 15 }, + timestamp: "2026-01-03T00:00:00.000Z", + }); + + const rows = core + .getDbInstance() + .prepare( + `SELECT account_label label, COUNT(*) requests, SUM(tokens_input + tokens_output) tokens + FROM usage_history GROUP BY account_key ORDER BY label` + ) + .all(); + assert.equal(imported.connection.id, bob.id); + assert.deepEqual(rows, [ + { label: "Alice history", requests: 1, tokens: 15 }, + { label: "Bob history", requests: 2, tokens: 75 }, + ]); +}); + +test("an established Codex user prevents an email-less legacy row from absorbing another user", async () => { + const providersDb = await import("../../src/lib/db/providers.ts"); + const usageHistory = await import("../../src/lib/usage/usageHistory.ts"); + const alice = await providersDb.createProviderConnection({ + provider: "codex", + authType: "oauth", + email: "alice@example.com", + displayName: "Alice history", + providerSpecificData: { workspaceId: "acct-shared", chatgptUserId: "user-alice" }, + }); + const legacy = await providersDb.createProviderConnection({ + provider: "codex", + authType: "oauth", + displayName: "Legacy history", + providerSpecificData: { workspaceId: "acct-shared" }, + }); + await usageHistory.saveRequestUsage({ + provider: "codex", + model: "gpt-5.5", + connectionId: alice.id as string, + tokens: { input: 10, output: 5 }, + timestamp: "2026-01-01T00:00:00.000Z", + }); + await usageHistory.saveRequestUsage({ + provider: "codex", + model: "gpt-5.5", + connectionId: legacy.id as string, + tokens: { input: 20, output: 10 }, + timestamp: "2026-01-02T00:00:00.000Z", + }); + + const parsed = parseAndValidateCodexAuth( + buildAuthFile("acct-shared", "user-bob", "bob@example.com") + ); + const imported = await createConnectionFromAuthFile(parsed, { overwriteExisting: true }); + + assert.equal(imported.created, true); + assert.notEqual(imported.connection.id, alice.id); + assert.notEqual(imported.connection.id, legacy.id); + assert.equal(imported.connection.accessToken, parsed.accessToken); + assert.equal( + (await providersDb.getProviderConnectionById(imported.connection.id as string))?.accessToken, + parsed.accessToken + ); + + const stored = core + .getDbInstance() + .prepare( + `SELECT access_token, refresh_token, id_token + FROM provider_connections WHERE id = ?` + ) + .get(imported.connection.id) as Record; + assert.notEqual(stored.access_token, parsed.accessToken); + assert.notEqual(stored.refresh_token, parsed.refreshToken); + assert.notEqual(stored.id_token, parsed.idToken); + + const histories = core + .getDbInstance() + .prepare( + `SELECT connection_id, account_label, COUNT(*) requests, + SUM(tokens_input + tokens_output) tokens + FROM usage_history GROUP BY connection_id, account_label ORDER BY account_label` + ) + .all(); + assert.deepEqual(histories, [ + { connection_id: alice.id, account_label: "Alice history", requests: 1, tokens: 15 }, + { connection_id: legacy.id, account_label: "Legacy history", requests: 1, tokens: 30 }, + ]); +}); + +test("legacy workspace/email identity promotes to a user identity without losing its history", async () => { + const providersDb = await import("../../src/lib/db/providers.ts"); + const usageHistory = await import("../../src/lib/usage/usageHistory.ts"); + + const legacy = await providersDb.createProviderConnection({ + provider: "codex", + authType: "oauth", + email: "alice@example.com", + displayName: "Historic label", + apiKey: "preserved-legacy-api-key", + providerSpecificData: { workspaceId: "acct-shared" }, + }); + await usageHistory.saveRequestUsage({ + provider: "codex", + model: "gpt-5.5", + connectionId: legacy.id as string, + tokens: { input: 10, output: 5 }, + timestamp: "2026-01-01T00:00:00.000Z", + }); + + const result = await providersDb.createProviderConnection({ + provider: "codex", + authType: "oauth", + email: "alice@example.com", + accessToken: "safe-promotion-access-token", + refreshToken: "safe-promotion-refresh-token", + idToken: "safe-promotion-id-token", + providerSpecificData: { workspaceId: "acct-shared", chatgptUserId: "user-alice" }, + }); + await usageHistory.saveRequestUsage({ + provider: "codex", + model: "gpt-5.5", + connectionId: result.id as string, + tokens: { input: 20, output: 10 }, + timestamp: "2026-01-02T00:00:00.000Z", + }); + + const groups = core + .getDbInstance() + .prepare( + `SELECT account_key, COUNT(*) requests, SUM(tokens_input + tokens_output) tokens, + MAX(account_label) FILTER (WHERE account_label_priority = 4) historic_label + FROM usage_history GROUP BY account_key` + ) + .all() as Array>; + assert.equal(result.id, legacy.id); + assert.equal(result.accessToken, "safe-promotion-access-token"); + assert.equal(result.apiKey, "preserved-legacy-api-key"); + const decrypted = await providersDb.getProviderConnectionById(result.id as string); + assert.equal(decrypted?.accessToken, "safe-promotion-access-token"); + assert.equal(decrypted?.apiKey, "preserved-legacy-api-key"); + const stored = core + .getDbInstance() + .prepare( + `SELECT access_token, refresh_token, id_token + FROM provider_connections WHERE id = ?` + ) + .get(result.id) as Record; + assert.notEqual(stored.access_token, "safe-promotion-access-token"); + assert.notEqual(stored.refresh_token, "safe-promotion-refresh-token"); + assert.notEqual(stored.id_token, "safe-promotion-id-token"); + assert.deepEqual( + groups.map(({ requests, tokens, historic_label }) => ({ requests, tokens, historic_label })), + [{ requests: 2, tokens: 45, historic_label: "Historic label" }] + ); +}); + +test("partial legacy identity promotion preserves credentials encrypted under another key", async () => { + const providersDb = await import("../../src/lib/db/providers.ts"); + const legacy = await providersDb.createProviderConnection({ + provider: "codex", + authType: "oauth", + email: "alice@example.com", + providerSpecificData: { workspaceId: "acct-shared" }, + }); + const [accessToken, refreshToken, idToken] = encryptWithStorageKey("codex-import-key-a", [ + "legacy-access", + "legacy-refresh", + "legacy-id", + ]); + core + .getDbInstance() + .prepare( + `UPDATE provider_connections + SET access_token = ?, refresh_token = ?, id_token = ? + WHERE id = ?` + ) + .run(accessToken, refreshToken, idToken, legacy.id); + const beforeCiphertext = { + access_token: accessToken, + refresh_token: refreshToken, + id_token: idToken, + }; + + const promoted = await providersDb.createProviderConnection({ + provider: "codex", + authType: "oauth", + email: "alice@example.com", + displayName: "Promoted identity", providerSpecificData: { workspaceId: "acct-shared", - importedAt: new Date().toISOString(), - // no chatgptUserId + chatgptUserId: "user-alice", }, }); - const incoming = parseAndValidateCodexAuth( - buildAuthFile("acct-shared", "user-alice", "alice@example.com") + assert.equal(promoted?.accessToken, undefined); + assert.equal(promoted?.refreshToken, undefined); + assert.equal(promoted?.idToken, undefined); + + const after = core + .getDbInstance() + .prepare( + `SELECT access_token, refresh_token, id_token + FROM provider_connections WHERE id = ?` + ) + .get(legacy.id) as Record; + assert.deepEqual({ ...after }, beforeCiphertext); +}); + +test("failed legacy promotion rolls back both provider and usage identity", async () => { + const providersDb = await import("../../src/lib/db/providers.ts"); + const usageHistory = await import("../../src/lib/usage/usageHistory.ts"); + const legacy = await providersDb.createProviderConnection({ + provider: "codex", + authType: "oauth", + email: "alice@example.com", + displayName: "Historic label", + providerSpecificData: { workspaceId: "acct-shared" }, + }); + await usageHistory.saveRequestUsage({ + provider: "codex", + model: "gpt-5.5", + connectionId: legacy.id as string, + tokens: { input: 10, output: 5 }, + timestamp: "2026-01-01T00:00:00.000Z", + }); + + const db = core.getDbInstance(); + const semanticState = () => ({ + provider: db + .prepare("SELECT email, provider_specific_data FROM provider_connections WHERE id = ?") + .get(legacy.id), + usage: db + .prepare( + `SELECT COUNT(DISTINCT account_key) accounts, COUNT(*) requests, + SUM(tokens_input + tokens_output) tokens, MAX(account_label) label + FROM usage_history WHERE connection_id = ?` + ) + .get(legacy.id), + }); + const before = semanticState(); + db.exec(`CREATE TRIGGER reject_codex_promotion BEFORE UPDATE ON provider_connections + BEGIN SELECT RAISE(ABORT, 'forced provider update failure'); END`); + + await assert.rejects( + providersDb.createProviderConnection({ + provider: "codex", + authType: "oauth", + email: "alice@example.com", + providerSpecificData: { workspaceId: "acct-shared", chatgptUserId: "user-alice" }, + }), + /forced provider update failure/ ); - const result = await createConnectionFromAuthFile(incoming, { overwriteExisting: true }); - assert.equal(result.created, false); - assert.equal((result.connection as JsonRecord).id, legacy.id); + + assert.deepEqual(semanticState(), before); }); diff --git a/tests/unit/db-migration-runner-account-identity.test.ts b/tests/unit/db-migration-runner-account-identity.test.ts index 7540dc1fc8..6f57fa763b 100644 --- a/tests/unit/db-migration-runner-account-identity.test.ts +++ b/tests/unit/db-migration-runner-account-identity.test.ts @@ -4,6 +4,10 @@ import fs from "node:fs"; import path from "node:path"; import { pathToFileURL } from "node:url"; import Database from "better-sqlite3"; +import { + resolveOrphanedUsageAccountIdentity, + resolveUsageAccountIdentity, +} from "../../src/lib/usage/accountIdentity.ts"; async function importFresh(modulePath) { const url = pathToFileURL(path.resolve(modulePath)).href; @@ -29,13 +33,16 @@ function withMockedMigrationFs(files, fn) { return originalExistsSync(target); }; - fs.readdirSync = ((target: string, options?: any) => { + fs.readdirSync = (( + target: fs.PathLike, + options?: BufferEncoding | { encoding: BufferEncoding } + ) => { if (files && isMigrationDir(target)) { return Object.keys(files); } - return originalReaddirSync(target, options); - }) as any; + return originalReaddirSync(target, options as never); + }) as typeof fs.readdirSync; fs.readFileSync = (target, options) => { const fileName = path.basename(String(target)); @@ -59,7 +66,7 @@ function createDb() { return new Database(":memory:"); } -test("migration 123 backfills account snapshots and creates its index", async () => { +test("migration 127 backfills account snapshots for historical rows", async () => { const runner = await importFresh("src/lib/db/migrationRunner.ts"); const db = createDb(); const migrationSql = fs.readFileSync( @@ -101,30 +108,266 @@ test("migration 123 backfills account snapshots and creates its index", async () const rows = db .prepare("SELECT account_key, account_label FROM usage_history ORDER BY id") .all(); - const index = db - .prepare( - "SELECT name FROM sqlite_master WHERE type = 'index' AND name = 'idx_uh_account_key'" - ) - .get(); assert.equal(count, 1); assert.deepEqual(rows, [ { - account_key: '["connection","codex","orphan-id"]', + account_key: resolveOrphanedUsageAccountIdentity("codex", "orphan-id").accountKey, account_label: "orphan-id", }, { - account_key: '["oauth","unknown","email","member@example.com","username","member"]', + account_key: resolveUsageAccountIdentity({ + id: "empty-provider", + provider: "", + authType: "oauth", + email: "member@example.com", + providerSpecificData: { username: "member" }, + }).accountKey, account_label: "member@example.com", }, ]); - assert.deepEqual(index, { name: "idx_uh_account_key" }); } finally { db.close(); } }); -test("migration 123 preserves exact dedup identity and ignores non-string JSON scalars", async () => { +test("migration 128 promotes only user-proven snapshots after a fresh 127 backfill", async () => { + const runner = await importFresh("src/lib/db/migrationRunner.ts"); + const db = createDb(); + const migration127 = fs.readFileSync( + "src/lib/db/migrations/127_usage_history_account_identity.sql", + "utf8" + ); + const migration128 = fs.readFileSync( + "src/lib/db/migrations/129_usage_history_codex_strong_identity.sql", + "utf8" + ); + + const strong = (connection) => + resolveUsageAccountIdentity({ + id: connection.id, + provider: "codex", + authType: "oauth", + email: connection.email, + providerSpecificData: connection.providerSpecificData, + }).accountKey; + const weakUserEmail = (userId, email) => + JSON.stringify(["oauth", "codex", "user", userId, "email", email]); + const weakWorkspaceEmail = (workspaceId, email) => + JSON.stringify(["oauth", "codex", "workspace", workspaceId, "email", email]); + + const connections = { + promoted: { + id: "promoted", + email: "new@example.com", + providerSpecificData: { workspaceId: "workspace-a", chatgptUserId: "user-a" }, + }, + userOnly: { + id: "user-only", + email: "new-solo@example.com", + providerSpecificData: { chatgptUserId: "user-solo" }, + }, + ambiguousWorkspace: { + id: "ambiguous-workspace", + email: "member@example.com", + providerSpecificData: { workspaceId: "workspace-b", chatgptUserId: "user-b" }, + }, + alreadyStrong: { + id: "already-strong", + email: "member@example.com", + providerSpecificData: { workspaceId: "workspace-c", chatgptUserId: "user-c" }, + }, + historicalUserMismatch: { + id: "historical-user-mismatch", + email: "member@example.com", + providerSpecificData: { chatgptUserId: "current-user" }, + }, + nonOauth: { + id: "non-oauth", + email: "member@example.com", + providerSpecificData: { chatgptUserId: "user-d" }, + }, + malformedKey: { + id: "malformed-key", + email: "member@example.com", + providerSpecificData: { chatgptUserId: "user-e" }, + }, + malformedConnection: { + id: "malformed-connection", + email: "member@example.com", + providerSpecificData: null, + }, + }; + + const weakKeys = { + // The current workspace cannot prove the historical workspace for this + // shipped user+email snapshot, so migration 128 must leave it unchanged. + promoted: weakUserEmail("user-a", "old@example.com"), + // Email changes do not matter when the current OAuth connection has no + // workspace and the embedded historical user ID matches. + userOnly: weakUserEmail("user-solo", "old-solo@example.com"), + ambiguousWorkspace: weakWorkspaceEmail("workspace-b", "member@example.com"), + deleted: weakUserEmail("user-deleted", "deleted@example.com"), + nonCodex: JSON.stringify(["oauth", "openai", "email", "member@example.com"]), + nonOauth: weakUserEmail("user-d", "member@example.com"), + malformedKey: "not valid JSON", + malformedConnection: weakUserEmail("user-f", "member@example.com"), + // Shipped historical input: the embedded user differs from the connection's + // current ChatGPT user, so migration 128 must not rewrite this snapshot. + historicalUserMismatch: + '["oauth","codex","user","historical-user","email","member@example.com"]', + }; + + try { + db.exec(` + CREATE TABLE provider_connections ( + id TEXT PRIMARY KEY, + provider TEXT, + auth_type TEXT, + name TEXT, + email TEXT, + display_name TEXT, + provider_specific_data TEXT + ); + CREATE TABLE usage_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + provider TEXT, + connection_id TEXT, + account_key TEXT, + account_label TEXT, + account_label_priority INTEGER DEFAULT 0 + ); + INSERT INTO provider_connections + (id, provider, auth_type, email, provider_specific_data) + VALUES + ('promoted', 'codex', 'oauth', 'new@example.com', + '{"workspaceId":"workspace-a","chatgptUserId":"user-a"}'), + ('user-only', 'codex', 'oauth', 'solo@example.com', + '{"chatgptUserId":"user-solo"}'), + ('ambiguous-workspace', 'codex', 'oauth', 'member@example.com', + '{"workspaceId":"workspace-b","chatgptUserId":"user-b"}'), + ('already-strong', 'codex', 'oauth', 'member@example.com', + '{"workspaceId":"workspace-c","chatgptUserId":"user-c"}'), + ('historical-user-mismatch', 'codex', 'oauth', 'member@example.com', + '{"chatgptUserId":"current-user"}'), + ('non-codex', 'openai', 'oauth', 'member@example.com', + '{"username":"member"}'), + ('non-oauth', 'codex', 'access_token', 'member@example.com', + '{"chatgptUserId":"user-d"}'), + ('malformed-key', 'codex', 'oauth', 'member@example.com', + '{"chatgptUserId":"user-e"}'), + ('malformed-connection', 'codex', 'oauth', 'member@example.com', + '{bad json'); + + -- Historical usage written before account snapshots existed. The + -- literal weak keys model what the shipped migration 127 backfilled. + INSERT INTO usage_history (provider, connection_id, account_key, account_label) VALUES + ('codex', 'promoted', '${weakKeys.promoted}', 'Old email label'), + ('codex', 'user-only', '${weakKeys.userOnly}', 'Solo label'), + ('codex', 'ambiguous-workspace', '${weakKeys.ambiguousWorkspace}', 'Workspace label'), + ('codex', 'deleted-connection', '${weakKeys.deleted}', 'Exported orphan'), + ('openai', 'non-codex', '${weakKeys.nonCodex}', 'Generic label'), + ('codex', 'non-oauth', '${weakKeys.nonOauth}', 'Token label'), + ('codex', 'malformed-key', '${weakKeys.malformedKey}', 'Malformed key label'), + ('codex', 'malformed-connection', '${weakKeys.malformedConnection}', 'Malformed connection label'), + ('codex', 'already-strong', '${strong(connections.alreadyStrong)}', 'Strong label'), + ('codex', 'historical-user-mismatch', '${weakKeys.historicalUserMismatch}', 'Historical mismatch label'); + `); + + // Fresh installs run the original shipped 127 first, then 128. The 127 + // backfill only touches rows without a snapshot, so the historical keys + // above survive unchanged into 128. + const applied = withMockedMigrationFs( + { + "127_usage_history_account_identity.sql": migration127, + "129_usage_history_codex_strong_identity.sql": migration128, + }, + () => runner.runMigrations(db) + ); + assert.equal(applied, 2); + + const rows = db + .prepare("SELECT connection_id, account_key, account_label FROM usage_history ORDER BY id") + .all(); + + assert.deepEqual(rows, [ + // The embedded historical user ID matches, but the current workspace + // does not prove the historical workspace. Keep the snapshot and label. + { + connection_id: "promoted", + account_key: weakKeys.promoted, + account_label: "Old email label", + }, + // With no current workspace, the matching embedded user ID is sufficient + // even though the connection email changed since the snapshot. + { + connection_id: "user-only", + account_key: strong(connections.userOnly), + account_label: "Solo label", + }, + // Same workspace and email, but no embedded user ID: the snapshot could + // belong to any workspace member, so it must stay weak. + { + connection_id: "ambiguous-workspace", + account_key: weakKeys.ambiguousWorkspace, + account_label: "Workspace label", + }, + // The connection is gone; nothing can prove who generated the usage. + { + connection_id: "deleted-connection", + account_key: weakKeys.deleted, + account_label: "Exported orphan", + }, + { + connection_id: "non-codex", + account_key: weakKeys.nonCodex, + account_label: "Generic label", + }, + { + connection_id: "non-oauth", + account_key: weakKeys.nonOauth, + account_label: "Token label", + }, + { + connection_id: "malformed-key", + account_key: weakKeys.malformedKey, + account_label: "Malformed key label", + }, + { + connection_id: "malformed-connection", + account_key: weakKeys.malformedConnection, + account_label: "Malformed connection label", + }, + { + connection_id: "already-strong", + account_key: strong(connections.alreadyStrong), + account_label: "Strong label", + }, + // A historical embedded user that differs from the currently connected + // user cannot be safely promoted; both historical key and label remain. + { + connection_id: "historical-user-mismatch", + account_key: weakKeys.historicalUserMismatch, + account_label: "Historical mismatch label", + }, + ]); + + // Rerunning 128 on an up-to-date database must not rewrite anything. + const rerun = withMockedMigrationFs( + { "129_usage_history_codex_strong_identity.sql": migration128 }, + () => runner.runMigrations(db) + ); + assert.equal(rerun, 0); + assert.deepEqual( + db.prepare("SELECT account_key FROM usage_history ORDER BY id").all(), + rows.map(({ account_key }) => ({ account_key })) + ); + } finally { + db.close(); + } +}); + +test("migration 127 preserves exact dedup identity and ignores non-string JSON scalars", async () => { const runner = await importFresh("src/lib/db/migrationRunner.ts"); const db = createDb(); const sql = fs.readFileSync( @@ -181,31 +424,55 @@ test("migration 123 preserves exact dedup identity and ignores non-string JSON s assert.deepEqual(rows, [ { connection_id: "generic", - account_key: '["oauth","openai","email"," Member@example.com ","username"," Member "]', + account_key: resolveUsageAccountIdentity({ + id: "generic", + provider: "openai", + authType: "oauth", + email: " Member@example.com ", + providerSpecificData: { username: " Member " }, + }).accountKey, }, { connection_id: "workspace", - account_key: '["oauth","codex","workspace"," Workspace ","email"," Member@example.com "]', + account_key: resolveUsageAccountIdentity({ + id: "workspace", + provider: "codex", + authType: "oauth", + email: " Member@example.com ", + providerSpecificData: { workspaceId: " Workspace " }, + }).accountKey, }, { connection_id: "numeric-workspace", - account_key: '["connection","codex","numeric-workspace"]', + account_key: resolveOrphanedUsageAccountIdentity("codex", "numeric-workspace").accountKey, }, { connection_id: "object-user", - account_key: '["connection","codex","object-user"]', + account_key: resolveOrphanedUsageAccountIdentity("codex", "object-user").accountKey, }, { connection_id: "array-username", - account_key: '["oauth","openai","email","member@example.com"]', + account_key: resolveUsageAccountIdentity({ + id: "array-username", + provider: "openai", + authType: "oauth", + email: "member@example.com", + providerSpecificData: { username: ["member"] }, + }).accountKey, }, { connection_id: "malformed-json", - account_key: '["oauth","openai","email","member@example.com"]', + account_key: resolveUsageAccountIdentity({ + id: "malformed-json", + provider: "openai", + authType: "oauth", + email: "member@example.com", + providerSpecificData: "{bad json", + }).accountKey, }, { connection_id: "", - account_key: '["connection","unknown","unknown"]', + account_key: resolveOrphanedUsageAccountIdentity("", "").accountKey, }, ]); } finally { diff --git a/tests/unit/json-migration-combos.test.ts b/tests/unit/json-migration-combos.test.ts index 988706b373..36ef86bd51 100644 --- a/tests/unit/json-migration-combos.test.ts +++ b/tests/unit/json-migration-combos.test.ts @@ -10,6 +10,16 @@ process.env.DATA_DIR = TEST_DATA_DIR; const core = await import("../../src/lib/db/core.ts"); const { runJsonMigration } = await import("../../src/lib/db/jsonMigration.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const usageHistory = await import("../../src/lib/usage/usageHistory.ts"); +const exportRoute = await import("../../src/app/api/settings/export-json/route.ts"); + +test.beforeEach(() => { + const db = core.getDbInstance(); + db.prepare("DELETE FROM usage_history").run(); + db.prepare("DELETE FROM provider_connections").run(); + db.prepare("DELETE FROM combos").run(); +}); test.after(() => { core.resetDbInstance(); @@ -18,19 +28,28 @@ test.after(() => { else process.env.DATA_DIR = ORIGINAL_DATA_DIR; }); -test("runJsonMigration preserves exported account snapshots after a connection was deleted", () => { +test("runJsonMigration preserves exported snapshots and camelCase connection attribution", () => { const db = core.getDbInstance(); runJsonMigration(db, { + providerConnections: [ + { + id: "codex-connection", + provider: "codex", + authType: "oauth", + email: "current@example.com", + providerSpecificData: { workspaceId: "team", chatgptUserId: "user-a" }, + }, + ], usageHistory: [ { id: 1, provider: "codex", model: "gpt-5.5", - connection_id: "deleted-codex-uuid", - account_key: '["oauth","codex","user","user-a","email","member@example.com"]', - account_label: "Production Codex", - account_label_priority: 4, + connectionId: "codex-connection", + accountKey: "stable-exported-account", + accountLabel: "Historical label", + accountLabelPriority: 4, tokens_input: 10, tokens_output: 5, timestamp: "2026-01-01T00:00:00.000Z", @@ -40,17 +59,59 @@ test("runJsonMigration preserves exported account snapshots after a connection w const row = db .prepare( - "SELECT account_key, account_label, account_label_priority FROM usage_history WHERE id = 1" + `SELECT connection_id, account_key, account_label, account_label_priority, + tokens_input + tokens_output total_tokens + FROM usage_history WHERE id = 1` ) .get(); assert.deepEqual(row, { - account_key: '["oauth","codex","user","user-a","email","member@example.com"]', - account_label: "Production Codex", + connection_id: "codex-connection", + account_key: "stable-exported-account", + account_label: "Historical label", account_label_priority: 4, + total_tokens: 15, }); }); +test("usage snapshots survive an export, connection deletion, and import round trip", async () => { + const db = core.getDbInstance(); + const connection = await providersDb.createProviderConnection({ + provider: "codex", + authType: "oauth", + email: "member@example.com", + displayName: "Production Codex", + providerSpecificData: { workspaceId: "team", chatgptUserId: "user-a" }, + }); + await usageHistory.saveRequestUsage({ + provider: "codex", + model: "gpt-5.5", + connectionId: connection.id as string, + tokens: { input: 10, output: 5 }, + timestamp: "2026-02-01T00:00:00.000Z", + }); + + const response = await exportRoute.GET( + new Request("http://localhost/api/settings/export-json?includeHistory=true") + ); + assert.equal(response.status, 200); + const exported = await response.json(); + + await providersDb.deleteProviderConnection(connection.id as string); + db.prepare("DELETE FROM usage_history").run(); + db.prepare("DELETE FROM provider_connections").run(); + runJsonMigration(db, exported); + + const restored = db + .prepare( + `SELECT COUNT(*) requests, SUM(tokens_input + tokens_output) tokens, + MAX(account_label) label + FROM usage_history WHERE connection_id = ?` + ) + .get(connection.id); + assert.deepEqual(restored, { requests: 1, tokens: 15, label: "Production Codex" }); +}); + test("runJsonMigration normalizes legacy combo strategy names at the import boundary", () => { const db = core.getDbInstance(); diff --git a/tests/unit/usage-account-analytics-route.test.ts b/tests/unit/usage-account-analytics-route.test.ts new file mode 100644 index 0000000000..42108323d8 --- /dev/null +++ b/tests/unit/usage-account-analytics-route.test.ts @@ -0,0 +1,390 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-account-analytics-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = "test-account-analytics-secret"; + +const core = await import("../../src/lib/db/core.ts"); +const localDb = await import("../../src/lib/localDb.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const usageHistory = await import("../../src/lib/usage/usageHistory.ts"); +const { resolveUsageAccountIdentity } = await import("../../src/lib/usage/accountIdentity.ts"); +const analyticsRoute = await import("../../src/app/api/usage/analytics/route.ts"); + +function makeRequest() { + return new Request("http://localhost/api/usage/analytics?startDate=2026-01-01T00:00:00.000Z"); +} + +async function readAnalytics() { + const response = await analyticsRoute.GET(makeRequest()); + assert.equal(response.status, 200); + return (await response.json()) as { + summary: Record; + byAccount: Array>; + }; +} + +async function readAccounts() { + return (await readAnalytics()).byAccount; +} + +test.beforeEach(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); + usageHistory.clearPendingRequests(); +}); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("Codex account grouping follows workspace and user identity, not email", async () => { + await localDb.updatePricing({ codex: { "gpt-5.5": { input: 1, output: 2 } } }); + const specs = [ + { workspaceId: "workspace-a", chatgptUserId: "user-a", email: "old@example.com" }, + { workspaceId: "workspace-a", chatgptUserId: "user-b", email: "old@example.com" }, + { workspaceId: "workspace-b", chatgptUserId: "user-a", email: "old@example.com" }, + ]; + + for (const [index, spec] of specs.entries()) { + const connection = await providersDb.createProviderConnection({ + provider: "codex", + authType: "oauth", + email: spec.email, + displayName: `Account ${index + 1}`, + providerSpecificData: spec, + }); + await usageHistory.saveRequestUsage({ + provider: "codex", + model: "gpt-5.5", + connectionId: connection.id as string, + tokens: { input: 1000, output: 500 }, + timestamp: `2026-01-0${index + 1}T00:00:00.000Z`, + }); + } + + const sameUser = await providersDb.createProviderConnection({ + provider: "codex", + authType: "oauth", + email: "new@example.com", + displayName: "Account 1 renamed", + providerSpecificData: { workspaceId: "workspace-a", chatgptUserId: "user-a" }, + }); + await usageHistory.saveRequestUsage({ + provider: "codex", + model: "gpt-5.5", + connectionId: sameUser.id as string, + tokens: { input: 1000, output: 500 }, + timestamp: "2026-01-04T00:00:00.000Z", + }); + + const accounts = await readAccounts(); + assert.equal(accounts.length, 3); + assert.deepEqual(accounts.map((account) => [account.account, account.requests]).sort(), [ + ["Account 1 renamed", 2], + ["Account 2", 1], + ["Account 3", 1], + ]); + assert.equal( + accounts.reduce((sum, account) => sum + Number(account.cost), 0), + 0.008 + ); +}); + +test("updating an established Codex user or workspace does not reattribute earlier usage", async () => { + const changes = [ + { + name: "user boundary", + before: { workspaceId: "workspace-user", chatgptUserId: "user-a" }, + after: { workspaceId: "workspace-user", chatgptUserId: "user-b" }, + }, + { + name: "workspace boundary", + before: { workspaceId: "workspace-a", chatgptUserId: "stable-user" }, + after: { workspaceId: "workspace-b", chatgptUserId: "stable-user" }, + }, + ]; + + for (const [index, change] of changes.entries()) { + const connection = await providersDb.createProviderConnection({ + provider: "codex", + authType: "oauth", + displayName: `${change.name} before`, + providerSpecificData: change.before, + }); + await usageHistory.saveRequestUsage({ + provider: "codex", + model: "gpt-5.5", + connectionId: connection.id as string, + tokens: { input: 10, output: 5 }, + timestamp: `2026-02-0${index + 1}T00:00:00.000Z`, + }); + + await providersDb.updateProviderConnection(connection.id as string, { + displayName: `${change.name} after`, + providerSpecificData: change.after, + }); + await usageHistory.saveRequestUsage({ + provider: "codex", + model: "gpt-5.5", + connectionId: connection.id as string, + tokens: { input: 20, output: 10 }, + timestamp: `2026-02-1${index + 1}T00:00:00.000Z`, + }); + } + + const accounts = await readAccounts(); + assert.deepEqual( + accounts.map((account) => [account.account, account.requests, account.totalTokens]).sort(), + [ + ["user boundary after", 1, 30], + ["user boundary before", 1, 15], + ["workspace boundary after", 1, 30], + ["workspace boundary before", 1, 15], + ] + ); +}); + +test("deleting and recreating the same Codex account preserves one historical account", async () => { + await localDb.updatePricing({ codex: { "gpt-5.5": { input: 1, output: 2 } } }); + const identity = { + workspaceId: "workspace-internal-recreated", + chatgptUserId: "user-internal-recreated", + }; + const original = await providersDb.createProviderConnection({ + provider: "codex", + authType: "oauth", + email: "member@example.com", + displayName: "Production Codex ", + providerSpecificData: identity, + }); + await usageHistory.saveRequestUsage({ + provider: "codex", + model: "gpt-5.5", + connectionId: original.id as string, + tokens: { input: 1000, output: 500 }, + timestamp: "2026-03-01T00:00:00.000Z", + }); + + assert.equal(await providersDb.deleteProviderConnection(original.id as string), true); + const recreated = await providersDb.createProviderConnection({ + provider: "codex", + authType: "oauth", + email: "member@example.com", + providerSpecificData: identity, + }); + assert.notEqual(recreated.id, original.id); + await usageHistory.saveRequestUsage({ + provider: "codex", + model: "gpt-5.5", + connectionId: recreated.id as string, + tokens: { input: 1000, output: 500 }, + timestamp: "2026-03-02T00:00:00.000Z", + }); + + const analytics = await readAnalytics(); + assert.deepEqual( + analytics.byAccount.map((account) => ({ + account: account.account, + requests: account.requests, + promptTokens: account.promptTokens, + completionTokens: account.completionTokens, + totalTokens: account.totalTokens, + cost: account.cost, + })), + [ + { + account: "Production Codex ", + requests: 2, + promptTokens: 2000, + completionTokens: 1000, + totalTokens: 3000, + cost: 0.004, + }, + ] + ); + + const serialized = JSON.stringify(analytics); + const { accountKey } = resolveUsageAccountIdentity({ + id: recreated.id, + provider: "codex", + authType: "oauth", + email: "member@example.com", + providerSpecificData: identity, + }); + for (const privateValue of [ + accountKey, + identity.workspaceId, + identity.chatgptUserId, + recreated.id as string, + ]) { + assert.equal(serialized.includes(privateValue), false); + } +}); + +test("account cost and usage share the same trimmed legacy connection fallback", async () => { + await localDb.updatePricing({ codex: { "gpt-5.5": { input: 1, output: 2 } } }); + const timestamp = new Date().toISOString(); + core + .getDbInstance() + .prepare( + `INSERT INTO usage_history + (provider, model, connection_id, account_label, account_label_priority, + tokens_input, tokens_output, success, timestamp) + VALUES ('codex', 'gpt-5.5', ' legacy-id ', 'Legacy Codex', 4, + 1000, 500, 1, ?)` + ) + .run(timestamp); + + const analytics = await readAnalytics(); + assert.equal(analytics.byAccount.length, 1); + assert.deepEqual( + { + account: analytics.byAccount[0].account, + requests: analytics.byAccount[0].requests, + totalTokens: analytics.byAccount[0].totalTokens, + cost: analytics.byAccount[0].cost, + }, + { account: "Legacy Codex", requests: 1, totalTokens: 1500, cost: 0.002 } + ); + assert.equal(analytics.summary.totalCost, analytics.byAccount[0].cost); +}); + +test("analytics prefers the highest-priority historical label and reports blank orphan IDs as unknown", async () => { + await localDb.updatePricing({ codex: { "gpt-5.5": { input: 1, output: 2 } } }); + const db = core.getDbInstance(); + const insert = db.prepare(`INSERT INTO usage_history + (provider, model, connection_id, account_key, account_label, account_label_priority, + tokens_input, tokens_output, success, timestamp) + VALUES (?, 'gpt-5.5', ?, ?, ?, ?, 10, 5, 1, ?)`); + insert.run( + "codex", + "old-uuid", + "stable-account", + " Production Codex ", + 4, + "2026-01-01T00:00:00.000Z" + ); + insert.run("codex", "new-uuid", "stable-account", " ", 5, "2026-01-02T00:00:00.000Z"); + insert.run("codex", " ", null, null, 0, "2026-01-03T00:00:00.000Z"); + + const analytics = await readAnalytics(); + assert.deepEqual( + analytics.byAccount.map((account) => [ + account.account, + account.requests, + account.totalTokens, + account.cost, + ]), + [ + ["Production Codex", 2, 30, 0.00004], + ["unknown", 1, 15, 0.00002], + ] + ); +}); + +test("generic provider and Codex workspace identities stay distinct under a shared email", async () => { + const accountSpecs = [ + { provider: "codex", providerSpecificData: { workspaceId: "workspace-a" } }, + { provider: "codex", providerSpecificData: { workspaceId: "workspace-b" } }, + { provider: "openai", providerSpecificData: {} }, + ]; + + for (const [index, spec] of accountSpecs.entries()) { + const connection = await providersDb.createProviderConnection({ + provider: spec.provider, + authType: "oauth", + email: "shared@example.com", + accessToken: `secret-${index}`, + providerSpecificData: spec.providerSpecificData, + }); + await usageHistory.saveRequestUsage({ + provider: spec.provider, + model: spec.provider === "codex" ? "gpt-5.5" : "gpt-4o", + connectionId: connection.id as string, + tokens: { input: 100 + index, output: 50 + index }, + timestamp: `2026-01-0${index + 1}T00:00:00.000Z`, + }); + } + + const accounts = await readAccounts(); + // Three separate accounts share one email label; grouping must not collapse + // them because workspace and provider identities differ. + assert.equal(accounts.length, 3); + assert.deepEqual(accounts.map((account) => account.account).sort(), [ + "shared@example.com", + "shared@example.com", + "shared@example.com", + ]); + assert.deepEqual(accounts.map((account) => account.requests).sort(), [1, 1, 1]); +}); + +test("a nonblank orphaned connection ID stays a truthful fallback label", async () => { + core + .getDbInstance() + .prepare( + `INSERT INTO usage_history (provider, model, connection_id, tokens_input, tokens_output, success, latency_ms, timestamp) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + ) + .run("codex", "gpt-5.5", "deleted-legacy-uuid", 10, 5, 1, 25, "2026-01-01T00:00:00.000Z"); + + const accounts = await readAccounts(); + assert.equal(accounts.length, 1); + assert.equal(accounts[0].account, "deleted-legacy-uuid"); +}); + +test("the newest label wins for one account at equal priority", async () => { + const db = core.getDbInstance(); + const accountKey = resolveUsageAccountIdentity({ + id: "new-uuid", + provider: "codex", + authType: "oauth", + email: "member@example.com", + providerSpecificData: { chatgptUserId: "user-a" }, + }).accountKey; + const now = Date.now(); + const insert = db.prepare( + `INSERT INTO usage_history + (provider, model, connection_id, account_key, account_label, account_label_priority, + tokens_input, tokens_output, success, latency_ms, timestamp) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ); + insert.run( + "codex", + "gpt-5.5", + "old-uuid", + accountKey, + "Zulu before rename", + 4, + 10, + 5, + 1, + 20, + new Date(now - 60_000).toISOString() + ); + insert.run( + "codex", + "gpt-5.5", + "new-uuid", + accountKey, + "Alpha after rename", + 4, + 20, + 10, + 1, + 30, + new Date(now).toISOString() + ); + + const analytics = await readAnalytics(); + assert.equal(analytics.summary.uniqueAccounts, 1); + assert.equal(analytics.byAccount.length, 1); + assert.equal(analytics.byAccount[0].account, "Alpha after rename"); + assert.equal(analytics.byAccount[0].requests, 2); +}); diff --git a/tests/unit/usage-account-identity.test.ts b/tests/unit/usage-account-identity.test.ts index c8af0f7a1d..3e8afc0b5f 100644 --- a/tests/unit/usage-account-identity.test.ts +++ b/tests/unit/usage-account-identity.test.ts @@ -21,7 +21,16 @@ test("Codex chatgptUserId identity is stable across UUID and label changes", () providerSpecificData: { chatgptUserId: "user-production" }, }); + const emailChanged = resolveUsageAccountIdentity({ + id: "third-uuid", + provider: "codex", + authType: "oauth", + email: "renamed@example.com", + providerSpecificData: { chatgptUserId: "user-production" }, + }); + assert.equal(first.accountKey, recreated.accountKey); + assert.equal(first.accountKey, emailChanged.accountKey); assert.equal(first.accountLabel, "Production Codex"); assert.equal(recreated.accountLabel, "member@example.com"); }); diff --git a/tests/unit/usage-analytics-route-extra.test.ts b/tests/unit/usage-analytics-route-extra.test.ts index 66613642f9..3154381a73 100644 --- a/tests/unit/usage-analytics-route-extra.test.ts +++ b/tests/unit/usage-analytics-route-extra.test.ts @@ -86,225 +86,6 @@ test.after(() => { process.env.API_KEY_SECRET = ORIGINAL_API_KEY_SECRET; } }); -test("GET /api/usage/analytics joins legacy workspace usage after Codex re-login", async () => { - await localDb.updatePricing({ - codex: { "gpt-5.5": { input: 1, output: 2 } }, - }); - - const db = core.getDbInstance(); - const connectionId = "legacy-workspace-connection"; - db.prepare( - `INSERT INTO provider_connections - (id, provider, auth_type, email, display_name, provider_specific_data, created_at, updated_at) - VALUES (?, 'codex', 'oauth', NULL, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)` - ).run(connectionId, "Production Codex", JSON.stringify({ workspaceId: "workspace-production" })); - - await usageHistory.saveRequestUsage({ - provider: "codex", - model: "gpt-5.5", - connectionId, - tokens: { input: 1000, output: 500 }, - timestamp: "2026-01-01T00:00:00.000Z", - }); - - const relogged = await providersDb.createProviderConnection({ - provider: "codex", - authType: "oauth", - email: "member@example.com", - accessToken: "new-secret", - providerSpecificData: { workspaceId: "workspace-production" }, - }); - assert.equal(relogged.id, connectionId); - const repaired = db - .prepare( - "SELECT account_key, account_label, account_label_priority FROM usage_history WHERE connection_id = ?" - ) - .get(connectionId); - assert.deepEqual(repaired, { - account_key: - '["oauth","codex","workspace","workspace-production","email","member@example.com"]', - account_label: "Production Codex", - account_label_priority: 4, - }); - - await usageHistory.saveRequestUsage({ - provider: "codex", - model: "gpt-5.5", - connectionId, - tokens: { input: 1000, output: 500 }, - timestamp: "2026-01-02T00:00:00.000Z", - }); - - const response = await analyticsRoute.GET( - makeRequest("http://localhost/api/usage/analytics?startDate=2026-01-01T00:00:00.000Z") - ); - const body = await response.json(); - - assert.equal(response.status, 200); - assert.equal(body.byAccount.length, 1); - assert.equal(body.byAccount[0].account, "Production Codex"); - assert.equal(body.byAccount[0].requests, 2); - assertClose(body.byAccount[0].cost, 0.004); -}); - -test("GET /api/usage/analytics preserves a Codex account across deletion and re-login", async () => { - await localDb.updatePricing({ - codex: { "gpt-5.5": { input: 1, output: 2 } }, - }); - - const original = await providersDb.createProviderConnection({ - provider: "codex", - authType: "oauth", - email: "member@example.com", - displayName: "Production Codex", - accessToken: "old-secret", - providerSpecificData: { workspaceId: "workspace-production" }, - }); - - await usageHistory.saveRequestUsage({ - provider: "codex", - model: "gpt-5.5", - connectionId: original.id as string, - tokens: { input: 1000, output: 500 }, - timestamp: "2026-01-01T00:00:00.000Z", - }); - await providersDb.deleteProviderConnection(original.id as string); - - const recreated = await providersDb.createProviderConnection({ - provider: "codex", - authType: "oauth", - email: "member@example.com", - accessToken: "new-secret", - providerSpecificData: { workspaceId: "workspace-production" }, - }); - assert.notEqual(recreated.id, original.id); - - await usageHistory.saveRequestUsage({ - provider: "codex", - model: "gpt-5.5", - connectionId: recreated.id as string, - tokens: { input: 1000, output: 500 }, - timestamp: "2026-01-02T00:00:00.000Z", - }); - - const response = await analyticsRoute.GET( - makeRequest("http://localhost/api/usage/analytics?startDate=2026-01-01T00:00:00.000Z") - ); - const body = await response.json(); - - assert.equal(response.status, 200); - assert.equal(body.byAccount.length, 1); - assert.equal(body.byAccount[0].account, "Production Codex"); - assert.equal("accountKey" in body.byAccount[0], false); - assert.equal(body.byAccount[0].requests, 2); - assertClose(body.byAccount[0].cost, 0.004); -}); - -test("GET /api/usage/analytics keeps provider and Codex workspace account identities distinct", async () => { - const accountSpecs = [ - { provider: "codex", workspaceId: "workspace-a" }, - { provider: "codex", workspaceId: "workspace-b" }, - { provider: "openai", workspaceId: undefined }, - ]; - - for (const [index, spec] of accountSpecs.entries()) { - const connection = await providersDb.createProviderConnection({ - provider: spec.provider, - authType: "oauth", - email: "shared@example.com", - accessToken: `secret-${index}`, - providerSpecificData: spec.workspaceId ? { workspaceId: spec.workspaceId } : {}, - }); - await usageHistory.saveRequestUsage({ - provider: spec.provider, - model: spec.provider === "codex" ? "gpt-5.5" : "gpt-4o", - connectionId: connection.id as string, - tokens: { input: 100 + index, output: 50 + index }, - timestamp: `2026-01-0${index + 1}T00:00:00.000Z`, - }); - } - - const response = await analyticsRoute.GET( - makeRequest("http://localhost/api/usage/analytics?startDate=2026-01-01T00:00:00.000Z") - ); - const body = await response.json(); - - assert.equal(response.status, 200); - assert.equal(body.byAccount.length, 3); - assert.ok(body.byAccount.every((row) => !("accountKey" in row))); - assert.deepEqual(body.byAccount.map((row) => row.account).sort(), [ - "shared@example.com", - "shared@example.com", - "shared@example.com", - ]); -}); - -test("GET /api/usage/analytics keeps an honest UUID label for an orphaned legacy account", async () => { - const db = core.getDbInstance(); - db.prepare( - `INSERT INTO usage_history (provider, model, connection_id, tokens_input, tokens_output, success, latency_ms, timestamp) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)` - ).run("codex", "gpt-5.5", "deleted-legacy-uuid", 10, 5, 1, 25, "2026-01-01T00:00:00.000Z"); - - const response = await analyticsRoute.GET( - makeRequest("http://localhost/api/usage/analytics?startDate=2026-01-01T00:00:00.000Z") - ); - const body = await response.json(); - - assert.equal(response.status, 200); - assert.equal(body.byAccount.length, 1); - assert.equal(body.byAccount[0].account, "deleted-legacy-uuid"); - assert.equal("accountKey" in body.byAccount[0], false); -}); - -test("GET /api/usage/analytics uses the newest equal-priority label for one account", async () => { - const db = core.getDbInstance(); - const accountKey = '["oauth","codex","user","user-a","email","member@example.com"]'; - const now = Date.now(); - const insert = db.prepare( - `INSERT INTO usage_history - (provider, model, connection_id, account_key, account_label, account_label_priority, - tokens_input, tokens_output, success, latency_ms, timestamp) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` - ); - insert.run( - "codex", - "gpt-5.5", - "old-uuid", - accountKey, - "Zulu before rename", - 4, - 10, - 5, - 1, - 20, - new Date(now - 60_000).toISOString() - ); - insert.run( - "codex", - "gpt-5.5", - "new-uuid", - accountKey, - "Alpha after rename", - 4, - 20, - 10, - 1, - 30, - new Date(now).toISOString() - ); - - const response = await analyticsRoute.GET(makeRequest("http://localhost/api/usage/analytics")); - const body = await response.json(); - - assert.equal(response.status, 200); - assert.equal(body.summary.uniqueAccounts, 1); - assert.equal(body.byAccount.length, 1); - assert.equal(body.byAccount[0].account, "Alpha after rename"); - assert.equal(body.byAccount[0].requests, 2); - assert.equal("accountKey" in body.byAccount[0], false); -}); - test("GET /api/usage/analytics includes cost by API key", async () => { await seedAnalyticsData();