account_codex_import_test.go 36 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023
  1. package admin
  2. import (
  3. "context"
  4. "encoding/base64"
  5. "encoding/json"
  6. "fmt"
  7. "strings"
  8. "testing"
  9. "time"
  10. "github.com/Wei-Shaw/sub2api/internal/service"
  11. )
  12. func TestParseCodexSessionImportEntriesSupportsRawTokenJSONAndArray(t *testing.T) {
  13. token1 := "raw-access-token-1"
  14. token2 := buildCodexImportTestJWT(t, time.Now().Add(time.Hour), map[string]any{
  15. "email": "json@example.com",
  16. })
  17. token3 := "raw-access-token-3"
  18. req := CodexSessionImportRequest{
  19. Content: fmt.Sprintf("%s\n{\"accessToken\":%q}\n[%q]", token1, token2, token3),
  20. }
  21. entries, err := parseCodexSessionImportEntries(req)
  22. if err != nil {
  23. t.Fatalf("parseCodexSessionImportEntries error = %v", err)
  24. }
  25. if len(entries) != 3 {
  26. t.Fatalf("len(entries) = %d, want 3", len(entries))
  27. }
  28. first, err := normalizeCodexImportEntry(entries[0])
  29. if err != nil {
  30. t.Fatalf("normalize raw token error = %v", err)
  31. }
  32. if first.Credentials["access_token"] != token1 {
  33. t.Fatalf("raw token access_token = %v, want %s", first.Credentials["access_token"], token1)
  34. }
  35. second, err := normalizeCodexImportEntry(entries[1])
  36. if err != nil {
  37. t.Fatalf("normalize json token error = %v", err)
  38. }
  39. if second.Email != "json@example.com" {
  40. t.Fatalf("email = %q, want json@example.com", second.Email)
  41. }
  42. third, err := normalizeCodexImportEntry(entries[2])
  43. if err != nil {
  44. t.Fatalf("normalize array token error = %v", err)
  45. }
  46. if third.Credentials["access_token"] != token3 {
  47. t.Fatalf("array token access_token = %v, want %s", third.Credentials["access_token"], token3)
  48. }
  49. }
  50. func TestParseCodexSessionImportEntriesFallsBackToLineModeForMixedJSONAndToken(t *testing.T) {
  51. req := CodexSessionImportRequest{
  52. Content: "{\"accessToken\":\"json-line-token\"}\nraw-line-token",
  53. }
  54. entries, err := parseCodexSessionImportEntries(req)
  55. if err != nil {
  56. t.Fatalf("parseCodexSessionImportEntries error = %v", err)
  57. }
  58. if len(entries) != 2 {
  59. t.Fatalf("len(entries) = %d, want 2", len(entries))
  60. }
  61. first, err := normalizeCodexImportEntry(entries[0])
  62. if err != nil {
  63. t.Fatalf("normalize json line error = %v", err)
  64. }
  65. if first.Credentials["access_token"] != "json-line-token" {
  66. t.Fatalf("json line access_token = %v, want json-line-token", first.Credentials["access_token"])
  67. }
  68. second, err := normalizeCodexImportEntry(entries[1])
  69. if err != nil {
  70. t.Fatalf("normalize raw line error = %v", err)
  71. }
  72. if second.Credentials["access_token"] != "raw-line-token" {
  73. t.Fatalf("raw line access_token = %v, want raw-line-token", second.Credentials["access_token"])
  74. }
  75. }
  76. func TestNormalizeCodexSessionJSONExtractsCredentialsAndIgnoresSessionToken(t *testing.T) {
  77. accessToken := buildCodexImportTestJWT(t, time.Now().Add(time.Hour), map[string]any{
  78. "email": "claim@example.com",
  79. "https://api.openai.com/auth": map[string]any{
  80. "chatgpt_account_id": "acct-from-claim",
  81. "chatgpt_user_id": "user-from-claim",
  82. "chatgpt_plan_type": "plus",
  83. "poid": "org-from-claim",
  84. },
  85. })
  86. raw := map[string]any{
  87. "user": map[string]any{
  88. "id": "user-from-json",
  89. "name": "Sup OO",
  90. "email": "json@example.com",
  91. "image": "https://example.com/avatar.png",
  92. },
  93. "account": map[string]any{
  94. "id": "acct-from-json",
  95. "planType": "free",
  96. },
  97. "accessToken": accessToken,
  98. "sessionToken": "secret-session-token",
  99. "expires": "2026-08-05T13:40:42.836Z",
  100. }
  101. item, err := normalizeCodexImportEntry(codexImportEntry{Index: 1, Value: raw})
  102. if err != nil {
  103. t.Fatalf("normalizeCodexImportEntry error = %v", err)
  104. }
  105. if item.Credentials["access_token"] != accessToken {
  106. t.Fatalf("access_token not stored")
  107. }
  108. if item.Credentials["email"] != "json@example.com" {
  109. t.Fatalf("email = %v, want json@example.com", item.Credentials["email"])
  110. }
  111. if item.Credentials["chatgpt_account_id"] != "acct-from-json" {
  112. t.Fatalf("chatgpt_account_id = %v, want acct-from-json", item.Credentials["chatgpt_account_id"])
  113. }
  114. if item.Credentials["chatgpt_user_id"] != "user-from-json" {
  115. t.Fatalf("chatgpt_user_id = %v, want user-from-json", item.Credentials["chatgpt_user_id"])
  116. }
  117. if item.Credentials["plan_type"] != "free" {
  118. t.Fatalf("plan_type = %v, want free", item.Credentials["plan_type"])
  119. }
  120. if _, ok := item.Credentials["session_token"]; ok {
  121. t.Fatalf("session_token should not be written to credentials")
  122. }
  123. if item.Extra["session_token_present"] != true {
  124. t.Fatalf("session_token_present = %v, want true", item.Extra["session_token_present"])
  125. }
  126. if item.Extra["session_expires_at"] != "2026-08-05T13:40:42Z" {
  127. t.Fatalf("session_expires_at = %v", item.Extra["session_expires_at"])
  128. }
  129. if item.TokenExpiresAt == nil {
  130. t.Fatalf("TokenExpiresAt should be parsed from accessToken")
  131. }
  132. }
  133. func TestMergeCodexImportCredentialsPreservesExistingRefreshFieldsWhenIncomingHasNoRefreshToken(t *testing.T) {
  134. existing := map[string]any{
  135. "access_token": "old-access-token",
  136. "refresh_token": "old-refresh-token",
  137. "client_id": "old-client-id",
  138. "id_token": "old-id-token",
  139. "model_mapping": map[string]any{"from": "existing"},
  140. "chatgpt_account_id": "acct-old",
  141. "unrelated_existing": "keep",
  142. }
  143. incoming := map[string]any{
  144. "access_token": "new-access-token",
  145. "expires_at": "2026-08-05T13:40:42Z",
  146. "chatgpt_account_id": "acct-new",
  147. }
  148. item := &codexImportAccount{
  149. AccessToken: "new-access-token",
  150. }
  151. merged := mergeCodexImportCredentials(existing, incoming, item)
  152. if merged["access_token"] != "new-access-token" {
  153. t.Fatalf("access_token = %v, want new-access-token", merged["access_token"])
  154. }
  155. if merged["chatgpt_account_id"] != "acct-new" {
  156. t.Fatalf("chatgpt_account_id = %v, want acct-new", merged["chatgpt_account_id"])
  157. }
  158. if merged["refresh_token"] != "old-refresh-token" {
  159. t.Fatalf("refresh_token = %v, want old-refresh-token", merged["refresh_token"])
  160. }
  161. if merged["client_id"] != "old-client-id" {
  162. t.Fatalf("client_id = %v, want old-client-id", merged["client_id"])
  163. }
  164. if _, ok := merged["id_token"]; ok {
  165. t.Fatalf("id_token should be cleared")
  166. }
  167. if merged["unrelated_existing"] != "keep" {
  168. t.Fatalf("unrelated_existing = %v, want keep", merged["unrelated_existing"])
  169. }
  170. if _, ok := merged["model_mapping"]; !ok {
  171. t.Fatalf("model_mapping should be preserved")
  172. }
  173. }
  174. func TestMergeCodexImportCredentialsKeepsRefreshFieldsWhenIncomingHasRefreshToken(t *testing.T) {
  175. existing := map[string]any{
  176. "refresh_token": "old-refresh-token",
  177. "client_id": "old-client-id",
  178. "id_token": "old-id-token",
  179. }
  180. incoming := map[string]any{
  181. "access_token": "new-access-token",
  182. "refresh_token": "new-refresh-token",
  183. "client_id": "new-client-id",
  184. "id_token": "new-id-token",
  185. }
  186. item := &codexImportAccount{
  187. AccessToken: "new-access-token",
  188. RefreshToken: "new-refresh-token",
  189. IDToken: "new-id-token",
  190. }
  191. merged := mergeCodexImportCredentials(existing, incoming, item)
  192. if merged["refresh_token"] != "new-refresh-token" {
  193. t.Fatalf("refresh_token = %v, want new-refresh-token", merged["refresh_token"])
  194. }
  195. if merged["client_id"] != "new-client-id" {
  196. t.Fatalf("client_id = %v, want new-client-id", merged["client_id"])
  197. }
  198. if merged["id_token"] != "new-id-token" {
  199. t.Fatalf("id_token = %v, want new-id-token", merged["id_token"])
  200. }
  201. }
  202. func TestNormalizeCodexImportRejectsExpiredAccessToken(t *testing.T) {
  203. expiredToken := buildCodexImportTestJWT(t, time.Now().Add(-time.Hour), map[string]any{})
  204. _, err := normalizeCodexImportEntry(codexImportEntry{Index: 1, Value: expiredToken})
  205. if err == nil {
  206. t.Fatal("normalizeCodexImportEntry error = nil, want expired token error")
  207. }
  208. if !strings.Contains(err.Error(), "已过期") {
  209. t.Fatalf("error = %v, want expired token message", err)
  210. }
  211. }
  212. func TestResolveCodexImportExpiryForNoRefreshTokenUsesTokenExpiry(t *testing.T) {
  213. tokenExpiresAt := time.Now().Add(time.Hour).UTC()
  214. item := &codexImportAccount{
  215. AccessToken: "access-token",
  216. Credentials: map[string]any{"access_token": "access-token"},
  217. TokenExpiresAt: &tokenExpiresAt,
  218. WarningTexts: []string{},
  219. }
  220. disabled := false
  221. req := CodexSessionImportRequest{AutoPauseOnExpired: &disabled}
  222. accountExpiresAt, credentialExpiresAt, autoPause, warnings, err := resolveCodexImportExpiry(req, item)
  223. if err != nil {
  224. t.Fatalf("resolveCodexImportExpiry error = %v", err)
  225. }
  226. if accountExpiresAt == nil || *accountExpiresAt != tokenExpiresAt.Unix() {
  227. t.Fatalf("account expires_at = %v, want %d", accountExpiresAt, tokenExpiresAt.Unix())
  228. }
  229. if credentialExpiresAt == nil || credentialExpiresAt.Unix() != tokenExpiresAt.Unix() {
  230. t.Fatalf("credential expires_at = %v, want %s", credentialExpiresAt, tokenExpiresAt)
  231. }
  232. if autoPause == nil || !*autoPause {
  233. t.Fatalf("autoPause = %v, want true", autoPause)
  234. }
  235. if len(warnings) == 0 {
  236. t.Fatalf("warnings should not be empty")
  237. }
  238. }
  239. func TestResolveCodexImportExpiryForNoRefreshTokenRequiresExpiry(t *testing.T) {
  240. item := &codexImportAccount{
  241. AccessToken: "opaque-access-token",
  242. Credentials: map[string]any{"access_token": "opaque-access-token"},
  243. WarningTexts: []string{},
  244. }
  245. _, _, _, _, err := resolveCodexImportExpiry(CodexSessionImportRequest{}, item)
  246. if err == nil {
  247. t.Fatal("resolveCodexImportExpiry error = nil, want missing expiry error")
  248. }
  249. if !strings.Contains(err.Error(), "无法解析 accessToken 过期时间") {
  250. t.Fatalf("error = %v, want missing expiry message", err)
  251. }
  252. }
  253. func TestResolveCodexImportExpiryForNoRefreshTokenUsesEarlierRequestExpiry(t *testing.T) {
  254. tokenExpiresAt := time.Now().Add(2 * time.Hour).UTC()
  255. requestExpiresAt := time.Now().Add(time.Hour).UTC()
  256. item := &codexImportAccount{
  257. AccessToken: "access-token",
  258. Credentials: map[string]any{"access_token": "access-token"},
  259. TokenExpiresAt: &tokenExpiresAt,
  260. WarningTexts: []string{},
  261. }
  262. reqUnix := requestExpiresAt.Unix()
  263. req := CodexSessionImportRequest{ExpiresAt: &reqUnix}
  264. accountExpiresAt, credentialExpiresAt, _, _, err := resolveCodexImportExpiry(req, item)
  265. if err != nil {
  266. t.Fatalf("resolveCodexImportExpiry error = %v", err)
  267. }
  268. if accountExpiresAt == nil || *accountExpiresAt != requestExpiresAt.Unix() {
  269. t.Fatalf("account expires_at = %v, want %d", accountExpiresAt, requestExpiresAt.Unix())
  270. }
  271. if credentialExpiresAt == nil || credentialExpiresAt.Unix() != requestExpiresAt.Unix() {
  272. t.Fatalf("credential expires_at = %v, want %s", credentialExpiresAt, requestExpiresAt)
  273. }
  274. }
  275. func TestCodexIdentityKeysPreferStrongIdentifiers(t *testing.T) {
  276. keys := buildCodexImportIdentityKeys("acct-1", "user-1", "same@example.com", "token", "refresh")
  277. if len(keys) == 0 || keys[0] != "user:user-1" {
  278. t.Fatalf("user key should have highest priority when refresh token exists: %v", keys)
  279. }
  280. if keys[len(keys)-1] != "account:acct-1" {
  281. t.Fatalf("shared account key should be the last fallback: %v", keys)
  282. }
  283. for _, key := range keys {
  284. if strings.HasPrefix(key, "email:") {
  285. t.Fatalf("strong identity should not include email fallback: %v", keys)
  286. }
  287. }
  288. keys = buildCodexImportIdentityKeys("", "", "same@example.com", "token", "refresh")
  289. hasEmail := false
  290. for _, key := range keys {
  291. if key == "email:same@example.com" {
  292. hasEmail = true
  293. }
  294. }
  295. if !hasEmail {
  296. t.Fatalf("weak identity should include email fallback: %v", keys)
  297. }
  298. keys = buildCodexImportIdentityKeys("acct-1", "user-1", "same@example.com", "token", "")
  299. if len(keys) != 1 || !strings.HasPrefix(keys[0], "access:") {
  300. t.Fatalf("accessToken-only identity should use only access fingerprint: %v", keys)
  301. }
  302. }
  303. func TestCodexAccountIndexDoesNotMatchDifferentUsersInSameChatGPTAccount(t *testing.T) {
  304. existing := service.Account{
  305. ID: 10,
  306. Credentials: map[string]any{
  307. "chatgpt_account_id": "team-1",
  308. "chatgpt_user_id": "user-1",
  309. "access_token": "token-1",
  310. "refresh_token": "refresh-1",
  311. },
  312. }
  313. index := buildCodexAccountIndex([]service.Account{existing})
  314. keys := buildCodexImportIdentityKeys("team-1", "user-2", "", "token-2", "refresh-2")
  315. if got, _ := index.Find(keys, "user-2"); got != nil {
  316. t.Fatalf("Find matched account ID %d for a different chatgpt_user_id in the same team", got.ID)
  317. }
  318. keys = buildCodexImportIdentityKeys("team-1", "user-1", "", "token-2", "refresh-2")
  319. got, _ := index.Find(keys, "user-1")
  320. if got == nil || got.ID != existing.ID {
  321. t.Fatalf("Find by same chatgpt_user_id = %v, want account ID %d", got, existing.ID)
  322. }
  323. }
  324. func TestCodexAccountIndexFallsBackToAccountKeyWhenRefreshTokenExistsAndUserIDMissing(t *testing.T) {
  325. // 含 refresh_token 的常规导入沿用 a5638a4e 的兼容逻辑:存量账号缺少
  326. // chatgpt_user_id 时,携带 user id 的重新导入仍可命中并回填。
  327. legacy := service.Account{
  328. ID: 20,
  329. Credentials: map[string]any{
  330. "chatgpt_account_id": "team-1",
  331. "access_token": "token-old",
  332. "refresh_token": "refresh-old",
  333. },
  334. }
  335. index := buildCodexAccountIndex([]service.Account{legacy})
  336. keys := buildCodexImportIdentityKeys("team-1", "user-1", "", "token-new", "refresh-new")
  337. got, matchedKey := index.Find(keys, "user-1")
  338. if got == nil || got.ID != legacy.ID {
  339. t.Fatalf("Find legacy account without stored user id = %v, want account ID %d", got, legacy.ID)
  340. }
  341. if matchedKey != "account:team-1" {
  342. t.Fatalf("matched key = %q, want account:team-1", matchedKey)
  343. }
  344. // 反向:含 refresh_token 的导入条目无法解析出 user id 时,仍应通过
  345. // account 键命中已有账号,保持常规导入去重行为。
  346. full := service.Account{
  347. ID: 21,
  348. Credentials: map[string]any{
  349. "chatgpt_account_id": "team-2",
  350. "chatgpt_user_id": "user-9",
  351. "access_token": "token-old",
  352. "refresh_token": "refresh-old",
  353. },
  354. }
  355. index = buildCodexAccountIndex([]service.Account{full})
  356. keys = buildCodexImportIdentityKeys("team-2", "", "", "token-opaque", "refresh-new")
  357. got, _ = index.Find(keys, "")
  358. if got == nil || got.ID != full.ID {
  359. t.Fatalf("Find by account key without entry user id = %v, want account ID %d", got, full.ID)
  360. }
  361. }
  362. func TestCodexAccountIndexAccessTokenOnlyUsesTokenFingerprint(t *testing.T) {
  363. existing := service.Account{
  364. ID: 22,
  365. Credentials: map[string]any{
  366. "chatgpt_account_id": "team-1",
  367. "chatgpt_user_id": "user-1",
  368. "access_token": "token-old",
  369. },
  370. }
  371. index := buildCodexAccountIndex([]service.Account{existing})
  372. keys := buildCodexImportIdentityKeys("team-1", "user-1", "", "token-new", "")
  373. if got, matchedKey := index.Find(keys, "user-1"); got != nil {
  374. t.Fatalf("accessToken-only import matched by %q despite different token: account ID %d", matchedKey, got.ID)
  375. }
  376. keys = buildCodexImportIdentityKeys("team-1", "user-1", "", "token-old", "")
  377. got, matchedKey := index.Find(keys, "user-1")
  378. if got == nil || got.ID != existing.ID {
  379. t.Fatalf("Find accessToken-only duplicate by fingerprint = %v, want account ID %d", got, existing.ID)
  380. }
  381. if !strings.HasPrefix(matchedKey, "access:") {
  382. t.Fatalf("matched key = %q, want access fingerprint", matchedKey)
  383. }
  384. }
  385. func TestCodexAccountIndexKeepsAllCandidatesForSharedAccountKey(t *testing.T) {
  386. legacy := service.Account{
  387. ID: 30,
  388. Credentials: map[string]any{
  389. "chatgpt_account_id": "team-1",
  390. "access_token": "token-legacy",
  391. "refresh_token": "refresh-legacy",
  392. },
  393. }
  394. member := service.Account{
  395. ID: 31,
  396. Credentials: map[string]any{
  397. "chatgpt_account_id": "team-1",
  398. "chatgpt_user_id": "user-2",
  399. "access_token": "token-member",
  400. "refresh_token": "refresh-member",
  401. },
  402. }
  403. // 无论索引构建顺序如何,携带新 user id 的条目都应跳过 user-2 的账号,
  404. // 命中缺少 user id 的存量账号,而不是因单一候选被遮蔽而落空。
  405. for _, accounts := range [][]service.Account{
  406. {member, legacy},
  407. {legacy, member},
  408. } {
  409. index := buildCodexAccountIndex(accounts)
  410. keys := buildCodexImportIdentityKeys("team-1", "user-1", "", "token-new", "refresh-new")
  411. got, matchedKey := index.Find(keys, "user-1")
  412. if got == nil || got.ID != legacy.ID {
  413. t.Fatalf("Find with shared account key = %v, want legacy account ID %d", got, legacy.ID)
  414. }
  415. if matchedKey != "account:team-1" {
  416. t.Fatalf("matched key = %q, want account:team-1", matchedKey)
  417. }
  418. keys = buildCodexImportIdentityKeys("team-1", "user-2", "", "token-new", "refresh-new")
  419. got, matchedKey = index.Find(keys, "user-2")
  420. if got == nil || got.ID != member.ID {
  421. t.Fatalf("Find by user key = %v, want member account ID %d", got, member.ID)
  422. }
  423. if matchedKey != "user:user-2" {
  424. t.Fatalf("matched key = %q, want user:user-2", matchedKey)
  425. }
  426. }
  427. }
  428. func TestCodexAccountIndexUpsertReplacesSameAccount(t *testing.T) {
  429. legacy := service.Account{
  430. ID: 40,
  431. Credentials: map[string]any{
  432. "chatgpt_account_id": "team-1",
  433. "access_token": "token-old",
  434. },
  435. }
  436. index := buildCodexAccountIndex([]service.Account{legacy})
  437. backfilled := service.Account{
  438. ID: 40,
  439. Credentials: map[string]any{
  440. "chatgpt_account_id": "team-1",
  441. "chatgpt_user_id": "user-1",
  442. "access_token": "token-new",
  443. "refresh_token": "refresh-new",
  444. },
  445. }
  446. index.Add(backfilled)
  447. // 回填后同一账号在 account 键下应被原位替换而非残留旧副本:
  448. // 其他成员的条目不应再通过旧副本(无 user id)命中该账号。
  449. keys := buildCodexImportIdentityKeys("team-1", "user-2", "", "token-other", "refresh-other")
  450. if got, matchedKey := index.Find(keys, "user-2"); got != nil {
  451. t.Fatalf("stale candidate matched after upsert by %q: account ID %d", matchedKey, got.ID)
  452. }
  453. keys = buildCodexImportIdentityKeys("team-1", "user-1", "", "token-other", "refresh-other")
  454. got, _ := index.Find(keys, "user-1")
  455. if got == nil || got.ID != backfilled.ID {
  456. t.Fatalf("Find after upsert = %v, want account ID %d", got, backfilled.ID)
  457. }
  458. if uid := codexCredentialString(got.Credentials, "chatgpt_user_id"); uid != "user-1" {
  459. t.Fatalf("upsert did not replace credentials, chatgpt_user_id = %q", uid)
  460. }
  461. }
  462. func TestCodexAccountIndexUpdateRemovesAllPreviousKeys(t *testing.T) {
  463. legacy := service.Account{
  464. ID: 50,
  465. Credentials: map[string]any{
  466. "chatgpt_account_id": "team-old",
  467. "chatgpt_user_id": "user-old",
  468. "email": "old@example.com",
  469. "access_token": "access-old",
  470. "agent_runtime_id": "runtime-old",
  471. },
  472. }
  473. index := buildCodexAccountIndex([]service.Account{legacy})
  474. updated := service.Account{
  475. ID: 50,
  476. Credentials: map[string]any{
  477. "chatgpt_account_id": "team-new",
  478. "chatgpt_user_id": "user-new",
  479. "email": "new@example.com",
  480. "access_token": "access-new",
  481. "agent_runtime_id": "runtime-new",
  482. },
  483. }
  484. index.Add(updated)
  485. oldKeys := append(buildCodexStoredIdentityKeys("team-old", "user-old", "old@example.com", "access-old"), "agent:runtime-old")
  486. for _, key := range oldKeys {
  487. if got, matchedKey := index.Find([]string{key}, "user-old"); got != nil {
  488. t.Fatalf("stale account matched by %q: account ID %d", matchedKey, got.ID)
  489. }
  490. }
  491. newKeys := append(buildCodexStoredIdentityKeys("team-new", "user-new", "new@example.com", "access-new"), "agent:runtime-new")
  492. for _, key := range newKeys {
  493. got, matchedKey := index.Find([]string{key}, "user-new")
  494. if got == nil || got.ID != updated.ID {
  495. t.Fatalf("updated account not found by %q: account=%v matched=%q", key, got, matchedKey)
  496. }
  497. }
  498. }
  499. func TestCodexAccountIndexUpdatePreservesSharedKeyCandidateOrder(t *testing.T) {
  500. first := service.Account{
  501. ID: 60,
  502. Credentials: map[string]any{
  503. "chatgpt_account_id": "team-shared",
  504. "access_token": "access-first-old",
  505. },
  506. }
  507. second := service.Account{
  508. ID: 61,
  509. Credentials: map[string]any{
  510. "chatgpt_account_id": "team-shared",
  511. "access_token": "access-second",
  512. },
  513. }
  514. index := buildCodexAccountIndex([]service.Account{first, second})
  515. first.Credentials["access_token"] = "access-first-new"
  516. index.Add(first)
  517. got, matchedKey := index.Find([]string{"account:team-shared"}, "")
  518. if got == nil || got.ID != first.ID {
  519. t.Fatalf("shared key candidate order changed after update: account=%v matched=%q", got, matchedKey)
  520. }
  521. }
  522. func TestCodexIdentitySeenDistinguishesTeamMembers(t *testing.T) {
  523. seen := map[string]codexSeenIdentity{}
  524. member1 := buildCodexImportIdentityKeys("team-1", "user-1", "", "token-1", "refresh-1")
  525. markCodexIdentitySeen(seen, member1, 1, "user-1")
  526. member2 := buildCodexImportIdentityKeys("team-1", "user-2", "", "token-2", "refresh-2")
  527. if index, ok := firstSeenCodexIdentity(seen, member2, "user-2"); ok {
  528. t.Fatalf("different team member treated as duplicate of entry %d", index)
  529. }
  530. again := buildCodexImportIdentityKeys("team-1", "user-1", "", "token-3", "refresh-3")
  531. index, ok := firstSeenCodexIdentity(seen, again, "user-1")
  532. if !ok || index != 1 {
  533. t.Fatalf("same user re-entry dedup = (%d, %v), want (1, true)", index, ok)
  534. }
  535. // 无 user id 的条目不应因共享 account id 与已见团队成员互相去重;
  536. // 只有相同 access token 指纹才视为重复。
  537. opaque := buildCodexImportIdentityKeys("team-1", "", "", "token-4", "")
  538. index, ok = firstSeenCodexIdentity(seen, opaque, "")
  539. if ok {
  540. t.Fatalf("entry without user id dedup = (%d, %v), want no match", index, ok)
  541. }
  542. }
  543. func TestNormalizeCodexImportUsesJWTSubForAccessTokenOnlyIdentity(t *testing.T) {
  544. accessToken := buildCodexImportTestJWT(t, time.Now().Add(time.Hour), map[string]any{
  545. "sub": "user-from-access-token",
  546. "https://api.openai.com/auth": map[string]any{
  547. "chatgpt_account_id": "workspace-1",
  548. },
  549. })
  550. item, err := normalizeCodexImportEntry(codexImportEntry{Index: 1, Value: accessToken})
  551. if err != nil {
  552. t.Fatalf("normalizeCodexImportEntry error = %v", err)
  553. }
  554. if item.UserID != "user-from-access-token" {
  555. t.Fatalf("UserID = %q, want JWT sub", item.UserID)
  556. }
  557. if len(item.IdentityKeys) != 1 || !strings.HasPrefix(item.IdentityKeys[0], "access:") {
  558. t.Fatalf("IdentityKeys = %v, want access fingerprint only for accessToken-only import", item.IdentityKeys)
  559. }
  560. if got := item.Credentials["chatgpt_user_id"]; got != "user-from-access-token" {
  561. t.Fatalf("credential chatgpt_user_id = %v, want JWT sub", got)
  562. }
  563. }
  564. func TestImportCodexSessionsAccessTokenOnlySameWorkspaceDifferentUsersCreatesTwoAccounts(t *testing.T) {
  565. svc := newCodexImportMemoryAdminService(nil)
  566. handler := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
  567. req := CodexSessionImportRequest{SkipDefaultGroupBind: boolPtr(true)}
  568. entries := []codexImportEntry{
  569. {Index: 1, Value: buildCodexAccessOnlyImportValue(t, "workspace-1", "user-1")},
  570. {Index: 2, Value: buildCodexAccessOnlyImportValue(t, "workspace-1", "user-2")},
  571. }
  572. result, err := handler.importCodexSessions(context.Background(), req, entries)
  573. if err != nil {
  574. t.Fatalf("importCodexSessions error = %v", err)
  575. }
  576. if result.Created != 2 || result.Updated != 0 || result.Skipped != 0 || result.Failed != 0 {
  577. t.Fatalf("result = %+v, want two created accounts", result)
  578. }
  579. if len(svc.createdAccounts) != 2 {
  580. t.Fatalf("created accounts = %d, want 2", len(svc.createdAccounts))
  581. }
  582. if svc.createdAccounts[0].Credentials["chatgpt_user_id"] == svc.createdAccounts[1].Credentials["chatgpt_user_id"] {
  583. t.Fatalf("created accounts share user id: %v", svc.createdAccounts)
  584. }
  585. }
  586. func TestImportCodexSessionsAccessTokenOnlySameWorkspaceAndUserDifferentTokensCreatesTwoAccounts(t *testing.T) {
  587. svc := newCodexImportMemoryAdminService(nil)
  588. handler := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
  589. req := CodexSessionImportRequest{SkipDefaultGroupBind: boolPtr(true)}
  590. entries := []codexImportEntry{
  591. {Index: 1, Value: map[string]any{
  592. "access_token": buildCodexImportTestJWT(t, time.Now().Add(time.Hour), map[string]any{
  593. "sub": "shared-user",
  594. "jti": "token-1",
  595. "https://api.openai.com/auth": map[string]any{
  596. "chatgpt_account_id": "workspace-1",
  597. },
  598. }),
  599. }},
  600. {Index: 2, Value: map[string]any{
  601. "access_token": buildCodexImportTestJWT(t, time.Now().Add(time.Hour), map[string]any{
  602. "sub": "shared-user",
  603. "jti": "token-2",
  604. "https://api.openai.com/auth": map[string]any{
  605. "chatgpt_account_id": "workspace-1",
  606. },
  607. }),
  608. }},
  609. }
  610. result, err := handler.importCodexSessions(context.Background(), req, entries)
  611. if err != nil {
  612. t.Fatalf("importCodexSessions error = %v", err)
  613. }
  614. if result.Created != 2 || result.Updated != 0 || result.Skipped != 0 || result.Failed != 0 {
  615. t.Fatalf("result = %+v, want two created accounts", result)
  616. }
  617. if len(svc.createdAccounts) != 2 {
  618. t.Fatalf("created accounts = %d, want 2", len(svc.createdAccounts))
  619. }
  620. }
  621. func TestImportCodexSessionsAccessTokenOnlySameUserUpdatesExisting(t *testing.T) {
  622. existingToken := buildCodexAccessToken(t, "workspace-1", "user-1", time.Now().Add(time.Hour))
  623. svc := newCodexImportMemoryAdminService([]service.Account{{
  624. ID: 10,
  625. Name: "existing",
  626. Platform: service.PlatformOpenAI,
  627. Type: service.AccountTypeOAuth,
  628. Credentials: map[string]any{
  629. "chatgpt_account_id": "workspace-1",
  630. "chatgpt_user_id": "user-1",
  631. "access_token": existingToken,
  632. },
  633. Extra: map[string]any{"openai_long_context_billing_enabled": false},
  634. }})
  635. handler := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
  636. req := CodexSessionImportRequest{SkipDefaultGroupBind: boolPtr(true)}
  637. entries := []codexImportEntry{
  638. {Index: 1, Value: map[string]any{"access_token": existingToken}},
  639. }
  640. result, err := handler.importCodexSessions(context.Background(), req, entries)
  641. if err != nil {
  642. t.Fatalf("importCodexSessions error = %v", err)
  643. }
  644. if result.Created != 0 || result.Updated != 1 || result.Failed != 0 {
  645. t.Fatalf("result = %+v, want one updated account", result)
  646. }
  647. if len(svc.createdAccounts) != 0 {
  648. t.Fatalf("created accounts = %d, want 0", len(svc.createdAccounts))
  649. }
  650. if len(svc.updatedAccounts) != 1 || svc.updatedAccounts[0].id != 10 {
  651. t.Fatalf("updated accounts = %+v, want account 10", svc.updatedAccounts)
  652. }
  653. if got := svc.updatedAccounts[0].input.Extra["openai_long_context_billing_enabled"]; got != false {
  654. t.Fatalf("openai_long_context_billing_enabled = %v, want false", got)
  655. }
  656. }
  657. func TestImportCodexSessionsUpgradesAccessTokenOnlyAccountWithRefreshToken(t *testing.T) {
  658. oldToken := buildCodexAccessTokenWithJTI(t, "workspace-1", "user-1", "old-token", time.Now().Add(time.Hour))
  659. newToken := buildCodexAccessTokenWithJTI(t, "workspace-1", "user-1", "new-token", time.Now().Add(time.Hour))
  660. svc := newCodexImportMemoryAdminService([]service.Account{{
  661. ID: 12,
  662. Name: "existing",
  663. Platform: service.PlatformOpenAI,
  664. Type: service.AccountTypeOAuth,
  665. Credentials: map[string]any{
  666. "chatgpt_account_id": "workspace-1",
  667. "chatgpt_user_id": "user-1",
  668. "access_token": oldToken,
  669. },
  670. }})
  671. handler := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
  672. req := CodexSessionImportRequest{SkipDefaultGroupBind: boolPtr(true)}
  673. entries := []codexImportEntry{
  674. {Index: 1, Value: map[string]any{
  675. "access_token": newToken,
  676. "refresh_token": "refresh-new",
  677. }},
  678. }
  679. result, err := handler.importCodexSessions(context.Background(), req, entries)
  680. if err != nil {
  681. t.Fatalf("importCodexSessions error = %v", err)
  682. }
  683. if result.Created != 0 || result.Updated != 1 || result.Failed != 0 {
  684. t.Fatalf("result = %+v, want one updated account", result)
  685. }
  686. if len(svc.updatedAccounts) != 1 || svc.updatedAccounts[0].id != 12 {
  687. t.Fatalf("updated accounts = %+v, want account 12", svc.updatedAccounts)
  688. }
  689. if got := svc.updatedAccounts[0].input.Credentials["refresh_token"]; got != "refresh-new" {
  690. t.Fatalf("updated refresh_token = %v, want refresh-new", got)
  691. }
  692. }
  693. func TestImportCodexSessionsAccessTokenOnlyPreservesExistingRefreshToken(t *testing.T) {
  694. existingToken := buildCodexAccessToken(t, "workspace-1", "user-1", time.Now().Add(time.Hour))
  695. svc := newCodexImportMemoryAdminService([]service.Account{{
  696. ID: 13,
  697. Name: "existing",
  698. Platform: service.PlatformOpenAI,
  699. Type: service.AccountTypeOAuth,
  700. Credentials: map[string]any{
  701. "chatgpt_account_id": "workspace-1",
  702. "chatgpt_user_id": "user-1",
  703. "access_token": existingToken,
  704. "refresh_token": "refresh-old",
  705. "client_id": "client-old",
  706. },
  707. }})
  708. handler := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
  709. req := CodexSessionImportRequest{SkipDefaultGroupBind: boolPtr(true)}
  710. entries := []codexImportEntry{
  711. {Index: 1, Value: map[string]any{"access_token": existingToken}},
  712. }
  713. result, err := handler.importCodexSessions(context.Background(), req, entries)
  714. if err != nil {
  715. t.Fatalf("importCodexSessions error = %v", err)
  716. }
  717. if result.Created != 0 || result.Updated != 1 || result.Failed != 0 {
  718. t.Fatalf("result = %+v, want one updated account", result)
  719. }
  720. update := svc.updatedAccounts[0].input
  721. if got := update.Credentials["refresh_token"]; got != "refresh-old" {
  722. t.Fatalf("refresh_token = %v, want refresh-old", got)
  723. }
  724. if got := update.Credentials["client_id"]; got != "client-old" {
  725. t.Fatalf("client_id = %v, want client-old", got)
  726. }
  727. if update.ExpiresAt != nil {
  728. t.Fatalf("ExpiresAt = %v, want nil to preserve OAuth account expiry", *update.ExpiresAt)
  729. }
  730. if update.AutoPauseOnExpired != nil {
  731. t.Fatalf("AutoPauseOnExpired = %v, want nil to preserve OAuth account scheduling", *update.AutoPauseOnExpired)
  732. }
  733. }
  734. func TestImportCodexSessionsBatchOldAccessTokenDoesNotRollbackRefreshToken(t *testing.T) {
  735. oldToken := buildCodexAccessTokenWithJTI(t, "workspace-1", "user-1", "old-token", time.Now().Add(time.Hour))
  736. newToken := buildCodexAccessTokenWithJTI(t, "workspace-1", "user-1", "new-token", time.Now().Add(time.Hour))
  737. svc := newCodexImportMemoryAdminService([]service.Account{{
  738. ID: 14,
  739. Name: "existing",
  740. Platform: service.PlatformOpenAI,
  741. Type: service.AccountTypeOAuth,
  742. Credentials: map[string]any{
  743. "chatgpt_account_id": "workspace-1",
  744. "chatgpt_user_id": "user-1",
  745. "access_token": oldToken,
  746. "refresh_token": "refresh-old",
  747. },
  748. }})
  749. handler := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
  750. req := CodexSessionImportRequest{SkipDefaultGroupBind: boolPtr(true)}
  751. entries := []codexImportEntry{
  752. {Index: 1, Value: map[string]any{
  753. "access_token": newToken,
  754. "refresh_token": "refresh-new",
  755. }},
  756. {Index: 2, Value: map[string]any{"access_token": oldToken}},
  757. }
  758. result, err := handler.importCodexSessions(context.Background(), req, entries)
  759. if err != nil {
  760. t.Fatalf("importCodexSessions error = %v", err)
  761. }
  762. if result.Updated != 1 || result.Created != 1 || result.Failed != 0 {
  763. t.Fatalf("result = %+v, want first item updated and stale access token created separately", result)
  764. }
  765. if len(svc.updatedAccounts) != 1 || svc.updatedAccounts[0].id != 14 {
  766. t.Fatalf("updated accounts = %+v, want account 14 updated once", svc.updatedAccounts)
  767. }
  768. stored, err := svc.GetAccount(context.Background(), 14)
  769. if err != nil {
  770. t.Fatalf("GetAccount error = %v", err)
  771. }
  772. if got := stored.Credentials["access_token"]; got != newToken {
  773. t.Fatalf("stored access_token rolled back = %v, want new token", got)
  774. }
  775. if got := stored.Credentials["refresh_token"]; got != "refresh-new" {
  776. t.Fatalf("stored refresh_token = %v, want refresh-new", got)
  777. }
  778. }
  779. func TestImportCodexSessionsWithRefreshTokenKeepsExistingDedup(t *testing.T) {
  780. existingToken := buildCodexAccessToken(t, "workspace-1", "user-1", time.Now().Add(time.Hour))
  781. svc := newCodexImportMemoryAdminService([]service.Account{{
  782. ID: 11,
  783. Name: "existing",
  784. Platform: service.PlatformOpenAI,
  785. Type: service.AccountTypeOAuth,
  786. Credentials: map[string]any{
  787. "chatgpt_account_id": "workspace-1",
  788. "chatgpt_user_id": "user-1",
  789. "access_token": existingToken,
  790. "refresh_token": "refresh-old",
  791. },
  792. }})
  793. handler := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
  794. req := CodexSessionImportRequest{SkipDefaultGroupBind: boolPtr(true)}
  795. entries := []codexImportEntry{
  796. {Index: 1, Value: buildCodexRefreshImportValue(t, "workspace-1", "user-1", "refresh-new")},
  797. }
  798. result, err := handler.importCodexSessions(context.Background(), req, entries)
  799. if err != nil {
  800. t.Fatalf("importCodexSessions error = %v", err)
  801. }
  802. if result.Created != 0 || result.Updated != 1 || result.Failed != 0 {
  803. t.Fatalf("result = %+v, want one updated account", result)
  804. }
  805. if got := svc.updatedAccounts[0].input.Credentials["refresh_token"]; got != "refresh-new" {
  806. t.Fatalf("updated refresh_token = %v, want refresh-new", got)
  807. }
  808. }
  809. type codexImportMemoryAdminService struct {
  810. *stubAdminService
  811. nextID int64
  812. updatedAccounts []struct {
  813. id int64
  814. input *service.UpdateAccountInput
  815. }
  816. }
  817. func newCodexImportMemoryAdminService(accounts []service.Account) *codexImportMemoryAdminService {
  818. stub := newStubAdminService()
  819. stub.accounts = append([]service.Account(nil), accounts...)
  820. return &codexImportMemoryAdminService{
  821. stubAdminService: stub,
  822. nextID: 100,
  823. }
  824. }
  825. func (s *codexImportMemoryAdminService) CreateAccount(ctx context.Context, input *service.CreateAccountInput) (*service.Account, error) {
  826. s.createdAccounts = append(s.createdAccounts, input)
  827. if s.createAccountErr != nil {
  828. return nil, s.createAccountErr
  829. }
  830. account := service.Account{
  831. ID: s.nextID,
  832. Name: input.Name,
  833. Platform: input.Platform,
  834. Type: input.Type,
  835. Status: service.StatusActive,
  836. Credentials: cloneCodexImportTestMap(input.Credentials),
  837. Extra: cloneCodexImportTestMap(input.Extra),
  838. }
  839. s.nextID++
  840. s.accounts = append(s.accounts, account)
  841. return &account, nil
  842. }
  843. func (s *codexImportMemoryAdminService) UpdateAccount(ctx context.Context, id int64, input *service.UpdateAccountInput) (*service.Account, error) {
  844. s.updatedAccounts = append(s.updatedAccounts, struct {
  845. id int64
  846. input *service.UpdateAccountInput
  847. }{id: id, input: input})
  848. if s.updateAccountErr != nil {
  849. return nil, s.updateAccountErr
  850. }
  851. for idx := range s.accounts {
  852. if s.accounts[idx].ID == id {
  853. s.accounts[idx].Credentials = cloneCodexImportTestMap(input.Credentials)
  854. s.accounts[idx].Extra = cloneCodexImportTestMap(input.Extra)
  855. return &s.accounts[idx], nil
  856. }
  857. }
  858. account := service.Account{ID: id, Status: service.StatusActive, Credentials: cloneCodexImportTestMap(input.Credentials)}
  859. return &account, nil
  860. }
  861. func (s *codexImportMemoryAdminService) GetAccount(ctx context.Context, id int64) (*service.Account, error) {
  862. for idx := range s.accounts {
  863. if s.accounts[idx].ID == id {
  864. return &s.accounts[idx], nil
  865. }
  866. }
  867. return s.stubAdminService.GetAccount(ctx, id)
  868. }
  869. func buildCodexAccessOnlyImportValue(t *testing.T, accountID, userID string) map[string]any {
  870. t.Helper()
  871. return map[string]any{
  872. "access_token": buildCodexAccessToken(t, accountID, userID, time.Now().Add(time.Hour)),
  873. }
  874. }
  875. func buildCodexRefreshImportValue(t *testing.T, accountID, userID, refreshToken string) map[string]any {
  876. t.Helper()
  877. return map[string]any{
  878. "access_token": buildCodexAccessToken(t, accountID, userID, time.Now().Add(time.Hour)),
  879. "refresh_token": refreshToken,
  880. }
  881. }
  882. func buildCodexAccessToken(t *testing.T, accountID, userID string, exp time.Time) string {
  883. t.Helper()
  884. return buildCodexAccessTokenWithJTI(t, accountID, userID, "", exp)
  885. }
  886. func buildCodexAccessTokenWithJTI(t *testing.T, accountID, userID, jti string, exp time.Time) string {
  887. t.Helper()
  888. claims := map[string]any{
  889. "sub": userID,
  890. "https://api.openai.com/auth": map[string]any{
  891. "chatgpt_account_id": accountID,
  892. },
  893. }
  894. if jti != "" {
  895. claims["jti"] = jti
  896. }
  897. return buildCodexImportTestJWT(t, exp, claims)
  898. }
  899. func cloneCodexImportTestMap(input map[string]any) map[string]any {
  900. if input == nil {
  901. return nil
  902. }
  903. out := make(map[string]any, len(input))
  904. for key, value := range input {
  905. out[key] = value
  906. }
  907. return out
  908. }
  909. func boolPtr(v bool) *bool {
  910. return &v
  911. }
  912. func buildCodexImportTestJWT(t *testing.T, exp time.Time, extraClaims map[string]any) string {
  913. t.Helper()
  914. header := map[string]any{
  915. "alg": "none",
  916. "typ": "JWT",
  917. }
  918. claims := map[string]any{
  919. "sub": "user-from-sub",
  920. "exp": exp.Unix(),
  921. "iat": time.Now().Unix(),
  922. }
  923. for k, v := range extraClaims {
  924. claims[k] = v
  925. }
  926. headerBytes, err := json.Marshal(header)
  927. if err != nil {
  928. t.Fatalf("marshal header: %v", err)
  929. }
  930. claimBytes, err := json.Marshal(claims)
  931. if err != nil {
  932. t.Fatalf("marshal claims: %v", err)
  933. }
  934. return base64.RawURLEncoding.EncodeToString(headerBytes) + "." + base64.RawURLEncoding.EncodeToString(claimBytes) + "."
  935. }