mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-24 16:05:44 +08:00
fix(admin): avoid merging Codex access-only imports
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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{
|
||||
|
||||
Reference in New Issue
Block a user