Skip to content

Commit 71d0ee5

Browse files
committed
fix(admin): allow AT-only accounts to be exported and migrated
Export and migrate previously skipped accounts whose refresh_token was empty, which dropped every AT-only account some Plus users rely on to bypass Codex's "add phone" flow. The import path has long supported AT-only mixed batches; only export and migrate were over-filtering. Skip the entry only when both refresh_token and access_token are empty so AT-only accounts make it through both ends of the migration. Covered by two regression tests in admin/handler_test.go. Fixes #123
1 parent 157c2ea commit 71d0ee5

2 files changed

Lines changed: 116 additions & 5 deletions

File tree

admin/handler.go

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4168,7 +4168,10 @@ func (h *Handler) ExportAccounts(c *gin.Context) {
41684168
}
41694169
}
41704170
rt := row.GetCredential("refresh_token")
4171-
if rt == "" {
4171+
at := row.GetCredential("access_token")
4172+
// AT-only accounts (没有 refresh_token,只靠 access_token,常用于规避
4173+
// add-phone 的 Plus 号) 也需要可导出与可迁移。仅当两个凭证都缺失才跳过。
4174+
if rt == "" && at == "" {
41724175
continue
41734176
}
41744177
entries = append(entries, cpaExportEntry{
@@ -4183,7 +4186,7 @@ func (h *Handler) ExportAccounts(c *gin.Context) {
41834186
Expired: row.GetCredential("expires_at"),
41844187
IDToken: row.GetCredential("id_token"),
41854188
AccountID: row.GetCredential("account_id"),
4186-
AccessToken: row.GetCredential("access_token"),
4189+
AccessToken: at,
41874190
LastRefresh: row.UpdatedAt.Format(time.RFC3339),
41884191
RefreshToken: rt,
41894192
})
@@ -4254,11 +4257,13 @@ func (h *Handler) MigrateAccounts(c *gin.Context) {
42544257
return
42554258
}
42564259

4257-
// 转换为 importToken 格式,复用 importAccountsCommon
4260+
// 转换为 importToken 格式,复用 importAccountsCommon (原生支持 AT-only 混合导入)
42584261
var tokens []importToken
42594262
for _, entry := range remoteAccounts {
42604263
rt := strings.TrimSpace(entry.RefreshToken)
4261-
if rt == "" {
4264+
at := strings.TrimSpace(entry.AccessToken)
4265+
// 至少需要一种凭证;两者都为空表示账号根本没有可用凭证。
4266+
if rt == "" && at == "" {
42624267
continue
42634268
}
42644269
name := entry.Email
@@ -4267,7 +4272,7 @@ func (h *Handler) MigrateAccounts(c *gin.Context) {
42674272
}
42684273
tokens = append(tokens, importToken{
42694274
refreshToken: rt,
4270-
accessToken: strings.TrimSpace(entry.AccessToken),
4275+
accessToken: at,
42714276
name: name,
42724277
email: strings.TrimSpace(entry.Email),
42734278
idToken: strings.TrimSpace(entry.IDToken),

admin/handler_test.go

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -654,6 +654,112 @@ func TestUpdateAccountSchedulerUpdatesRuntimeOverrides(t *testing.T) {
654654
}
655655
}
656656

657+
// AT-only 账号(没有 refresh_token,只靠 access_token)是规避 Codex Plus "add
658+
// phone" 流程的常用形态。导出/迁移以前会因为 rt=="" 直接跳过这些账号,导致
659+
// issue #123 中的迁移丢号。下面两个测试保护已修好的过滤逻辑。
660+
func TestExportAccountsIncludesATOnly(t *testing.T) {
661+
gin.SetMode(gin.TestMode)
662+
663+
db := newTestAdminDB(t)
664+
665+
rtID, err := db.InsertAccount(context.Background(), "rt-account", "rt_value", "")
666+
if err != nil {
667+
t.Fatalf("insert rt account: %v", err)
668+
}
669+
if err := db.UpdateCredentials(context.Background(), rtID, map[string]interface{}{
670+
"email": "rt@example.com",
671+
"access_token": "at_for_rt",
672+
}); err != nil {
673+
t.Fatalf("update rt credentials: %v", err)
674+
}
675+
676+
atID, err := db.InsertAccount(context.Background(), "at-account", "", "")
677+
if err != nil {
678+
t.Fatalf("insert at-only account: %v", err)
679+
}
680+
if err := db.UpdateCredentials(context.Background(), atID, map[string]interface{}{
681+
"email": "at@example.com",
682+
"access_token": "at_only_value",
683+
}); err != nil {
684+
t.Fatalf("update at-only credentials: %v", err)
685+
}
686+
687+
handler := &Handler{db: db}
688+
689+
recorder := httptest.NewRecorder()
690+
ctx, _ := gin.CreateTestContext(recorder)
691+
ctx.Request = httptest.NewRequest(http.MethodGet, "/api/admin/accounts/export?filter=all", nil)
692+
693+
handler.ExportAccounts(ctx)
694+
695+
if recorder.Code != http.StatusOK {
696+
t.Fatalf("status = %d, want %d: %s", recorder.Code, http.StatusOK, recorder.Body.String())
697+
}
698+
699+
var entries []cpaExportEntry
700+
if err := json.Unmarshal(recorder.Body.Bytes(), &entries); err != nil {
701+
t.Fatalf("decode response: %v", err)
702+
}
703+
704+
if len(entries) != 2 {
705+
t.Fatalf("got %d entries, want 2 (rt + at-only)", len(entries))
706+
}
707+
708+
byEmail := make(map[string]cpaExportEntry, len(entries))
709+
for _, e := range entries {
710+
byEmail[e.Email] = e
711+
}
712+
713+
rt, ok := byEmail["rt@example.com"]
714+
if !ok {
715+
t.Fatal("rt-based account missing from export")
716+
}
717+
if rt.RefreshToken != "rt_value" || rt.AccessToken != "at_for_rt" {
718+
t.Fatalf("rt entry tokens = (rt=%q, at=%q), want (rt_value, at_for_rt)", rt.RefreshToken, rt.AccessToken)
719+
}
720+
721+
at, ok := byEmail["at@example.com"]
722+
if !ok {
723+
t.Fatal("AT-only account missing from export")
724+
}
725+
if at.RefreshToken != "" {
726+
t.Fatalf("AT-only RefreshToken = %q, want empty", at.RefreshToken)
727+
}
728+
if at.AccessToken != "at_only_value" {
729+
t.Fatalf("AT-only AccessToken = %q, want at_only_value", at.AccessToken)
730+
}
731+
}
732+
733+
func TestExportAccountsSkipsAccountsWithoutCredentials(t *testing.T) {
734+
gin.SetMode(gin.TestMode)
735+
736+
db := newTestAdminDB(t)
737+
738+
if _, err := db.InsertAccount(context.Background(), "empty-account", "", ""); err != nil {
739+
t.Fatalf("insert empty account: %v", err)
740+
}
741+
742+
handler := &Handler{db: db}
743+
744+
recorder := httptest.NewRecorder()
745+
ctx, _ := gin.CreateTestContext(recorder)
746+
ctx.Request = httptest.NewRequest(http.MethodGet, "/api/admin/accounts/export?filter=all", nil)
747+
748+
handler.ExportAccounts(ctx)
749+
750+
if recorder.Code != http.StatusOK {
751+
t.Fatalf("status = %d, want %d: %s", recorder.Code, http.StatusOK, recorder.Body.String())
752+
}
753+
754+
var entries []cpaExportEntry
755+
if err := json.Unmarshal(recorder.Body.Bytes(), &entries); err != nil {
756+
t.Fatalf("decode response: %v", err)
757+
}
758+
if len(entries) != 0 {
759+
t.Fatalf("got %d entries, want 0 (account has no credentials)", len(entries))
760+
}
761+
}
762+
657763
func newTestAdminDB(t *testing.T) *database.DB {
658764
t.Helper()
659765

0 commit comments

Comments
 (0)