mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
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 <diegosouza.pw@gmail.com> Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
This commit is contained in:
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]') <> ''
|
||||
);
|
||||
@@ -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<string, unknown>;
|
||||
|
||||
const CONNECTION_CREDENTIAL_FIELDS = ["apiKey", "accessToken", "refreshToken", "idToken"] as const;
|
||||
|
||||
interface StatementLike<TRow = unknown> {
|
||||
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();
|
||||
|
||||
87
src/lib/db/providers/usageIdentityReconciliation.ts
Normal file
87
src/lib/db/providers/usageIdentityReconciliation.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { resolveUsageAccountIdentity } from "@/lib/usage/accountIdentity";
|
||||
import { parseProviderSpecificData } from "../webSessionDedup";
|
||||
import { toStringOrNull } from "./columns";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
interface StatementLike {
|
||||
run: (...params: unknown[]) => { changes?: number };
|
||||
}
|
||||
|
||||
interface DbLike {
|
||||
prepare: <TRow = unknown>(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,
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
|
||||
@@ -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<JsonRecord | null> {
|
||||
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);
|
||||
}
|
||||
|
||||
62
src/lib/oauth/utils/codexConnectionSelection.ts
Normal file
62
src/lib/oauth/utils/codexConnectionSelection.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
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
|
||||
);
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
|
||||
@@ -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<string, string>;
|
||||
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<string, string>;
|
||||
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<Record<string, unknown>>;
|
||||
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<string, string>;
|
||||
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<string, string>;
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
390
tests/unit/usage-account-analytics-route.test.ts
Normal file
390
tests/unit/usage-account-analytics-route.test.ts
Normal file
@@ -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<string, unknown>;
|
||||
byAccount: Array<Record<string, unknown>>;
|
||||
};
|
||||
}
|
||||
|
||||
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 <member@example.com>",
|
||||
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 <member@example.com>",
|
||||
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);
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user