Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
389 lines
16 KiB
Go
389 lines
16 KiB
Go
package repository
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"database/sql/driver"
|
|
"regexp"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
sqlmock "github.com/DATA-DOG/go-sqlmock"
|
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func TestAccountRepository_SetTempUnschedulable_NoRowsAffectedDoesNotWriteOutbox(t *testing.T) {
|
|
exec := &recordingSQLExecutor{result: rowsAffectedResult(0)}
|
|
repo := newAccountRepositoryWithSQL(nil, exec, nil)
|
|
until := time.Now().Add(10 * time.Minute)
|
|
|
|
err := repo.SetTempUnschedulable(context.Background(), 42, until, "retry")
|
|
require.NoError(t, err)
|
|
require.Len(t, exec.execQueries, 1)
|
|
require.Contains(t, exec.execQueries[0], "UPDATE accounts")
|
|
require.NotContains(t, strings.Join(exec.execQueries, "\n"), "scheduler_outbox")
|
|
}
|
|
|
|
func TestAccountRepository_GrokCredentialConditionalMutationsAreEligibleAndAtomicallyPropagated(t *testing.T) {
|
|
proxyID := int64(77)
|
|
snapshot := service.GrokCredentialMutationSnapshot{
|
|
CredentialsJSON: `{"access_token":"access","refresh_token":"refresh","_token_version":123}`,
|
|
ProxyID: &proxyID,
|
|
}
|
|
|
|
t.Run("permanent", func(t *testing.T) {
|
|
exec := &recordingSQLExecutor{result: rowsAffectedResult(0)}
|
|
repo := newAccountRepositoryWithSQL(nil, exec, nil)
|
|
|
|
updated, err := repo.SetGrokCredentialErrorIfMatch(context.Background(), 42, snapshot, "revoked")
|
|
|
|
require.NoError(t, err)
|
|
require.False(t, updated)
|
|
require.Len(t, exec.execQueries, 1)
|
|
normalized := normalizeSQLWhitespace(exec.execQueries[0])
|
|
require.Contains(t, normalized, "WITH updated AS ( UPDATE accounts AS a")
|
|
require.Contains(t, normalized, "a.schedulable IS TRUE")
|
|
require.Contains(t, normalized, "a.temp_unschedulable_until IS NULL OR a.temp_unschedulable_until <= NOW()")
|
|
require.Contains(t, normalized, "a.rate_limit_reset_at IS NULL OR a.rate_limit_reset_at <= NOW()")
|
|
require.Contains(t, normalized, "a.overload_until IS NULL OR a.overload_until <= NOW()")
|
|
require.Contains(t, normalized, "a.credentials = $7::jsonb")
|
|
require.Contains(t, normalized, "a.proxy_id IS NOT DISTINCT FROM $8")
|
|
require.Contains(t, normalized, "NOT EXISTS ( SELECT 1 FROM proxies p")
|
|
require.Contains(t, normalized, "INSERT INTO scheduler_outbox")
|
|
require.Len(t, exec.execArgs[0], 10)
|
|
require.Equal(t, snapshot.CredentialsJSON, exec.execArgs[0][6])
|
|
require.Equal(t, &proxyID, exec.execArgs[0][7])
|
|
require.Equal(t, string(service.GrokCredentialReasonProxyInvalid), exec.execArgs[0][8])
|
|
require.Equal(t, service.SchedulerOutboxEventAccountChanged, exec.execArgs[0][9])
|
|
})
|
|
|
|
t.Run("transient", func(t *testing.T) {
|
|
exec := &recordingSQLExecutor{result: rowsAffectedResult(0)}
|
|
repo := newAccountRepositoryWithSQL(nil, exec, nil)
|
|
|
|
updated, err := repo.SetGrokCredentialTempUnschedulableIfMatch(
|
|
context.Background(), 42, snapshot, time.Now().Add(time.Minute), "temporary",
|
|
)
|
|
|
|
require.NoError(t, err)
|
|
require.False(t, updated)
|
|
require.Len(t, exec.execQueries, 1)
|
|
normalized := normalizeSQLWhitespace(exec.execQueries[0])
|
|
require.Contains(t, normalized, "WITH updated AS ( UPDATE accounts AS a")
|
|
require.Contains(t, normalized, "a.schedulable IS TRUE")
|
|
require.Contains(t, normalized, "a.temp_unschedulable_until IS NULL OR a.temp_unschedulable_until <= NOW()")
|
|
require.Contains(t, normalized, "a.rate_limit_reset_at IS NULL OR a.rate_limit_reset_at <= NOW()")
|
|
require.Contains(t, normalized, "a.overload_until IS NULL OR a.overload_until <= NOW()")
|
|
require.Contains(t, normalized, "a.credentials = $7::jsonb")
|
|
require.Contains(t, normalized, "a.proxy_id IS NOT DISTINCT FROM $8")
|
|
require.Contains(t, normalized, "INSERT INTO scheduler_outbox")
|
|
require.Len(t, exec.execArgs[0], 9)
|
|
require.Equal(t, snapshot.CredentialsJSON, exec.execArgs[0][6])
|
|
require.Equal(t, &proxyID, exec.execArgs[0][7])
|
|
require.Equal(t, service.SchedulerOutboxEventAccountChanged, exec.execArgs[0][8])
|
|
})
|
|
}
|
|
|
|
func TestAccountRepository_GrokCredentialCommitCarriesOutboxAcrossCallerCancellation(t *testing.T) {
|
|
snapshot := service.GrokCredentialMutationSnapshot{CredentialsJSON: `{"access_token":"access","refresh_token":"refresh"}`}
|
|
tests := []struct {
|
|
name string
|
|
mutate func(context.Context, *accountRepository) (bool, error)
|
|
}{
|
|
{
|
|
name: "permanent",
|
|
mutate: func(ctx context.Context, repo *accountRepository) (bool, error) {
|
|
return repo.SetGrokCredentialErrorIfMatch(ctx, 42, snapshot, string(service.GrokCredentialReasonRevoked))
|
|
},
|
|
},
|
|
{
|
|
name: "transient",
|
|
mutate: func(ctx context.Context, repo *accountRepository) (bool, error) {
|
|
return repo.SetGrokCredentialTempUnschedulableIfMatch(ctx, 42, snapshot, time.Now().Add(time.Minute), string(service.GrokCredentialReasonRefreshTransient))
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
exec := &recordingSQLExecutor{result: rowsAffectedResult(1), afterExec: cancel}
|
|
repo := newAccountRepositoryWithSQL(nil, exec, nil)
|
|
|
|
updated, err := tt.mutate(ctx, repo)
|
|
|
|
require.NoError(t, err)
|
|
require.True(t, updated)
|
|
require.ErrorIs(t, ctx.Err(), context.Canceled)
|
|
require.Len(t, exec.execQueries, 1, "state update and scheduler outbox must share one atomic SQL statement")
|
|
require.Contains(t, normalizeSQLWhitespace(exec.execQueries[0]), "INSERT INTO scheduler_outbox")
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAccountRepository_SetGrokOAuthErrorIfCredentialsUnchanged_RequiresActiveExactCredentialMatch(t *testing.T) {
|
|
exec := &recordingSQLExecutor{result: rowsAffectedResult(0)}
|
|
repo := newAccountRepositoryWithSQL(nil, exec, nil)
|
|
|
|
applied, err := repo.SetGrokOAuthErrorIfCredentialsUnchanged(
|
|
context.Background(),
|
|
42,
|
|
map[string]any{"access_token": "observed", "_token_version": int64(7)},
|
|
"missing refresh token",
|
|
)
|
|
|
|
require.NoError(t, err)
|
|
require.False(t, applied)
|
|
require.Len(t, exec.execQueries, 1, "the account mutation and conditional outbox insert must be one statement")
|
|
normalized := normalizeSQLWhitespace(exec.execQueries[0])
|
|
require.Contains(t, normalized, "WITH updated AS")
|
|
require.Contains(t, normalized, "INSERT INTO scheduler_outbox")
|
|
require.Contains(t, normalized, "FROM updated")
|
|
require.Contains(t, normalized, "platform = $4")
|
|
require.Contains(t, normalized, "type = $5")
|
|
require.Contains(t, normalized, "status = $6")
|
|
require.Contains(t, normalized, "credentials = $7::jsonb")
|
|
require.Contains(t, normalized, "NULLIF(BTRIM(a.credentials->>'refresh_token'), '') IS NULL")
|
|
require.Len(t, exec.execArgs, 1)
|
|
require.Equal(t, service.StatusActive, exec.execArgs[0][5])
|
|
require.Contains(t, exec.execArgs[0][6], `"_token_version":7`)
|
|
}
|
|
|
|
func TestAccountRepository_SetGrokOAuthErrorIfCredentialsUnchanged_AppliedWritesOutbox(t *testing.T) {
|
|
exec := &recordingSQLExecutor{result: rowsAffectedResult(1)}
|
|
repo := newAccountRepositoryWithSQL(nil, exec, nil)
|
|
|
|
applied, err := repo.SetGrokOAuthErrorIfCredentialsUnchanged(
|
|
context.Background(),
|
|
42,
|
|
map[string]any{"access_token": "observed"},
|
|
"missing refresh token",
|
|
)
|
|
|
|
require.NoError(t, err)
|
|
require.True(t, applied)
|
|
require.Len(t, exec.execQueries, 1)
|
|
normalized := normalizeSQLWhitespace(exec.execQueries[0])
|
|
require.Contains(t, normalized, "WITH updated AS")
|
|
require.Contains(t, normalized, "INSERT INTO scheduler_outbox")
|
|
require.Contains(t, normalized, "SELECT $8, updated.id, NULL, NULL FROM updated")
|
|
}
|
|
|
|
func TestAccountRepository_SetGrokOAuthRefreshErrorIfCredentialsUnchanged_UsesAttemptCredentialsAndProxy(t *testing.T) {
|
|
exec := &recordingSQLExecutor{result: rowsAffectedResult(0)}
|
|
repo := newAccountRepositoryWithSQL(nil, exec, nil)
|
|
proxyID := int64(17)
|
|
|
|
applied, err := repo.SetGrokOAuthRefreshErrorIfCredentialsUnchanged(
|
|
context.Background(),
|
|
42,
|
|
map[string]any{"refresh_token": "attempted", "_token_version": int64(7)},
|
|
&proxyID,
|
|
"revoked",
|
|
)
|
|
|
|
require.NoError(t, err)
|
|
require.False(t, applied)
|
|
require.Len(t, exec.execQueries, 1)
|
|
normalized := normalizeSQLWhitespace(exec.execQueries[0])
|
|
require.Contains(t, normalized, "credentials = $7::jsonb")
|
|
require.Contains(t, normalized, "proxy_id IS NOT DISTINCT FROM $8")
|
|
require.NotContains(t, normalized, "credentials->>'refresh_token'",
|
|
"background invalid_grant CAS must accept the attempted refresh token; only reconciliation requires it missing")
|
|
require.Equal(t, &proxyID, exec.execArgs[0][7])
|
|
require.Contains(t, normalized, "INSERT INTO scheduler_outbox")
|
|
require.Len(t, exec.execArgs[0], 9)
|
|
}
|
|
|
|
func TestAccountRepository_SetGrokOAuthRefreshTempUnschedulableIfCredentialsUnchanged_UsesAttemptCredentialsAndProxy(t *testing.T) {
|
|
exec := &recordingSQLExecutor{result: rowsAffectedResult(0)}
|
|
repo := newAccountRepositoryWithSQL(nil, exec, nil)
|
|
proxyID := int64(19)
|
|
|
|
applied, err := repo.SetGrokOAuthRefreshTempUnschedulableIfCredentialsUnchanged(
|
|
context.Background(),
|
|
42,
|
|
map[string]any{"refresh_token": "attempted", "_token_version": int64(8)},
|
|
&proxyID,
|
|
time.Now().Add(10*time.Minute),
|
|
"retry exhausted",
|
|
)
|
|
|
|
require.NoError(t, err)
|
|
require.False(t, applied)
|
|
require.Len(t, exec.execQueries, 1)
|
|
normalized := normalizeSQLWhitespace(exec.execQueries[0])
|
|
require.Contains(t, normalized, "credentials = $7::jsonb")
|
|
require.Contains(t, normalized, "proxy_id IS NOT DISTINCT FROM $8")
|
|
require.Contains(t, normalized, "a.temp_unschedulable_until IS NULL OR a.temp_unschedulable_until < $1")
|
|
require.Len(t, exec.execArgs[0], 9)
|
|
require.Equal(t, &proxyID, exec.execArgs[0][7])
|
|
require.Contains(t, normalized, "INSERT INTO scheduler_outbox")
|
|
}
|
|
|
|
func TestAccountRepository_UpdateGrokOAuthCredentialsIfUnchanged_UsesExactAttemptStateAndAtomicOutbox(t *testing.T) {
|
|
exec := &recordingSQLExecutor{result: rowsAffectedResult(1)}
|
|
repo := newAccountRepositoryWithSQL(nil, exec, nil)
|
|
proxyID := int64(29)
|
|
|
|
applied, err := repo.UpdateGrokOAuthCredentialsIfUnchanged(
|
|
context.Background(),
|
|
42,
|
|
map[string]any{"refresh_token": "attempted", "_token_version": int64(9)},
|
|
&proxyID,
|
|
map[string]any{"refresh_token": "rotated", "_token_version": int64(10)},
|
|
)
|
|
|
|
require.NoError(t, err)
|
|
require.True(t, applied)
|
|
require.Len(t, exec.execQueries, 1)
|
|
normalized := normalizeSQLWhitespace(exec.execQueries[0])
|
|
require.Contains(t, normalized, "WITH updated AS")
|
|
require.Contains(t, normalized, "credentials = $1::jsonb")
|
|
require.Contains(t, normalized, "credentials = $5::jsonb")
|
|
require.Contains(t, normalized, "proxy_id IS NOT DISTINCT FROM $6")
|
|
require.Contains(t, normalized, "INSERT INTO scheduler_outbox")
|
|
require.Len(t, exec.execArgs[0], 7)
|
|
require.Equal(t, &proxyID, exec.execArgs[0][5])
|
|
}
|
|
|
|
func TestAccountRepository_ListOAuthRefreshCandidatePage_SQLFilter(t *testing.T) {
|
|
db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherRegexp))
|
|
require.NoError(t, err)
|
|
defer func() { _ = db.Close() }()
|
|
|
|
var capturedSQL string
|
|
var capturedArgs []any
|
|
mock.ExpectQuery("SELECT id").
|
|
WillReturnRows(sqlmock.NewRows([]string{"id"})).
|
|
WillDelayFor(0)
|
|
|
|
repo := newAccountRepositoryWithSQL(nil, captureQuerySQL{db: db, captured: &capturedSQL, args: &capturedArgs}, nil)
|
|
|
|
page, err := repo.ListOAuthRefreshCandidatePage(context.Background(), service.OAuthRefreshPageOptions{
|
|
Platforms: []string{service.PlatformAnthropic, service.PlatformOpenAI, service.PlatformGemini, service.PlatformAntigravity, service.PlatformGrok},
|
|
AfterID: 100,
|
|
Limit: 200,
|
|
ActiveOnly: true,
|
|
IncludeSetupToken: true,
|
|
RequireRefreshToken: true,
|
|
ExcludeRetryCooldown: true,
|
|
})
|
|
require.NoError(t, err)
|
|
require.Empty(t, page.Accounts)
|
|
|
|
normalized := normalizeSQLWhitespace(capturedSQL)
|
|
require.Contains(t, normalized, "deleted_at IS NULL")
|
|
require.Contains(t, normalized, "schedulable = TRUE",
|
|
"permanently unschedulable accounts must not remain OAuth refresh candidates")
|
|
require.Contains(t, normalized, "status = 'active'")
|
|
// setup-token 的 access_token 同为 8h 短期令牌,必须与 oauth 一起纳入后台刷新候选
|
|
require.Contains(t, normalized, "type IN ('oauth', 'setup-token')")
|
|
require.Contains(t, normalized, "platform = ANY($1)")
|
|
require.NotContains(t, normalized, "platform IN ('anthropic'",
|
|
"candidate platforms must come from the refresher registry instead of a second hard-coded list")
|
|
require.Contains(t, normalized, "credentials ? 'refresh_token'")
|
|
require.Contains(t, normalized, "btrim(credentials->>'refresh_token') <> ''")
|
|
require.Contains(t, normalized, "temp_unschedulable_until > NOW()")
|
|
require.Contains(t, normalized, "temp_unschedulable_reason LIKE 'token refresh retry exhausted:%'")
|
|
require.Contains(t, normalized, "IS NOT TRUE",
|
|
"must use IS NOT TRUE so accounts with NULL temp_unschedulable_until are not silently excluded by PG 3-valued logic")
|
|
require.NotContains(t, normalized, "AND NOT (",
|
|
"plain NOT (...) excludes NULL temp_unschedulable_until rows (the common healthy case)")
|
|
require.Contains(t, normalized, "id > $2")
|
|
require.Contains(t, normalized, "ORDER BY id ASC")
|
|
require.Contains(t, normalized, "LIMIT $3")
|
|
require.NotContains(t, normalized, "credentials->>'expires_at'")
|
|
require.Len(t, capturedArgs, 3)
|
|
require.Equal(t, int64(100), capturedArgs[1])
|
|
require.Equal(t, 200, capturedArgs[2])
|
|
valuer, ok := capturedArgs[0].(interface{ Value() (driver.Value, error) })
|
|
require.True(t, ok)
|
|
platforms, err := valuer.Value()
|
|
require.NoError(t, err)
|
|
require.Contains(t, platforms, service.PlatformGrok)
|
|
require.NoError(t, mock.ExpectationsWereMet())
|
|
}
|
|
|
|
func TestAccountRepository_ListOAuthRefreshCandidatePage_ReconciliationExcludesAPIKeys(t *testing.T) {
|
|
db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherRegexp))
|
|
require.NoError(t, err)
|
|
defer func() { _ = db.Close() }()
|
|
|
|
var capturedSQL string
|
|
mock.ExpectQuery("SELECT id").WillReturnRows(sqlmock.NewRows([]string{"id"}))
|
|
repo := newAccountRepositoryWithSQL(nil, captureQuerySQL{db: db, captured: &capturedSQL}, nil)
|
|
|
|
page, err := repo.ListOAuthRefreshCandidatePage(context.Background(), service.OAuthRefreshPageOptions{
|
|
Platforms: []string{service.PlatformGrok},
|
|
AfterID: 0,
|
|
Limit: 50,
|
|
})
|
|
require.NoError(t, err)
|
|
require.Empty(t, page.Accounts)
|
|
|
|
normalized := normalizeSQLWhitespace(capturedSQL)
|
|
require.Contains(t, normalized, "type = 'oauth'")
|
|
require.NotContains(t, normalized, "type IN ('oauth', 'setup-token')")
|
|
require.NotContains(t, normalized, "type = 'api-key'")
|
|
require.NotContains(t, normalized, "credentials ? 'refresh_token'",
|
|
"reconciliation must be able to find structurally invalid OAuth rows")
|
|
require.Contains(t, normalized, "ORDER BY id ASC")
|
|
require.NoError(t, mock.ExpectationsWereMet())
|
|
}
|
|
|
|
type captureQuerySQL struct {
|
|
db *sql.DB
|
|
captured *string
|
|
args *[]any
|
|
}
|
|
|
|
func (c captureQuerySQL) ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) {
|
|
return c.db.ExecContext(ctx, query, args...)
|
|
}
|
|
|
|
func (c captureQuerySQL) QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) {
|
|
if c.captured != nil {
|
|
*c.captured = query
|
|
}
|
|
if c.args != nil {
|
|
*c.args = append([]any(nil), args...)
|
|
}
|
|
return c.db.QueryContext(ctx, query, args...)
|
|
}
|
|
|
|
func normalizeSQLWhitespace(sql string) string {
|
|
return strings.Join(regexp.MustCompile(`\s+`).Split(strings.TrimSpace(sql), -1), " ")
|
|
}
|
|
|
|
type rowsAffectedResult int64
|
|
|
|
func (r rowsAffectedResult) LastInsertId() (int64, error) { return 0, nil }
|
|
func (r rowsAffectedResult) RowsAffected() (int64, error) { return int64(r), nil }
|
|
|
|
type recordingSQLExecutor struct {
|
|
result sql.Result
|
|
err error
|
|
afterExec func()
|
|
execQueries []string
|
|
execArgs [][]any
|
|
}
|
|
|
|
func (e *recordingSQLExecutor) ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) {
|
|
e.execQueries = append(e.execQueries, query)
|
|
e.execArgs = append(e.execArgs, append([]any(nil), args...))
|
|
if e.err != nil {
|
|
return nil, e.err
|
|
}
|
|
if e.afterExec != nil {
|
|
e.afterExec()
|
|
}
|
|
return e.result, nil
|
|
}
|
|
|
|
func (e *recordingSQLExecutor) QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) {
|
|
return nil, sql.ErrNoRows
|
|
}
|