diff --git a/backend/internal/handler/admin/account_codex_import.go b/backend/internal/handler/admin/account_codex_import.go index 6ec9495d84..01a5fbfa1c 100644 --- a/backend/internal/handler/admin/account_codex_import.go +++ b/backend/internal/handler/admin/account_codex_import.go @@ -253,6 +253,17 @@ func (h *AccountHandler) importCodexSessions(ctx context.Context, req CodexSessi Message: "已有账号未记录 chatgpt_user_id,已按共享的 chatgpt_account_id 匹配并回填,请确认两者属于同一用户", }) } + preserveExistingRefresh := item.RefreshToken == "" && + codexCredentialString(existing.Credentials, "refresh_token") != "" + if preserveExistingRefresh { + result.Warnings = append(result.Warnings, CodexSessionImportMessage{ + Index: entry.Index, + Name: accountName, + Message: "已有账号包含 refresh_token,本次 accessToken-only 导入已保留自动续期凭据", + }) + effectiveExpiresAt = nil + autoPauseOnExpired = nil + } mergedCredentials := mergeCodexImportCredentials(existing.Credentials, credentials, item) mergedExtra := mergeCodexImportMap(existing.Extra, extra) updateInput := &service.UpdateAccountInput{ @@ -592,7 +603,7 @@ func normalizeCodexImportEntry(entry codexImportEntry) (*codexImportAccount, err fingerprint := codexTokenFingerprint(item.AccessToken) item.Extra["access_token_sha256"] = fingerprint - item.IdentityKeys = buildCodexIdentityKeys(item.AccountID, item.UserID, item.Email, item.AccessToken) + item.IdentityKeys = buildCodexImportIdentityKeys(item.AccountID, item.UserID, item.Email, item.AccessToken, item.RefreshToken) item.Name = buildCodexImportAccountName(item, entry.Index) return item, nil @@ -815,13 +826,25 @@ func sanitizeCodexImportCredentialExtras(input map[string]any) map[string]any { return out } -// buildCodexIdentityKeys 按身份强度排序生成匹配键:chatgpt_account_id 在同一 -// ChatGPT 团队内是共享的,因此 account: 键排在最后,且命中时还需通过 -// codexIdentityConflicts 的跨用户校验才生效。 -func buildCodexIdentityKeys(accountID, userID, email, accessToken string) []string { +// buildCodexImportIdentityKeys 生成导入条目的匹配键。refresh_token 缺失时 +// Codex session 只能作为 accessToken-only 凭据使用,此时以 access token +// 指纹作为唯一稳定身份,避免同 workspace 下共享的 account/user 标识误合并。 +func buildCodexImportIdentityKeys(accountID, userID, email, accessToken, refreshToken string) []string { + accessToken = strings.TrimSpace(accessToken) + refreshToken = strings.TrimSpace(refreshToken) + if refreshToken == "" && accessToken != "" { + return []string{"access:" + codexTokenFingerprint(accessToken)} + } + return buildCodexStoredIdentityKeys(accountID, userID, email, accessToken) +} + +// buildCodexStoredIdentityKeys 生成存量账号索引键,保留 user/account 维度, +// 让 accessToken-only 账号后续升级为完整 OAuth 时仍能命中并更新原账号。 +func buildCodexStoredIdentityKeys(accountID, userID, email, accessToken string) []string { keys := make([]string, 0, 3) accountID = strings.TrimSpace(accountID) userID = strings.TrimSpace(userID) + accessToken = strings.TrimSpace(accessToken) if userID != "" { keys = append(keys, "user:"+userID) } @@ -830,7 +853,7 @@ func buildCodexIdentityKeys(accountID, userID, email, accessToken string) []stri keys = append(keys, "email:"+email) } } - if accessToken = strings.TrimSpace(accessToken); accessToken != "" { + if accessToken != "" { keys = append(keys, "access:"+codexTokenFingerprint(accessToken)) } if accountID != "" { @@ -854,7 +877,8 @@ func (i *codexAccountIndex) Add(account service.Account) { if i.accountsByKey == nil { i.accountsByKey = map[string][]service.Account{} } - keys := buildCodexIdentityKeys( + i.remove(account.ID) + keys := buildCodexStoredIdentityKeys( codexCredentialString(account.Credentials, "chatgpt_account_id"), codexCredentialString(account.Credentials, "chatgpt_user_id"), codexCredentialString(account.Credentials, "email"), @@ -865,6 +889,22 @@ func (i *codexAccountIndex) Add(account service.Account) { } } +func (i *codexAccountIndex) remove(accountID int64) { + for key, accounts := range i.accountsByKey { + kept := accounts[:0] + for _, account := range accounts { + if account.ID != accountID { + kept = append(kept, account) + } + } + if len(kept) == 0 { + delete(i.accountsByKey, key) + continue + } + i.accountsByKey[key] = kept + } +} + // upsertCodexAccount 保留同一键下的全部候选账号(共享的 account: 键可对应 // 团队内多个账号),同一账号重复 Add 时原位替换为最新状态。 func upsertCodexAccount(accounts []service.Account, account service.Account) []service.Account { @@ -894,9 +934,9 @@ func (i *codexAccountIndex) Find(keys []string, userID string) (*service.Account } // codexIdentityConflicts 判断 account: 键的命中是否把同一 ChatGPT 团队的两个 -// 不同成员误连到一起:双方都携带 user id 且不相等时视为冲突。任一侧缺少 -// user id 时保留匹配,使早期未记录 chatgpt_user_id 的存量账号仍能被更新 -// (并借助凭据合并回填 user id),而不是产生重复账号。 +// 不同成员误连到一起:双方都携带 user id 且不相等时视为冲突。存量索引侧 +// 仍保留 account 键,任一侧缺少 user id 时允许匹配,使含 refresh_token +// 的常规导入和 accessToken-only 账号升级为完整 OAuth 时仍能更新原账号。 func codexIdentityConflicts(key, userID, storedUserID string) bool { if !strings.HasPrefix(key, "account:") { return false @@ -948,8 +988,15 @@ func mergeCodexImportCredentials(existing, incoming map[string]any, item *codexI return out } if strings.TrimSpace(item.RefreshToken) == "" { - delete(out, "refresh_token") - delete(out, "client_id") + if codexCredentialString(existing, "refresh_token") == "" { + delete(out, "refresh_token") + delete(out, "client_id") + } else { + out["refresh_token"] = existing["refresh_token"] + if clientID, ok := existing["client_id"]; ok { + out["client_id"] = clientID + } + } } if strings.TrimSpace(item.IDToken) == "" { delete(out, "id_token") diff --git a/backend/internal/handler/admin/account_codex_import_test.go b/backend/internal/handler/admin/account_codex_import_test.go index f4ee5bd7db..a52463aa86 100644 --- a/backend/internal/handler/admin/account_codex_import_test.go +++ b/backend/internal/handler/admin/account_codex_import_test.go @@ -1,6 +1,7 @@ package admin import ( + "context" "encoding/base64" "encoding/json" "fmt" @@ -144,7 +145,7 @@ func TestNormalizeCodexSessionJSONExtractsCredentialsAndIgnoresSessionToken(t *t } } -func TestMergeCodexImportCredentialsClearsStaleRefreshFieldsWhenIncomingHasNoRefreshToken(t *testing.T) { +func TestMergeCodexImportCredentialsPreservesExistingRefreshFieldsWhenIncomingHasNoRefreshToken(t *testing.T) { existing := map[string]any{ "access_token": "old-access-token", "refresh_token": "old-refresh-token", @@ -171,11 +172,11 @@ func TestMergeCodexImportCredentialsClearsStaleRefreshFieldsWhenIncomingHasNoRef if merged["chatgpt_account_id"] != "acct-new" { t.Fatalf("chatgpt_account_id = %v, want acct-new", merged["chatgpt_account_id"]) } - if _, ok := merged["refresh_token"]; ok { - t.Fatalf("refresh_token should be cleared") + if merged["refresh_token"] != "old-refresh-token" { + t.Fatalf("refresh_token = %v, want old-refresh-token", merged["refresh_token"]) } - if _, ok := merged["client_id"]; ok { - t.Fatalf("client_id should be cleared") + if merged["client_id"] != "old-client-id" { + t.Fatalf("client_id = %v, want old-client-id", merged["client_id"]) } if _, ok := merged["id_token"]; ok { t.Fatalf("id_token should be cleared") @@ -301,9 +302,9 @@ func TestResolveCodexImportExpiryForNoRefreshTokenUsesEarlierRequestExpiry(t *te } func TestCodexIdentityKeysPreferStrongIdentifiers(t *testing.T) { - keys := buildCodexIdentityKeys("acct-1", "user-1", "same@example.com", "token") + keys := buildCodexImportIdentityKeys("acct-1", "user-1", "same@example.com", "token", "refresh") if len(keys) == 0 || keys[0] != "user:user-1" { - t.Fatalf("user key should have highest priority: %v", keys) + t.Fatalf("user key should have highest priority when refresh token exists: %v", keys) } if keys[len(keys)-1] != "account:acct-1" { t.Fatalf("shared account key should be the last fallback: %v", keys) @@ -314,7 +315,7 @@ func TestCodexIdentityKeysPreferStrongIdentifiers(t *testing.T) { } } - keys = buildCodexIdentityKeys("", "", "same@example.com", "token") + keys = buildCodexImportIdentityKeys("", "", "same@example.com", "token", "refresh") hasEmail := false for _, key := range keys { if key == "email:same@example.com" { @@ -324,6 +325,11 @@ func TestCodexIdentityKeysPreferStrongIdentifiers(t *testing.T) { if !hasEmail { t.Fatalf("weak identity should include email fallback: %v", keys) } + + keys = buildCodexImportIdentityKeys("acct-1", "user-1", "same@example.com", "token", "") + if len(keys) != 1 || !strings.HasPrefix(keys[0], "access:") { + t.Fatalf("accessToken-only identity should use only access fingerprint: %v", keys) + } } func TestCodexAccountIndexDoesNotMatchDifferentUsersInSameChatGPTAccount(t *testing.T) { @@ -333,35 +339,37 @@ func TestCodexAccountIndexDoesNotMatchDifferentUsersInSameChatGPTAccount(t *test "chatgpt_account_id": "team-1", "chatgpt_user_id": "user-1", "access_token": "token-1", + "refresh_token": "refresh-1", }, } index := buildCodexAccountIndex([]service.Account{existing}) - keys := buildCodexIdentityKeys("team-1", "user-2", "", "token-2") + keys := buildCodexImportIdentityKeys("team-1", "user-2", "", "token-2", "refresh-2") if got, _ := index.Find(keys, "user-2"); got != nil { t.Fatalf("Find matched account ID %d for a different chatgpt_user_id in the same team", got.ID) } - keys = buildCodexIdentityKeys("team-1", "user-1", "", "token-2") + keys = buildCodexImportIdentityKeys("team-1", "user-1", "", "token-2", "refresh-2") got, _ := index.Find(keys, "user-1") if got == nil || got.ID != existing.ID { t.Fatalf("Find by same chatgpt_user_id = %v, want account ID %d", got, existing.ID) } } -func TestCodexAccountIndexFallsBackToAccountKeyWhenUserIDMissing(t *testing.T) { - // 存量账号缺少 chatgpt_user_id:携带 user id 的重新导入应命中并更新(回填), - // 而不是创建重复账号。 +func TestCodexAccountIndexFallsBackToAccountKeyWhenRefreshTokenExistsAndUserIDMissing(t *testing.T) { + // 含 refresh_token 的常规导入沿用 a5638a4e 的兼容逻辑:存量账号缺少 + // chatgpt_user_id 时,携带 user id 的重新导入仍可命中并回填。 legacy := service.Account{ ID: 20, Credentials: map[string]any{ "chatgpt_account_id": "team-1", "access_token": "token-old", + "refresh_token": "refresh-old", }, } index := buildCodexAccountIndex([]service.Account{legacy}) - keys := buildCodexIdentityKeys("team-1", "user-1", "", "token-new") + keys := buildCodexImportIdentityKeys("team-1", "user-1", "", "token-new", "refresh-new") got, matchedKey := index.Find(keys, "user-1") if got == nil || got.ID != legacy.ID { t.Fatalf("Find legacy account without stored user id = %v, want account ID %d", got, legacy.ID) @@ -370,30 +378,59 @@ func TestCodexAccountIndexFallsBackToAccountKeyWhenUserIDMissing(t *testing.T) { t.Fatalf("matched key = %q, want account:team-1", matchedKey) } - // 反向:导入条目无法解析出 user id 时,仍应通过 account 键命中已有账号。 + // 反向:含 refresh_token 的导入条目无法解析出 user id 时,仍应通过 + // account 键命中已有账号,保持常规导入去重行为。 full := service.Account{ ID: 21, Credentials: map[string]any{ "chatgpt_account_id": "team-2", "chatgpt_user_id": "user-9", "access_token": "token-old", + "refresh_token": "refresh-old", }, } index = buildCodexAccountIndex([]service.Account{full}) - keys = buildCodexIdentityKeys("team-2", "", "", "token-opaque") + keys = buildCodexImportIdentityKeys("team-2", "", "", "token-opaque", "refresh-new") got, _ = index.Find(keys, "") if got == nil || got.ID != full.ID { t.Fatalf("Find by account key without entry user id = %v, want account ID %d", got, full.ID) } } +func TestCodexAccountIndexAccessTokenOnlyUsesTokenFingerprint(t *testing.T) { + existing := service.Account{ + ID: 22, + Credentials: map[string]any{ + "chatgpt_account_id": "team-1", + "chatgpt_user_id": "user-1", + "access_token": "token-old", + }, + } + index := buildCodexAccountIndex([]service.Account{existing}) + + keys := buildCodexImportIdentityKeys("team-1", "user-1", "", "token-new", "") + if got, matchedKey := index.Find(keys, "user-1"); got != nil { + t.Fatalf("accessToken-only import matched by %q despite different token: account ID %d", matchedKey, got.ID) + } + + keys = buildCodexImportIdentityKeys("team-1", "user-1", "", "token-old", "") + got, matchedKey := index.Find(keys, "user-1") + if got == nil || got.ID != existing.ID { + t.Fatalf("Find accessToken-only duplicate by fingerprint = %v, want account ID %d", got, existing.ID) + } + if !strings.HasPrefix(matchedKey, "access:") { + t.Fatalf("matched key = %q, want access fingerprint", matchedKey) + } +} + func TestCodexAccountIndexKeepsAllCandidatesForSharedAccountKey(t *testing.T) { legacy := service.Account{ ID: 30, Credentials: map[string]any{ "chatgpt_account_id": "team-1", "access_token": "token-legacy", + "refresh_token": "refresh-legacy", }, } member := service.Account{ @@ -402,10 +439,11 @@ func TestCodexAccountIndexKeepsAllCandidatesForSharedAccountKey(t *testing.T) { "chatgpt_account_id": "team-1", "chatgpt_user_id": "user-2", "access_token": "token-member", + "refresh_token": "refresh-member", }, } - // 无论索引构建顺序如何,携带新 user id 的条目都应跳过 user-2 的账号、 + // 无论索引构建顺序如何,携带新 user id 的条目都应跳过 user-2 的账号, // 命中缺少 user id 的存量账号,而不是因单一候选被遮蔽而落空。 for _, accounts := range [][]service.Account{ {member, legacy}, @@ -413,7 +451,7 @@ func TestCodexAccountIndexKeepsAllCandidatesForSharedAccountKey(t *testing.T) { } { index := buildCodexAccountIndex(accounts) - keys := buildCodexIdentityKeys("team-1", "user-1", "", "token-new") + keys := buildCodexImportIdentityKeys("team-1", "user-1", "", "token-new", "refresh-new") got, matchedKey := index.Find(keys, "user-1") if got == nil || got.ID != legacy.ID { t.Fatalf("Find with shared account key = %v, want legacy account ID %d", got, legacy.ID) @@ -422,7 +460,7 @@ func TestCodexAccountIndexKeepsAllCandidatesForSharedAccountKey(t *testing.T) { t.Fatalf("matched key = %q, want account:team-1", matchedKey) } - keys = buildCodexIdentityKeys("team-1", "user-2", "", "token-new") + keys = buildCodexImportIdentityKeys("team-1", "user-2", "", "token-new", "refresh-new") got, matchedKey = index.Find(keys, "user-2") if got == nil || got.ID != member.ID { t.Fatalf("Find by user key = %v, want member account ID %d", got, member.ID) @@ -449,18 +487,19 @@ func TestCodexAccountIndexUpsertReplacesSameAccount(t *testing.T) { "chatgpt_account_id": "team-1", "chatgpt_user_id": "user-1", "access_token": "token-new", + "refresh_token": "refresh-new", }, } index.Add(backfilled) // 回填后同一账号在 account 键下应被原位替换而非残留旧副本: // 其他成员的条目不应再通过旧副本(无 user id)命中该账号。 - keys := buildCodexIdentityKeys("team-1", "user-2", "", "token-other") - if got, _ := index.Find(keys, "user-2"); got != nil { - t.Fatalf("stale candidate matched after upsert: account ID %d", got.ID) + keys := buildCodexImportIdentityKeys("team-1", "user-2", "", "token-other", "refresh-other") + if got, matchedKey := index.Find(keys, "user-2"); got != nil { + t.Fatalf("stale candidate matched after upsert by %q: account ID %d", matchedKey, got.ID) } - keys = buildCodexIdentityKeys("team-1", "user-1", "", "token-other") + keys = buildCodexImportIdentityKeys("team-1", "user-1", "", "token-other", "refresh-other") got, _ := index.Find(keys, "user-1") if got == nil || got.ID != backfilled.ID { t.Fatalf("Find after upsert = %v, want account ID %d", got, backfilled.ID) @@ -472,28 +511,421 @@ func TestCodexAccountIndexUpsertReplacesSameAccount(t *testing.T) { func TestCodexIdentitySeenDistinguishesTeamMembers(t *testing.T) { seen := map[string]codexSeenIdentity{} - member1 := buildCodexIdentityKeys("team-1", "user-1", "", "token-1") + member1 := buildCodexImportIdentityKeys("team-1", "user-1", "", "token-1", "refresh-1") markCodexIdentitySeen(seen, member1, 1, "user-1") - member2 := buildCodexIdentityKeys("team-1", "user-2", "", "token-2") + member2 := buildCodexImportIdentityKeys("team-1", "user-2", "", "token-2", "refresh-2") if index, ok := firstSeenCodexIdentity(seen, member2, "user-2"); ok { t.Fatalf("different team member treated as duplicate of entry %d", index) } - again := buildCodexIdentityKeys("team-1", "user-1", "", "token-3") + again := buildCodexImportIdentityKeys("team-1", "user-1", "", "token-3", "refresh-3") index, ok := firstSeenCodexIdentity(seen, again, "user-1") if !ok || index != 1 { t.Fatalf("same user re-entry dedup = (%d, %v), want (1, true)", index, ok) } - // 无 user id 的条目与已见同 account 条目视为重复(保守跳过,与既有行为一致)。 - opaque := buildCodexIdentityKeys("team-1", "", "", "token-4") + // 无 user id 的条目不应因共享 account id 与已见团队成员互相去重; + // 只有相同 access token 指纹才视为重复。 + opaque := buildCodexImportIdentityKeys("team-1", "", "", "token-4", "") index, ok = firstSeenCodexIdentity(seen, opaque, "") - if !ok || index != 1 { - t.Fatalf("entry without user id dedup = (%d, %v), want (1, true)", index, ok) + if ok { + t.Fatalf("entry without user id dedup = (%d, %v), want no match", index, ok) } } +func TestNormalizeCodexImportUsesJWTSubForAccessTokenOnlyIdentity(t *testing.T) { + accessToken := buildCodexImportTestJWT(t, time.Now().Add(time.Hour), map[string]any{ + "sub": "user-from-access-token", + "https://api.openai.com/auth": map[string]any{ + "chatgpt_account_id": "workspace-1", + }, + }) + + item, err := normalizeCodexImportEntry(codexImportEntry{Index: 1, Value: accessToken}) + if err != nil { + t.Fatalf("normalizeCodexImportEntry error = %v", err) + } + if item.UserID != "user-from-access-token" { + t.Fatalf("UserID = %q, want JWT sub", item.UserID) + } + if len(item.IdentityKeys) != 1 || !strings.HasPrefix(item.IdentityKeys[0], "access:") { + t.Fatalf("IdentityKeys = %v, want access fingerprint only for accessToken-only import", item.IdentityKeys) + } + if got := item.Credentials["chatgpt_user_id"]; got != "user-from-access-token" { + t.Fatalf("credential chatgpt_user_id = %v, want JWT sub", got) + } +} + +func TestImportCodexSessionsAccessTokenOnlySameWorkspaceDifferentUsersCreatesTwoAccounts(t *testing.T) { + svc := newCodexImportMemoryAdminService(nil) + handler := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + req := CodexSessionImportRequest{SkipDefaultGroupBind: boolPtr(true)} + entries := []codexImportEntry{ + {Index: 1, Value: buildCodexAccessOnlyImportValue(t, "workspace-1", "user-1")}, + {Index: 2, Value: buildCodexAccessOnlyImportValue(t, "workspace-1", "user-2")}, + } + + result, err := handler.importCodexSessions(context.Background(), req, entries) + if err != nil { + t.Fatalf("importCodexSessions error = %v", err) + } + if result.Created != 2 || result.Updated != 0 || result.Skipped != 0 || result.Failed != 0 { + t.Fatalf("result = %+v, want two created accounts", result) + } + if len(svc.createdAccounts) != 2 { + t.Fatalf("created accounts = %d, want 2", len(svc.createdAccounts)) + } + if svc.createdAccounts[0].Credentials["chatgpt_user_id"] == svc.createdAccounts[1].Credentials["chatgpt_user_id"] { + t.Fatalf("created accounts share user id: %v", svc.createdAccounts) + } +} + +func TestImportCodexSessionsAccessTokenOnlySameWorkspaceAndUserDifferentTokensCreatesTwoAccounts(t *testing.T) { + svc := newCodexImportMemoryAdminService(nil) + handler := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + req := CodexSessionImportRequest{SkipDefaultGroupBind: boolPtr(true)} + entries := []codexImportEntry{ + {Index: 1, Value: map[string]any{ + "access_token": buildCodexImportTestJWT(t, time.Now().Add(time.Hour), map[string]any{ + "sub": "shared-user", + "jti": "token-1", + "https://api.openai.com/auth": map[string]any{ + "chatgpt_account_id": "workspace-1", + }, + }), + }}, + {Index: 2, Value: map[string]any{ + "access_token": buildCodexImportTestJWT(t, time.Now().Add(time.Hour), map[string]any{ + "sub": "shared-user", + "jti": "token-2", + "https://api.openai.com/auth": map[string]any{ + "chatgpt_account_id": "workspace-1", + }, + }), + }}, + } + + result, err := handler.importCodexSessions(context.Background(), req, entries) + if err != nil { + t.Fatalf("importCodexSessions error = %v", err) + } + if result.Created != 2 || result.Updated != 0 || result.Skipped != 0 || result.Failed != 0 { + t.Fatalf("result = %+v, want two created accounts", result) + } + if len(svc.createdAccounts) != 2 { + t.Fatalf("created accounts = %d, want 2", len(svc.createdAccounts)) + } +} + +func TestImportCodexSessionsAccessTokenOnlySameUserUpdatesExisting(t *testing.T) { + existingToken := buildCodexAccessToken(t, "workspace-1", "user-1", time.Now().Add(time.Hour)) + svc := newCodexImportMemoryAdminService([]service.Account{{ + ID: 10, + Name: "existing", + Platform: service.PlatformOpenAI, + Type: service.AccountTypeOAuth, + Credentials: map[string]any{ + "chatgpt_account_id": "workspace-1", + "chatgpt_user_id": "user-1", + "access_token": existingToken, + }, + }}) + handler := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + req := CodexSessionImportRequest{SkipDefaultGroupBind: boolPtr(true)} + entries := []codexImportEntry{ + {Index: 1, Value: map[string]any{"access_token": existingToken}}, + } + + result, err := handler.importCodexSessions(context.Background(), req, entries) + if err != nil { + t.Fatalf("importCodexSessions error = %v", err) + } + if result.Created != 0 || result.Updated != 1 || result.Failed != 0 { + t.Fatalf("result = %+v, want one updated account", result) + } + if len(svc.createdAccounts) != 0 { + t.Fatalf("created accounts = %d, want 0", len(svc.createdAccounts)) + } + if len(svc.updatedAccounts) != 1 || svc.updatedAccounts[0].id != 10 { + t.Fatalf("updated accounts = %+v, want account 10", svc.updatedAccounts) + } +} + +func TestImportCodexSessionsUpgradesAccessTokenOnlyAccountWithRefreshToken(t *testing.T) { + oldToken := buildCodexAccessTokenWithJTI(t, "workspace-1", "user-1", "old-token", time.Now().Add(time.Hour)) + newToken := buildCodexAccessTokenWithJTI(t, "workspace-1", "user-1", "new-token", time.Now().Add(time.Hour)) + svc := newCodexImportMemoryAdminService([]service.Account{{ + ID: 12, + Name: "existing", + Platform: service.PlatformOpenAI, + Type: service.AccountTypeOAuth, + Credentials: map[string]any{ + "chatgpt_account_id": "workspace-1", + "chatgpt_user_id": "user-1", + "access_token": oldToken, + }, + }}) + handler := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + req := CodexSessionImportRequest{SkipDefaultGroupBind: boolPtr(true)} + entries := []codexImportEntry{ + {Index: 1, Value: map[string]any{ + "access_token": newToken, + "refresh_token": "refresh-new", + }}, + } + + result, err := handler.importCodexSessions(context.Background(), req, entries) + if err != nil { + t.Fatalf("importCodexSessions error = %v", err) + } + if result.Created != 0 || result.Updated != 1 || result.Failed != 0 { + t.Fatalf("result = %+v, want one updated account", result) + } + if len(svc.updatedAccounts) != 1 || svc.updatedAccounts[0].id != 12 { + t.Fatalf("updated accounts = %+v, want account 12", svc.updatedAccounts) + } + if got := svc.updatedAccounts[0].input.Credentials["refresh_token"]; got != "refresh-new" { + t.Fatalf("updated refresh_token = %v, want refresh-new", got) + } +} + +func TestImportCodexSessionsAccessTokenOnlyPreservesExistingRefreshToken(t *testing.T) { + existingToken := buildCodexAccessToken(t, "workspace-1", "user-1", time.Now().Add(time.Hour)) + svc := newCodexImportMemoryAdminService([]service.Account{{ + ID: 13, + Name: "existing", + Platform: service.PlatformOpenAI, + Type: service.AccountTypeOAuth, + Credentials: map[string]any{ + "chatgpt_account_id": "workspace-1", + "chatgpt_user_id": "user-1", + "access_token": existingToken, + "refresh_token": "refresh-old", + "client_id": "client-old", + }, + }}) + handler := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + req := CodexSessionImportRequest{SkipDefaultGroupBind: boolPtr(true)} + entries := []codexImportEntry{ + {Index: 1, Value: map[string]any{"access_token": existingToken}}, + } + + result, err := handler.importCodexSessions(context.Background(), req, entries) + if err != nil { + t.Fatalf("importCodexSessions error = %v", err) + } + if result.Created != 0 || result.Updated != 1 || result.Failed != 0 { + t.Fatalf("result = %+v, want one updated account", result) + } + update := svc.updatedAccounts[0].input + if got := update.Credentials["refresh_token"]; got != "refresh-old" { + t.Fatalf("refresh_token = %v, want refresh-old", got) + } + if got := update.Credentials["client_id"]; got != "client-old" { + t.Fatalf("client_id = %v, want client-old", got) + } + if update.ExpiresAt != nil { + t.Fatalf("ExpiresAt = %v, want nil to preserve OAuth account expiry", *update.ExpiresAt) + } + if update.AutoPauseOnExpired != nil { + t.Fatalf("AutoPauseOnExpired = %v, want nil to preserve OAuth account scheduling", *update.AutoPauseOnExpired) + } +} + +func TestImportCodexSessionsBatchOldAccessTokenDoesNotRollbackRefreshToken(t *testing.T) { + oldToken := buildCodexAccessTokenWithJTI(t, "workspace-1", "user-1", "old-token", time.Now().Add(time.Hour)) + newToken := buildCodexAccessTokenWithJTI(t, "workspace-1", "user-1", "new-token", time.Now().Add(time.Hour)) + svc := newCodexImportMemoryAdminService([]service.Account{{ + ID: 14, + Name: "existing", + Platform: service.PlatformOpenAI, + Type: service.AccountTypeOAuth, + Credentials: map[string]any{ + "chatgpt_account_id": "workspace-1", + "chatgpt_user_id": "user-1", + "access_token": oldToken, + "refresh_token": "refresh-old", + }, + }}) + handler := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + req := CodexSessionImportRequest{SkipDefaultGroupBind: boolPtr(true)} + entries := []codexImportEntry{ + {Index: 1, Value: map[string]any{ + "access_token": newToken, + "refresh_token": "refresh-new", + }}, + {Index: 2, Value: map[string]any{"access_token": oldToken}}, + } + + result, err := handler.importCodexSessions(context.Background(), req, entries) + if err != nil { + t.Fatalf("importCodexSessions error = %v", err) + } + if result.Updated != 1 || result.Created != 1 || result.Failed != 0 { + t.Fatalf("result = %+v, want first item updated and stale access token created separately", result) + } + if len(svc.updatedAccounts) != 1 || svc.updatedAccounts[0].id != 14 { + t.Fatalf("updated accounts = %+v, want account 14 updated once", svc.updatedAccounts) + } + stored, err := svc.GetAccount(context.Background(), 14) + if err != nil { + t.Fatalf("GetAccount error = %v", err) + } + if got := stored.Credentials["access_token"]; got != newToken { + t.Fatalf("stored access_token rolled back = %v, want new token", got) + } + if got := stored.Credentials["refresh_token"]; got != "refresh-new" { + t.Fatalf("stored refresh_token = %v, want refresh-new", got) + } +} + +func TestImportCodexSessionsWithRefreshTokenKeepsExistingDedup(t *testing.T) { + existingToken := buildCodexAccessToken(t, "workspace-1", "user-1", time.Now().Add(time.Hour)) + svc := newCodexImportMemoryAdminService([]service.Account{{ + ID: 11, + Name: "existing", + Platform: service.PlatformOpenAI, + Type: service.AccountTypeOAuth, + Credentials: map[string]any{ + "chatgpt_account_id": "workspace-1", + "chatgpt_user_id": "user-1", + "access_token": existingToken, + "refresh_token": "refresh-old", + }, + }}) + handler := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + req := CodexSessionImportRequest{SkipDefaultGroupBind: boolPtr(true)} + entries := []codexImportEntry{ + {Index: 1, Value: buildCodexRefreshImportValue(t, "workspace-1", "user-1", "refresh-new")}, + } + + result, err := handler.importCodexSessions(context.Background(), req, entries) + if err != nil { + t.Fatalf("importCodexSessions error = %v", err) + } + if result.Created != 0 || result.Updated != 1 || result.Failed != 0 { + t.Fatalf("result = %+v, want one updated account", result) + } + if got := svc.updatedAccounts[0].input.Credentials["refresh_token"]; got != "refresh-new" { + t.Fatalf("updated refresh_token = %v, want refresh-new", got) + } +} + +type codexImportMemoryAdminService struct { + *stubAdminService + nextID int64 + updatedAccounts []struct { + id int64 + input *service.UpdateAccountInput + } +} + +func newCodexImportMemoryAdminService(accounts []service.Account) *codexImportMemoryAdminService { + stub := newStubAdminService() + stub.accounts = append([]service.Account(nil), accounts...) + return &codexImportMemoryAdminService{ + stubAdminService: stub, + nextID: 100, + } +} + +func (s *codexImportMemoryAdminService) CreateAccount(ctx context.Context, input *service.CreateAccountInput) (*service.Account, error) { + s.createdAccounts = append(s.createdAccounts, input) + if s.createAccountErr != nil { + return nil, s.createAccountErr + } + account := service.Account{ + ID: s.nextID, + Name: input.Name, + Platform: input.Platform, + Type: input.Type, + Status: service.StatusActive, + Credentials: cloneCodexImportTestMap(input.Credentials), + Extra: cloneCodexImportTestMap(input.Extra), + } + s.nextID++ + s.accounts = append(s.accounts, account) + return &account, nil +} + +func (s *codexImportMemoryAdminService) UpdateAccount(ctx context.Context, id int64, input *service.UpdateAccountInput) (*service.Account, error) { + s.updatedAccounts = append(s.updatedAccounts, struct { + id int64 + input *service.UpdateAccountInput + }{id: id, input: input}) + if s.updateAccountErr != nil { + return nil, s.updateAccountErr + } + for idx := range s.accounts { + if s.accounts[idx].ID == id { + s.accounts[idx].Credentials = cloneCodexImportTestMap(input.Credentials) + s.accounts[idx].Extra = cloneCodexImportTestMap(input.Extra) + return &s.accounts[idx], nil + } + } + account := service.Account{ID: id, Status: service.StatusActive, Credentials: cloneCodexImportTestMap(input.Credentials)} + return &account, nil +} + +func (s *codexImportMemoryAdminService) GetAccount(ctx context.Context, id int64) (*service.Account, error) { + for idx := range s.accounts { + if s.accounts[idx].ID == id { + return &s.accounts[idx], nil + } + } + return s.stubAdminService.GetAccount(ctx, id) +} + +func buildCodexAccessOnlyImportValue(t *testing.T, accountID, userID string) map[string]any { + t.Helper() + return map[string]any{ + "access_token": buildCodexAccessToken(t, accountID, userID, time.Now().Add(time.Hour)), + } +} + +func buildCodexRefreshImportValue(t *testing.T, accountID, userID, refreshToken string) map[string]any { + t.Helper() + return map[string]any{ + "access_token": buildCodexAccessToken(t, accountID, userID, time.Now().Add(time.Hour)), + "refresh_token": refreshToken, + } +} + +func buildCodexAccessToken(t *testing.T, accountID, userID string, exp time.Time) string { + t.Helper() + return buildCodexAccessTokenWithJTI(t, accountID, userID, "", exp) +} + +func buildCodexAccessTokenWithJTI(t *testing.T, accountID, userID, jti string, exp time.Time) string { + t.Helper() + claims := map[string]any{ + "sub": userID, + "https://api.openai.com/auth": map[string]any{ + "chatgpt_account_id": accountID, + }, + } + if jti != "" { + claims["jti"] = jti + } + return buildCodexImportTestJWT(t, exp, claims) +} + +func cloneCodexImportTestMap(input map[string]any) map[string]any { + if input == nil { + return nil + } + out := make(map[string]any, len(input)) + for key, value := range input { + out[key] = value + } + return out +} + +func boolPtr(v bool) *bool { + return &v +} + func buildCodexImportTestJWT(t *testing.T, exp time.Time, extraClaims map[string]any) string { t.Helper() header := map[string]any{