mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-20 00:18:55 +00:00
fix(opencode): preserve managed usage state on account updates
This commit is contained in:
@@ -648,7 +648,19 @@ func lockAndMergeAccountProbeExtra(
|
||||
extra -> 'upstream_billing_probe',
|
||||
extra -> 'ollama_cloud_usage_session',
|
||||
extra -> 'ollama_cloud_usage_auto_refresh',
|
||||
extra -> 'ollama_cloud_usage_snapshot'
|
||||
extra -> 'ollama_cloud_usage_snapshot',
|
||||
COALESCE(
|
||||
platform = 'openai'
|
||||
AND $2 = 'openai'
|
||||
AND type = 'apikey'
|
||||
AND $3 = 'apikey'
|
||||
AND credentials -> 'api_key' IS NOT DISTINCT FROM $4::jsonb -> 'api_key'
|
||||
AND `+opencodeGoBaseURLMatchSQLPrefix+`credentials ->> 'base_url'`+opencodeGoBaseURLMatchSQLSuffix+`
|
||||
AND `+opencodeGoBaseURLMatchSQLPrefix+`$4::jsonb ->> 'base_url'`+opencodeGoBaseURLMatchSQLSuffix+`,
|
||||
false
|
||||
),
|
||||
extra -> 'opencode_go_usage_auto_refresh',
|
||||
extra -> 'opencode_go_usage_snapshot'
|
||||
FROM accounts
|
||||
WHERE id = $1 AND deleted_at IS NULL
|
||||
FOR NO KEY UPDATE
|
||||
@@ -665,15 +677,18 @@ func lockAndMergeAccountProbeExtra(
|
||||
}
|
||||
|
||||
var (
|
||||
identityUnchanged bool
|
||||
ollamaGroupIdentityUnchanged bool
|
||||
ollamaProxyIdentityUnchanged bool
|
||||
currentEnabled []byte
|
||||
currentRateSyncEnabled []byte
|
||||
currentSnapshot []byte
|
||||
currentOllamaSession []byte
|
||||
currentOllamaAutoRefresh []byte
|
||||
currentOllamaSnapshot []byte
|
||||
identityUnchanged bool
|
||||
ollamaGroupIdentityUnchanged bool
|
||||
ollamaProxyIdentityUnchanged bool
|
||||
opencodeGroupIdentityUnchanged bool
|
||||
currentEnabled []byte
|
||||
currentRateSyncEnabled []byte
|
||||
currentSnapshot []byte
|
||||
currentOllamaSession []byte
|
||||
currentOllamaAutoRefresh []byte
|
||||
currentOllamaSnapshot []byte
|
||||
currentOpenCodeAutoRefresh []byte
|
||||
currentOpenCodeSnapshot []byte
|
||||
)
|
||||
if err := rows.Scan(
|
||||
&identityUnchanged,
|
||||
@@ -685,6 +700,9 @@ func lockAndMergeAccountProbeExtra(
|
||||
¤tOllamaSession,
|
||||
¤tOllamaAutoRefresh,
|
||||
¤tOllamaSnapshot,
|
||||
&opencodeGroupIdentityUnchanged,
|
||||
¤tOpenCodeAutoRefresh,
|
||||
¤tOpenCodeSnapshot,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -700,6 +718,8 @@ func lockAndMergeAccountProbeExtra(
|
||||
service.OllamaCloudUsageSessionExtraKey,
|
||||
service.OllamaCloudUsageAutoRefreshExtraKey,
|
||||
service.OllamaCloudUsageSnapshotExtraKey,
|
||||
service.OpenCodeGoUsageAutoRefreshExtraKey,
|
||||
service.OpenCodeGoUsageSnapshotExtraKey,
|
||||
} {
|
||||
delete(extra, key)
|
||||
}
|
||||
@@ -777,6 +797,24 @@ func lockAndMergeAccountProbeExtra(
|
||||
}
|
||||
}
|
||||
}
|
||||
// OpenCode Go 受管状态与 Ollama 同范式:组身份(normalized opencode base URL +
|
||||
// api_key)未变时从锁定的 DB 行回填,客户端 DTO 脱敏或并发 refresh 写入都无法
|
||||
// 覆盖/丢失;身份改变或不再 eligible 时既不回填,写出的 extra 即清除旧状态。
|
||||
// 代理不属于组身份,但 snapshot 外呼与 CAS 经代理,故代理变化时快照失效而开关保留。
|
||||
if service.IsOpenCodeGoUsageAccount(account) && opencodeGroupIdentityUnchanged {
|
||||
if value, ok, err := decodeAccountExtraJSON(currentOpenCodeAutoRefresh); err != nil {
|
||||
return nil, err
|
||||
} else if ok {
|
||||
extra[service.OpenCodeGoUsageAutoRefreshExtraKey] = value
|
||||
}
|
||||
if ollamaProxyIdentityUnchanged {
|
||||
if snapshot, ok, err := decodeAccountExtraJSON(currentOpenCodeSnapshot); err != nil {
|
||||
return nil, err
|
||||
} else if ok {
|
||||
extra[service.OpenCodeGoUsageSnapshotExtraKey] = snapshot
|
||||
}
|
||||
}
|
||||
}
|
||||
return extra, nil
|
||||
}
|
||||
|
||||
@@ -819,6 +857,25 @@ func (r *accountRepository) UpdateCredentials(ctx context.Context, id int64, cre
|
||||
SET
|
||||
credentials = $1::jsonb,
|
||||
extra = CASE
|
||||
-- OpenCode Go 分支必须先于 Ollama 分支求值:Ollama 守卫对任意
|
||||
-- openai/anthropic apikey 行在 api_key/base_url 变化时都会命中,
|
||||
-- 若排在前面会遮蔽 opencode 行的清理。opencode 与 ollama base URL
|
||||
-- 正则互斥,旧行只会命中其中一个分支。
|
||||
WHEN platform = 'openai'
|
||||
AND type = 'apikey'
|
||||
AND credentials IS DISTINCT FROM $1::jsonb
|
||||
AND `+opencodeGoBaseURLMatchSQLPrefix+`credentials ->> 'base_url'`+opencodeGoBaseURLMatchSQLSuffix+`
|
||||
AND (
|
||||
credentials -> 'api_key' IS DISTINCT FROM $1::jsonb -> 'api_key'
|
||||
OR (`+opencodeGoBaseURLMatchSQLPrefix+`$1::jsonb ->> 'base_url'`+opencodeGoBaseURLMatchSQLSuffix+`) IS NOT TRUE
|
||||
)
|
||||
THEN COALESCE(extra, '{}'::jsonb)
|
||||
- 'upstream_billing_probe'
|
||||
- 'opencode_go_usage_auto_refresh'
|
||||
- 'opencode_go_usage_snapshot'
|
||||
- 'ollama_cloud_usage_session'
|
||||
- 'ollama_cloud_usage_auto_refresh'
|
||||
- 'ollama_cloud_usage_snapshot'
|
||||
-- 凭证整体未变化 ⇒ Ollama 组身份必然未变化;顶层 DISTINCT 守卫防止
|
||||
-- 非 Ollama 账号的无变化持久化误清探测快照或重写 NULL extra。
|
||||
WHEN platform IN (`+ollamaCloudUsagePlatformsSQL+`)
|
||||
@@ -3009,7 +3066,23 @@ func (r *accountRepository) BulkUpdate(ctx context.Context, ids []int64, updates
|
||||
" AND "+ollamaCloudBaseURLMatchesSQL(credentialPlaceholder+"::jsonb ->> 'base_url'")+")")
|
||||
}
|
||||
|
||||
if len(updates.Extra) > 0 || len(ollamaGroupIdentityChanges) > 0 || ollamaProxyIdentityChanged != "" || updates.EnsureCodexFingerprintSeed {
|
||||
// OpenCode Go 身份清理与 Ollama 同范式但更精确:仅当旧行是 opencode base URL
|
||||
// 身份时才可能持有受管键,且只在该组身份(api_key / normalized base URL)真正
|
||||
// 变化或不再 eligible 时清除,避免与 Ollama 分支交叉误清。
|
||||
opencodeGroupIdentityChanges := make([]string, 0, 2)
|
||||
opencodeOldBaseURL := opencodeGoBaseURLMatchSQLPrefix + "credentials ->> 'base_url'" + opencodeGoBaseURLMatchSQLSuffix
|
||||
if _, ok := updates.Credentials["api_key"]; ok {
|
||||
opencodeGroupIdentityChanges = append(opencodeGroupIdentityChanges,
|
||||
opencodeOldBaseURL+" AND credentials -> 'api_key' IS DISTINCT FROM "+credentialPlaceholder+"::jsonb -> 'api_key'")
|
||||
}
|
||||
if _, ok := updates.Credentials["base_url"]; ok {
|
||||
// NULL-safe:新 base_url 缺失/为 null 时 regex(NULL) 为 NULL,NOT NULL 仍为
|
||||
// NULL 会令 WHEN 不命中而残留 OpenCode 状态;IS NOT TRUE 把 NULL 视为不匹配。
|
||||
opencodeGroupIdentityChanges = append(opencodeGroupIdentityChanges,
|
||||
opencodeOldBaseURL+" AND ("+opencodeGoBaseURLMatchSQLPrefix+credentialPlaceholder+"::jsonb ->> 'base_url'"+opencodeGoBaseURLMatchSQLSuffix+") IS NOT TRUE")
|
||||
}
|
||||
|
||||
if len(updates.Extra) > 0 || len(ollamaGroupIdentityChanges) > 0 || len(opencodeGroupIdentityChanges) > 0 || ollamaProxyIdentityChanged != "" || updates.EnsureCodexFingerprintSeed {
|
||||
extraExpression := "COALESCE(extra, '{}'::jsonb)"
|
||||
if len(updates.Extra) > 0 {
|
||||
payload, err := json.Marshal(updates.Extra)
|
||||
@@ -3040,13 +3113,44 @@ func (r *accountRepository) BulkUpdate(ctx context.Context, ids []int64, updates
|
||||
snapshotIdentityChanged = "(" + snapshotIdentityChanged + " OR " + proxyChanged + ")"
|
||||
}
|
||||
}
|
||||
// 代理不属于 OpenCode 组身份,但 snapshot 外呼与 CAS 经代理:
|
||||
// 组身份变化清除开关+快照,仅代理变化清除快照而保留开关。
|
||||
// eligible 判定必须包含旧行 opencode base URL:否则 OpenAI+Ollama 行在
|
||||
// 代理变化时会先命中 OpenCode 分支(CASE 按序求值)而遮蔽 Ollama 分支的
|
||||
// 快照清理。opencode 与 ollama 正则互斥,带上 base URL 后两套分支互斥。
|
||||
opencodeEligibleAccount := "platform = 'openai' AND type = 'apikey' AND " + opencodeOldBaseURL
|
||||
opencodeGroupIdentityChanged := ""
|
||||
if len(opencodeGroupIdentityChanges) > 0 {
|
||||
opencodeGroupIdentityChanged = "(" + opencodeEligibleAccount + " AND (" + joinClauses(opencodeGroupIdentityChanges, " OR ") + "))"
|
||||
}
|
||||
opencodeSnapshotIdentityChanged := opencodeGroupIdentityChanged
|
||||
if ollamaProxyIdentityChanged != "" {
|
||||
opencodeProxyChanged := "(" + opencodeEligibleAccount + " AND " + ollamaProxyIdentityChanged + ")"
|
||||
if opencodeSnapshotIdentityChanged == "" {
|
||||
opencodeSnapshotIdentityChanged = opencodeProxyChanged
|
||||
} else {
|
||||
opencodeSnapshotIdentityChanged = "(" + opencodeSnapshotIdentityChanged + " OR " + opencodeProxyChanged + ")"
|
||||
}
|
||||
}
|
||||
caseBranches := make([]string, 0, 4)
|
||||
if opencodeGroupIdentityChanged != "" {
|
||||
caseBranches = append(caseBranches,
|
||||
" WHEN "+opencodeGroupIdentityChanged+" THEN ("+extraExpression+") - 'opencode_go_usage_auto_refresh' - 'opencode_go_usage_snapshot'")
|
||||
}
|
||||
if opencodeSnapshotIdentityChanged != "" {
|
||||
caseBranches = append(caseBranches,
|
||||
" WHEN "+opencodeSnapshotIdentityChanged+" THEN ("+extraExpression+") - 'opencode_go_usage_snapshot'")
|
||||
}
|
||||
if groupIdentityChanged != "" {
|
||||
extraExpression = "CASE" +
|
||||
" WHEN " + groupIdentityChanged + " THEN (" + extraExpression + ") - 'ollama_cloud_usage_session' - 'ollama_cloud_usage_auto_refresh' - 'ollama_cloud_usage_snapshot'" +
|
||||
" WHEN " + snapshotIdentityChanged + " THEN (" + extraExpression + ") - 'ollama_cloud_usage_snapshot'" +
|
||||
" ELSE " + extraExpression + " END"
|
||||
} else if snapshotIdentityChanged != "" {
|
||||
extraExpression = "CASE WHEN " + snapshotIdentityChanged + " THEN (" + extraExpression + ") - 'ollama_cloud_usage_snapshot' ELSE " + extraExpression + " END"
|
||||
caseBranches = append(caseBranches,
|
||||
" WHEN "+groupIdentityChanged+" THEN ("+extraExpression+") - 'ollama_cloud_usage_session' - 'ollama_cloud_usage_auto_refresh' - 'ollama_cloud_usage_snapshot'")
|
||||
}
|
||||
if snapshotIdentityChanged != "" {
|
||||
caseBranches = append(caseBranches,
|
||||
" WHEN "+snapshotIdentityChanged+" THEN ("+extraExpression+") - 'ollama_cloud_usage_snapshot'")
|
||||
}
|
||||
if len(caseBranches) > 0 {
|
||||
extraExpression = "CASE" + strings.Join(caseBranches, "") + " ELSE " + extraExpression + " END"
|
||||
}
|
||||
if updates.EnsureCodexFingerprintSeed {
|
||||
extraExpression = ensureCodexFingerprintSeedSQL(extraExpression)
|
||||
|
||||
@@ -12,7 +12,10 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
opencodeGoBaseURLRegexSQL = `^[hH][tT][tT][pP][sS]://[oO][pP][eE][nN][cC][oO][dD][eE]\.[aA][iI]/[zZ][eE][nN]/[gG][oO]/[vV]1/?$`
|
||||
// 与 service.isOpenCodeGoBaseURL 对齐:Go 侧接受显式默认端口 :443(parsed.Host ==
|
||||
// hostname+":443"),SQL 正则必须同样接受可选 :443,否则同一 base_url 在 Go 判定
|
||||
// eligible 而 SQL 判定不 eligible,身份清理与组查询会漏行。只做 parity,不扩域名/路径。
|
||||
opencodeGoBaseURLRegexSQL = `^[hH][tT][tT][pP][sS]://[oO][pP][eE][nN][cC][oO][dD][eE]\.[aA][iI](:[4][4][3])?/[zZ][eE][nN]/[gG][oO]/[vV]1/?$`
|
||||
opencodeGoBaseURLMatchSQLPrefix = "btrim("
|
||||
opencodeGoBaseURLMatchSQLSuffix = ") ~ '" + opencodeGoBaseURLRegexSQL + "'"
|
||||
opencodeGoUsageEligibleSQL = `
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
//go:build integration
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// openCodeGoManagedExtra 构造同时携带探测 / Ollama / OpenCode 受管键的 extra,
|
||||
// 用于验证各身份清理分支只清自己该清的键、不跨身份误清。
|
||||
func openCodeGoManagedExtra(now time.Time) map[string]any {
|
||||
return map[string]any{
|
||||
service.UpstreamBillingProbeExtraKey: map[string]any{"status": "ok"},
|
||||
service.OllamaCloudUsageSessionExtraKey: "cipher:wos-session=fixture",
|
||||
service.OllamaCloudUsageAutoRefreshExtraKey: true,
|
||||
service.OllamaCloudUsageSnapshotExtraKey: map[string]any{
|
||||
"status": service.OllamaCloudUsageStatusOK, "last_attempt_at": now, "next_refresh_at": now.Add(time.Hour),
|
||||
},
|
||||
service.OpenCodeGoUsageAutoRefreshExtraKey: true,
|
||||
service.OpenCodeGoUsageSnapshotExtraKey: map[string]any{
|
||||
"status": service.OpenCodeGoUsageStatusOK, "last_attempt_at": now, "next_refresh_at": now.Add(time.Hour),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// F1:混合 OpenCode + openai Ollama + anthropic Ollama 行的批量代理变化与
|
||||
// 代理+key 变化,验证 OpenCode/Ollama CASE 分支互斥、各自只清自己该清的键。
|
||||
// 回归点:OpenCode eligible 判定若不含旧行 opencode base URL,OpenAI+Ollama 行
|
||||
// 在代理变化时会先命中 OpenCode 分支而遮蔽 Ollama 快照清理。
|
||||
func TestBulkUpdateOpenCodeGoMixedPlatformIdentityCleanup(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
tx := testEntTx(t)
|
||||
repo := newAccountRepositoryWithSQL(tx.Client(), tx, nil)
|
||||
now := time.Now().UTC()
|
||||
|
||||
create := func(name, platform, baseURL string, extra map[string]any) *service.Account {
|
||||
t.Helper()
|
||||
return mustCreateAccount(t, tx.Client(), &service.Account{
|
||||
Name: name, Platform: platform, Type: service.AccountTypeAPIKey,
|
||||
Credentials: map[string]any{"api_key": "shared-key", "base_url": baseURL},
|
||||
Extra: extra,
|
||||
})
|
||||
}
|
||||
ollamaExtra := func() map[string]any {
|
||||
return map[string]any{
|
||||
service.OllamaCloudUsageSessionExtraKey: "cipher:wos-session=fixture",
|
||||
service.OllamaCloudUsageAutoRefreshExtraKey: true,
|
||||
service.OllamaCloudUsageSnapshotExtraKey: map[string]any{
|
||||
"status": service.OllamaCloudUsageStatusOK, "last_attempt_at": now, "next_refresh_at": now.Add(time.Hour),
|
||||
},
|
||||
}
|
||||
}
|
||||
opencode := create("mixed-opencode", service.PlatformOpenAI, "https://opencode.ai/zen/go/v1", map[string]any{
|
||||
service.OpenCodeGoUsageAutoRefreshExtraKey: true,
|
||||
service.OpenCodeGoUsageSnapshotExtraKey: map[string]any{
|
||||
"status": service.OpenCodeGoUsageStatusOK, "last_attempt_at": now, "next_refresh_at": now.Add(time.Hour),
|
||||
},
|
||||
})
|
||||
openaiOllama := create("mixed-openai-ollama", service.PlatformOpenAI, "https://ollama.com", ollamaExtra())
|
||||
anthropicOllama := create("mixed-anthropic-ollama", service.PlatformAnthropic, "https://ollama.com", ollamaExtra())
|
||||
proxy := mustCreateProxy(t, tx.Client(), &service.Proxy{
|
||||
Name: "mixed-proxy", Protocol: "http", Host: "127.0.0.1", Port: 3128,
|
||||
Username: "user", Password: "pass", Status: service.StatusActive,
|
||||
})
|
||||
proxy2 := mustCreateProxy(t, tx.Client(), &service.Proxy{
|
||||
Name: "mixed-proxy-2", Protocol: "http", Host: "127.0.0.2", Port: 3128,
|
||||
Username: "user", Password: "pass", Status: service.StatusActive,
|
||||
})
|
||||
|
||||
// 只改 proxy:OpenCode 行清快照留开关;Ollama 行清快照留 session/开关。
|
||||
rows, err := repo.BulkUpdate(ctx, []int64{opencode.ID, openaiOllama.ID, anthropicOllama.ID}, service.AccountBulkUpdate{
|
||||
ProxyID: &proxy.ID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(3), rows)
|
||||
|
||||
opencodeLoaded, err := repo.GetByID(ctx, opencode.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, true, opencodeLoaded.Extra[service.OpenCodeGoUsageAutoRefreshExtraKey])
|
||||
require.NotContains(t, opencodeLoaded.Extra, service.OpenCodeGoUsageSnapshotExtraKey)
|
||||
|
||||
for _, id := range []int64{openaiOllama.ID, anthropicOllama.ID} {
|
||||
loaded, err := repo.GetByID(ctx, id)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "cipher:wos-session=fixture", loaded.Extra[service.OllamaCloudUsageSessionExtraKey])
|
||||
require.Equal(t, true, loaded.Extra[service.OllamaCloudUsageAutoRefreshExtraKey])
|
||||
require.NotContains(t, loaded.Extra, service.OllamaCloudUsageSnapshotExtraKey,
|
||||
"OpenAI+Ollama 行的快照必须由 Ollama 分支清理,不能被 OpenCode 分支遮蔽")
|
||||
}
|
||||
|
||||
// 同时改 proxy + api_key:OpenCode 行清开关+快照;Ollama 行清 session/开关/快照。
|
||||
rows, err = repo.BulkUpdate(ctx, []int64{opencode.ID, openaiOllama.ID, anthropicOllama.ID}, service.AccountBulkUpdate{
|
||||
ProxyID: &proxy2.ID,
|
||||
Credentials: map[string]any{"api_key": "rotated-key"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(3), rows)
|
||||
|
||||
opencodeLoaded, err = repo.GetByID(ctx, opencode.ID)
|
||||
require.NoError(t, err)
|
||||
require.NotContains(t, opencodeLoaded.Extra, service.OpenCodeGoUsageAutoRefreshExtraKey)
|
||||
require.NotContains(t, opencodeLoaded.Extra, service.OpenCodeGoUsageSnapshotExtraKey)
|
||||
|
||||
for _, id := range []int64{openaiOllama.ID, anthropicOllama.ID} {
|
||||
loaded, err := repo.GetByID(ctx, id)
|
||||
require.NoError(t, err)
|
||||
require.NotContains(t, loaded.Extra, service.OllamaCloudUsageSessionExtraKey)
|
||||
require.NotContains(t, loaded.Extra, service.OllamaCloudUsageAutoRefreshExtraKey)
|
||||
require.NotContains(t, loaded.Extra, service.OllamaCloudUsageSnapshotExtraKey)
|
||||
}
|
||||
}
|
||||
|
||||
// F2:UpdateCredentials 的 OpenCode 分支必须与通用身份变化不变量一致——
|
||||
// 清 probe、OpenCode 两键以及不应跨身份残留的 Ollama session/auto/snapshot。
|
||||
func TestUpdateCredentialsOpenCodeGoClearsAllManagedStateOnIdentityChange(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
tx := testEntTx(t)
|
||||
repo := newAccountRepositoryWithSQL(tx.Client(), tx, nil)
|
||||
now := time.Now().UTC()
|
||||
|
||||
account := mustCreateAccount(t, tx.Client(), &service.Account{
|
||||
Name: "opencode-credentials", Platform: service.PlatformOpenAI, Type: service.AccountTypeAPIKey,
|
||||
Credentials: map[string]any{"api_key": "old-key", "base_url": "https://opencode.ai/zen/go/v1"},
|
||||
Extra: openCodeGoManagedExtra(now),
|
||||
})
|
||||
|
||||
require.NoError(t, repo.UpdateCredentials(ctx, account.ID, map[string]any{
|
||||
"api_key": "new-key", "base_url": "https://opencode.ai/zen/go/v1",
|
||||
}))
|
||||
loaded, err := repo.GetByID(ctx, account.ID)
|
||||
require.NoError(t, err)
|
||||
require.NotContains(t, loaded.Extra, service.UpstreamBillingProbeExtraKey)
|
||||
require.NotContains(t, loaded.Extra, service.OpenCodeGoUsageAutoRefreshExtraKey)
|
||||
require.NotContains(t, loaded.Extra, service.OpenCodeGoUsageSnapshotExtraKey)
|
||||
require.NotContains(t, loaded.Extra, service.OllamaCloudUsageSessionExtraKey)
|
||||
require.NotContains(t, loaded.Extra, service.OllamaCloudUsageAutoRefreshExtraKey)
|
||||
require.NotContains(t, loaded.Extra, service.OllamaCloudUsageSnapshotExtraKey)
|
||||
}
|
||||
|
||||
// F3:新 credentials 缺 base_url(NULL)时 OpenCode 分支必须仍命中(NULL-safe),
|
||||
// 否则 OpenCode 受管状态会残留。api_key 保持不变,专门压 NOT regex(NULL) 路径。
|
||||
func TestUpdateCredentialsOpenCodeGoMissingBaseURLClearsManagedState(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
tx := testEntTx(t)
|
||||
repo := newAccountRepositoryWithSQL(tx.Client(), tx, nil)
|
||||
now := time.Now().UTC()
|
||||
|
||||
account := mustCreateAccount(t, tx.Client(), &service.Account{
|
||||
Name: "opencode-missing-base-url", Platform: service.PlatformOpenAI, Type: service.AccountTypeAPIKey,
|
||||
Credentials: map[string]any{"api_key": "old-key", "base_url": "https://opencode.ai/zen/go/v1"},
|
||||
Extra: map[string]any{
|
||||
service.OpenCodeGoUsageAutoRefreshExtraKey: true,
|
||||
service.OpenCodeGoUsageSnapshotExtraKey: map[string]any{
|
||||
"status": service.OpenCodeGoUsageStatusOK, "last_attempt_at": now, "next_refresh_at": now.Add(time.Hour),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
require.NoError(t, repo.UpdateCredentials(ctx, account.ID, map[string]any{
|
||||
"api_key": "old-key",
|
||||
}))
|
||||
loaded, err := repo.GetByID(ctx, account.ID)
|
||||
require.NoError(t, err)
|
||||
require.NotContains(t, loaded.Extra, service.OpenCodeGoUsageAutoRefreshExtraKey)
|
||||
require.NotContains(t, loaded.Extra, service.OpenCodeGoUsageSnapshotExtraKey)
|
||||
}
|
||||
|
||||
// F3 延伸:BulkUpdate 移除 base_url(null)时 opencode 受管键必须清除,
|
||||
// 否则 NOT regex(NULL) 为 NULL 会令 WHEN 不命中而残留。
|
||||
func TestBulkUpdateOpenCodeGoRemovedBaseURLClearsManagedState(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
tx := testEntTx(t)
|
||||
repo := newAccountRepositoryWithSQL(tx.Client(), tx, nil)
|
||||
now := time.Now().UTC()
|
||||
|
||||
account := mustCreateAccount(t, tx.Client(), &service.Account{
|
||||
Name: "opencode-bulk-remove-base-url", Platform: service.PlatformOpenAI, Type: service.AccountTypeAPIKey,
|
||||
Credentials: map[string]any{"api_key": "old-key", "base_url": "https://opencode.ai/zen/go/v1"},
|
||||
Extra: map[string]any{
|
||||
service.OpenCodeGoUsageAutoRefreshExtraKey: true,
|
||||
service.OpenCodeGoUsageSnapshotExtraKey: map[string]any{
|
||||
"status": service.OpenCodeGoUsageStatusOK, "last_attempt_at": now, "next_refresh_at": now.Add(time.Hour),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
rows, err := repo.BulkUpdate(ctx, []int64{account.ID}, service.AccountBulkUpdate{
|
||||
Credentials: map[string]any{"base_url": nil},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(1), rows)
|
||||
loaded, err := repo.GetByID(ctx, account.ID)
|
||||
require.NoError(t, err)
|
||||
require.NotContains(t, loaded.Extra, service.OpenCodeGoUsageAutoRefreshExtraKey)
|
||||
require.NotContains(t, loaded.Extra, service.OpenCodeGoUsageSnapshotExtraKey)
|
||||
}
|
||||
|
||||
// F6:SQL eligible 判定与 Go 侧对齐,接受显式默认端口 :443。
|
||||
func TestOpenCodeGoUsageEligibleSQLAcceptsDefaultPort443(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
tx := testEntTx(t)
|
||||
repo := newAccountRepositoryWithSQL(tx.Client(), tx, nil)
|
||||
|
||||
account := mustCreateAccount(t, tx.Client(), &service.Account{
|
||||
Name: "opencode-port-443", Platform: service.PlatformOpenAI, Type: service.AccountTypeAPIKey,
|
||||
Credentials: map[string]any{"api_key": "port-key", "base_url": "https://opencode.ai:443/zen/go/v1"},
|
||||
Extra: map[string]any{},
|
||||
})
|
||||
|
||||
// ListOpenCodeGoUsageGroupAccounts 走 opencodeGoUsageEligibleSQL,能解析到
|
||||
// :443 行即证明 SQL 正则与 Go 判定一致。
|
||||
groups, err := repo.ListOpenCodeGoUsageGroupAccounts(ctx, []*service.Account{account})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, groups, 1)
|
||||
require.Equal(t, account.ID, groups[0].ID)
|
||||
}
|
||||
|
||||
// F7:生产故障路径——前端脱敏普通编辑(incoming Extra 不含 OpenCode 受管键)经真实
|
||||
// repo.Update 走 lockAndMergeAccountProbeExtra 的 SELECT/Scan/UPDATE 后,custom 生效
|
||||
// 且两个受管键从锁定 DB 行回填;incoming Extra 伪造受管键时以锁定 DB 值为准。
|
||||
// 旧代码(无 opencode 三列与回填分支)下:脱敏编辑会直接抹掉 DB 里的开关与快照,
|
||||
// 伪造编辑会把伪造值原样写入,两条断言都会失败。
|
||||
func TestUpdateOpenCodeGoManagedStateSurvivesDesensitizedEdit(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
tx := testEntTx(t)
|
||||
repo := newAccountRepositoryWithSQL(tx.Client(), tx, nil)
|
||||
now := time.Now().UTC()
|
||||
|
||||
proxy := mustCreateProxy(t, tx.Client(), &service.Proxy{
|
||||
Name: "opencode-edit-proxy", Protocol: "http", Host: "127.0.0.1", Port: 3128,
|
||||
Username: "user", Password: "pass", Status: service.StatusActive,
|
||||
})
|
||||
account := mustCreateAccount(t, tx.Client(), &service.Account{
|
||||
Name: "opencode-desensitized-edit", Platform: service.PlatformOpenAI, Type: service.AccountTypeAPIKey,
|
||||
Credentials: map[string]any{"api_key": "edit-key", "base_url": "https://opencode.ai/zen/go/v1"},
|
||||
ProxyID: &proxy.ID,
|
||||
Extra: map[string]any{
|
||||
service.OpenCodeGoUsageAutoRefreshExtraKey: true,
|
||||
service.OpenCodeGoUsageSnapshotExtraKey: map[string]any{
|
||||
"status": service.OpenCodeGoUsageStatusOK, "last_attempt_at": now, "next_refresh_at": now.Add(time.Hour),
|
||||
},
|
||||
"custom": "original",
|
||||
},
|
||||
})
|
||||
|
||||
// 前端脱敏普通编辑:incoming Extra 不含两个受管键,只改 custom;base_url 用
|
||||
// :443 + 尾斜杠变体证明 normalized 身份判定(同 ID/platform/type/api_key/proxy)。
|
||||
edit, err := repo.GetByID(ctx, account.ID)
|
||||
require.NoError(t, err)
|
||||
edit.Credentials = map[string]any{"api_key": "edit-key", "base_url": "https://opencode.ai:443/zen/go/v1/"}
|
||||
edit.Extra = map[string]any{"custom": "edited"}
|
||||
require.NoError(t, repo.Update(ctx, edit))
|
||||
|
||||
loaded, err := repo.GetByID(ctx, account.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "edited", loaded.Extra["custom"])
|
||||
require.Equal(t, true, loaded.Extra[service.OpenCodeGoUsageAutoRefreshExtraKey],
|
||||
"脱敏编辑不得抹掉 auto_refresh")
|
||||
snapshot, ok := loaded.Extra[service.OpenCodeGoUsageSnapshotExtraKey].(map[string]any)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, service.OpenCodeGoUsageStatusOK, snapshot["status"], "脱敏编辑不得抹掉快照")
|
||||
|
||||
// incoming Extra 伪造受管键:锁定 DB 值必须胜出。
|
||||
forged, err := repo.GetByID(ctx, account.ID)
|
||||
require.NoError(t, err)
|
||||
forged.Extra = map[string]any{
|
||||
service.OpenCodeGoUsageAutoRefreshExtraKey: false,
|
||||
service.OpenCodeGoUsageSnapshotExtraKey: map[string]any{"status": "forged"},
|
||||
"custom": "edited-again",
|
||||
}
|
||||
require.NoError(t, repo.Update(ctx, forged))
|
||||
|
||||
loaded, err = repo.GetByID(ctx, account.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "edited-again", loaded.Extra["custom"])
|
||||
require.Equal(t, true, loaded.Extra[service.OpenCodeGoUsageAutoRefreshExtraKey],
|
||||
"伪造 auto_refresh 必须以锁定 DB 值为准")
|
||||
snapshot, ok = loaded.Extra[service.OpenCodeGoUsageSnapshotExtraKey].(map[string]any)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, service.OpenCodeGoUsageStatusOK, snapshot["status"], "伪造快照必须以锁定 DB 值为准")
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -66,3 +67,329 @@ func TestUpdateOpenCodeGoUsageSnapshotWritesSnapshotOnly(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, mock.ExpectationsWereMet())
|
||||
}
|
||||
|
||||
// lockAndMergeAccountProbeExtra 的 SELECT 现在多出三列(opencode 组身份 / 开关 / 快照),
|
||||
// 供通用 Update 路径做 OpenCode 受管键的原子回填。
|
||||
func openCodeGoMergeMockColumns() []string {
|
||||
return []string{
|
||||
"identity_unchanged", "ollama_group_unchanged", "ollama_proxy_unchanged",
|
||||
"enabled", "rate_sync_enabled", "snapshot",
|
||||
"ollama_session", "ollama_auto", "ollama_snapshot",
|
||||
"opencode_group_unchanged", "opencode_auto", "opencode_snapshot",
|
||||
}
|
||||
}
|
||||
|
||||
func openCodeGoSnapshotJSON() string {
|
||||
previousAttempt := time.Date(2026, time.August, 15, 8, 0, 0, 0, time.UTC)
|
||||
raw, err := json.Marshal(&service.OpenCodeGoUsageSnapshot{
|
||||
Status: service.OpenCodeGoUsageStatusOK,
|
||||
LastAttemptAt: previousAttempt,
|
||||
NextRefreshAt: previousAttempt.Add(time.Hour),
|
||||
})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
func TestLockAndMergeAccountProbeExtraPreservesOpenCodeGoManagedState(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
account *service.Account
|
||||
groupUnchanged bool
|
||||
proxyUnchanged bool
|
||||
databaseAuto string
|
||||
databaseSnapshot string
|
||||
inputExtra map[string]any
|
||||
wantAuto any // nil 表示键不存在
|
||||
wantSnapshot any // nil 表示键不存在
|
||||
wantSnapshotCheck bool
|
||||
}{
|
||||
{
|
||||
name: "identity unchanged restores both managed keys over forged input",
|
||||
account: openCodeGoUsageRepositoryAccount(),
|
||||
groupUnchanged: true,
|
||||
proxyUnchanged: true,
|
||||
databaseAuto: "true",
|
||||
databaseSnapshot: openCodeGoSnapshotJSON(),
|
||||
inputExtra: map[string]any{
|
||||
service.OpenCodeGoUsageAutoRefreshExtraKey: false,
|
||||
service.OpenCodeGoUsageSnapshotExtraKey: map[string]any{"status": "forged"},
|
||||
},
|
||||
wantAuto: true,
|
||||
wantSnapshotCheck: true,
|
||||
},
|
||||
{
|
||||
name: "identity unchanged keeps disabled switch from database",
|
||||
account: openCodeGoUsageRepositoryAccount(),
|
||||
groupUnchanged: true,
|
||||
proxyUnchanged: true,
|
||||
databaseAuto: "false",
|
||||
databaseSnapshot: openCodeGoSnapshotJSON(),
|
||||
inputExtra: map[string]any{
|
||||
service.OpenCodeGoUsageAutoRefreshExtraKey: true,
|
||||
},
|
||||
wantAuto: false,
|
||||
wantSnapshotCheck: true,
|
||||
},
|
||||
{
|
||||
name: "api key changed clears both managed keys",
|
||||
account: openCodeGoUsageRepositoryAccount(),
|
||||
groupUnchanged: false,
|
||||
proxyUnchanged: true,
|
||||
databaseAuto: "true",
|
||||
databaseSnapshot: openCodeGoSnapshotJSON(),
|
||||
inputExtra: map[string]any{
|
||||
service.OpenCodeGoUsageAutoRefreshExtraKey: true,
|
||||
service.OpenCodeGoUsageSnapshotExtraKey: map[string]any{"status": "stale"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "base url no longer eligible clears both managed keys",
|
||||
account: openCodeGoUsageRepositoryAccount(),
|
||||
groupUnchanged: false,
|
||||
proxyUnchanged: true,
|
||||
databaseAuto: "true",
|
||||
databaseSnapshot: openCodeGoSnapshotJSON(),
|
||||
inputExtra: map[string]any{"custom": "value"},
|
||||
},
|
||||
{
|
||||
name: "proxy changed invalidates snapshot but keeps auto-refresh switch",
|
||||
account: openCodeGoUsageRepositoryAccount(),
|
||||
groupUnchanged: true,
|
||||
proxyUnchanged: false,
|
||||
databaseAuto: "true",
|
||||
databaseSnapshot: openCodeGoSnapshotJSON(),
|
||||
inputExtra: map[string]any{"custom": "value"},
|
||||
wantAuto: true,
|
||||
},
|
||||
{
|
||||
name: "no longer eligible account never inherits managed keys",
|
||||
account: func() *service.Account {
|
||||
account := openCodeGoUsageRepositoryAccount()
|
||||
account.Type = service.AccountTypeOAuth
|
||||
return account
|
||||
}(),
|
||||
groupUnchanged: false,
|
||||
proxyUnchanged: true,
|
||||
databaseAuto: "true",
|
||||
databaseSnapshot: openCodeGoSnapshotJSON(),
|
||||
inputExtra: map[string]any{
|
||||
service.OpenCodeGoUsageAutoRefreshExtraKey: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
client, mock := newOllamaCloudUsageRepositoryTestClient(t)
|
||||
credentials, err := json.Marshal(normalizeJSONMap(tt.account.Credentials))
|
||||
require.NoError(t, err)
|
||||
mock.ExpectQuery(`(?s)`+regexp.QuoteMeta("SELECT")+`.*`+regexp.QuoteMeta("FOR NO KEY UPDATE")).
|
||||
WithArgs(tt.account.ID, tt.account.Platform, tt.account.Type, string(credentials), nil).
|
||||
WillReturnRows(sqlmock.NewRows(openCodeGoMergeMockColumns()).
|
||||
AddRow(false, false, tt.proxyUnchanged, nil, nil, nil, nil, nil, nil, tt.groupUnchanged, tt.databaseAuto, tt.databaseSnapshot))
|
||||
|
||||
got, err := lockAndMergeAccountProbeExtra(context.Background(), client, tt.account, nil, nil)
|
||||
require.NoError(t, err)
|
||||
if tt.wantAuto == nil {
|
||||
require.NotContains(t, got, service.OpenCodeGoUsageAutoRefreshExtraKey)
|
||||
} else {
|
||||
require.Equal(t, tt.wantAuto, got[service.OpenCodeGoUsageAutoRefreshExtraKey])
|
||||
}
|
||||
if tt.wantSnapshotCheck {
|
||||
snapshot, ok := got[service.OpenCodeGoUsageSnapshotExtraKey].(map[string]any)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, service.OpenCodeGoUsageStatusOK, snapshot["status"])
|
||||
} else if tt.wantSnapshot == nil {
|
||||
require.NotContains(t, got, service.OpenCodeGoUsageSnapshotExtraKey)
|
||||
} else {
|
||||
require.Equal(t, tt.wantSnapshot, got[service.OpenCodeGoUsageSnapshotExtraKey])
|
||||
}
|
||||
require.NoError(t, mock.ExpectationsWereMet())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateCredentialsOpenCodeGoIdentityChangeClearsManagedExtra(t *testing.T) {
|
||||
client, mock := newOllamaCloudUsageRepositoryTestClient(t)
|
||||
mock.ExpectBegin()
|
||||
// opencode 清理分支必须文本上先于 ollama 分支出现,否则 opencode 行的
|
||||
// api_key/base_url 变化会被先求值的 Ollama 分支遮蔽。
|
||||
mock.ExpectExec(`(?s)UPDATE accounts.*- 'opencode_go_usage_auto_refresh'.*- 'opencode_go_usage_snapshot'.*- 'ollama_cloud_usage_session'`).
|
||||
WithArgs(`{"api_key":"new-key","base_url":"https://opencode.ai/zen/go/v1"}`, int64(17)).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec(regexp.QuoteMeta("INSERT INTO scheduler_outbox")).
|
||||
WithArgs(service.SchedulerOutboxEventAccountChanged, int64(17), nil, nil, sqlmock.AnyArg()).
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
mock.ExpectCommit()
|
||||
repo := newAccountRepositoryWithSQL(client, nil, nil)
|
||||
|
||||
err := repo.UpdateCredentials(context.Background(), 17, map[string]any{
|
||||
"api_key": "new-key", "base_url": "https://opencode.ai/zen/go/v1",
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, mock.ExpectationsWereMet())
|
||||
}
|
||||
|
||||
// opencode → ollama 的 base_url 跨域变化同样必须清除 opencode 受管键(不再 eligible)。
|
||||
func TestUpdateCredentialsOpenCodeGoToOllamaCrossOverClearsManagedExtra(t *testing.T) {
|
||||
client, mock := newOllamaCloudUsageRepositoryTestClient(t)
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectExec(`(?s)UPDATE accounts.*opencode_go_usage_auto_refresh.*opencode_go_usage_snapshot`).
|
||||
WithArgs(`{"api_key":"same-key","base_url":"https://ollama.com/v1"}`, int64(17)).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec(regexp.QuoteMeta("INSERT INTO scheduler_outbox")).
|
||||
WithArgs(service.SchedulerOutboxEventAccountChanged, int64(17), nil, nil, sqlmock.AnyArg()).
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
mock.ExpectCommit()
|
||||
repo := newAccountRepositoryWithSQL(client, nil, nil)
|
||||
|
||||
err := repo.UpdateCredentials(context.Background(), 17, map[string]any{
|
||||
"api_key": "same-key", "base_url": "https://ollama.com/v1",
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, mock.ExpectationsWereMet())
|
||||
}
|
||||
|
||||
// OpenCode 清理分支必须带顶层 credentials DISTINCT 守卫:凭证未变化时不能误清受管键。
|
||||
func TestUpdateCredentialsOpenCodeGoCleanupRequiresChangedCredentials(t *testing.T) {
|
||||
client, mock := newOllamaCloudUsageRepositoryTestClient(t)
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectExec(`(?s)UPDATE accounts.*CASE.*AND credentials IS DISTINCT FROM \$1::jsonb`).
|
||||
WithArgs(`{"api_key":"same-key","base_url":"https://relay.example.com/v1"}`, int64(17)).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec(regexp.QuoteMeta("INSERT INTO scheduler_outbox")).
|
||||
WithArgs(service.SchedulerOutboxEventAccountChanged, int64(17), nil, nil, sqlmock.AnyArg()).
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
mock.ExpectCommit()
|
||||
repo := newAccountRepositoryWithSQL(client, nil, nil)
|
||||
|
||||
err := repo.UpdateCredentials(context.Background(), 17, map[string]any{
|
||||
"api_key": "same-key", "base_url": "https://relay.example.com/v1",
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, mock.ExpectationsWereMet())
|
||||
}
|
||||
|
||||
func TestBulkUpdateOpenCodeGoIdentityCleanupIsValueConditional(t *testing.T) {
|
||||
exec := &recordingSQLExecutor{result: rowsAffectedResult(1)}
|
||||
repo := newAccountRepositoryWithSQL(nil, exec, nil)
|
||||
|
||||
_, err := repo.BulkUpdate(context.Background(), []int64{17}, service.AccountBulkUpdate{
|
||||
Credentials: map[string]any{"api_key": "new-key"},
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, exec.execQueries)
|
||||
query := normalizeSQLWhitespace(exec.execQueries[0])
|
||||
// OpenCode 分支必须出现在 Ollama 分支之前,且只在旧行是 opencode base URL 时触发。
|
||||
require.Contains(t, query, "platform = 'openai' AND type = 'apikey'")
|
||||
require.Contains(t, query, "- 'opencode_go_usage_auto_refresh' - 'opencode_go_usage_snapshot'")
|
||||
opencodeBranch := strings.Index(query, "opencode_go_usage_auto_refresh")
|
||||
ollamaBranch := strings.Index(query, "ollama_cloud_usage_auto_refresh")
|
||||
require.NotEqual(t, -1, opencodeBranch)
|
||||
require.NotEqual(t, -1, ollamaBranch)
|
||||
require.Less(t, opencodeBranch, ollamaBranch)
|
||||
}
|
||||
|
||||
// F1 回归:OpenCode eligible 判定必须包含旧行 opencode base URL,否则 OpenAI+Ollama
|
||||
// 行在代理变化时会先命中 OpenCode 分支而遮蔽 Ollama 快照清理。这里断言 OpenCode 分支
|
||||
// 的 WHEN 携带 opencode 正则(与 Ollama 正则互斥),真实行为由 integration 测试覆盖。
|
||||
func TestBulkUpdateOpenCodeGoEligiblePredicateIncludesBaseURL(t *testing.T) {
|
||||
exec := &recordingSQLExecutor{result: rowsAffectedResult(1)}
|
||||
repo := newAccountRepositoryWithSQL(nil, exec, nil)
|
||||
|
||||
proxyID := int64(9)
|
||||
_, err := repo.BulkUpdate(context.Background(), []int64{17}, service.AccountBulkUpdate{
|
||||
ProxyID: &proxyID,
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, exec.execQueries)
|
||||
query := normalizeSQLWhitespace(exec.execQueries[0])
|
||||
// 第一个 WHEN 分支是 OpenCode 快照失效分支(代理变化),其 eligible 判定必须
|
||||
// 包含 opencode base URL 正则,使 OpenAI+Ollama 行无法命中该分支。
|
||||
caseStart := strings.Index(query, "CASE")
|
||||
firstThen := strings.Index(query, "THEN")
|
||||
require.NotEqual(t, -1, caseStart)
|
||||
require.NotEqual(t, -1, firstThen)
|
||||
require.Less(t, caseStart, firstThen)
|
||||
firstWhen := query[caseStart:firstThen]
|
||||
require.Contains(t, firstWhen, "[oO][pP][eE][nN][cC][oO][dD][eE]")
|
||||
require.Contains(t, firstWhen, "credentials ->> 'base_url'")
|
||||
require.NotContains(t, firstWhen, "[oO][lL][lL][aA][mM][aA]")
|
||||
}
|
||||
|
||||
// F3 延伸:BulkUpdate 的 opencode base_url 变化子句同样必须 NULL-safe——新 base_url
|
||||
// 缺失/为 null 时 regex(NULL) 为 NULL,NOT NULL 仍为 NULL 会令 WHEN 不命中而残留
|
||||
// OpenCode 状态;IS NOT TRUE 把 NULL 视为不匹配。
|
||||
func TestBulkUpdateOpenCodeGoBaseURLClauseIsNullSafe(t *testing.T) {
|
||||
exec := &recordingSQLExecutor{result: rowsAffectedResult(1)}
|
||||
repo := newAccountRepositoryWithSQL(nil, exec, nil)
|
||||
|
||||
_, err := repo.BulkUpdate(context.Background(), []int64{17}, service.AccountBulkUpdate{
|
||||
Credentials: map[string]any{"base_url": nil},
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, exec.execQueries)
|
||||
query := normalizeSQLWhitespace(exec.execQueries[0])
|
||||
require.Contains(t, query, "IS NOT TRUE")
|
||||
require.NotContains(t, query, "AND NOT btrim(credentials ->> 'base_url')")
|
||||
}
|
||||
|
||||
// F6 回归:SQL 正则与 service.isOpenCodeGoBaseURL 对齐,接受显式默认端口 :443。
|
||||
// 该正则同时用于 eligible 判定与身份清理,Go 侧接受而 SQL 侧拒绝会导致漏清/漏组。
|
||||
func TestOpenCodeGoBaseURLRegexSQLAcceptsDefaultPort443(t *testing.T) {
|
||||
re := regexp.MustCompile(opencodeGoBaseURLRegexSQL)
|
||||
for _, url := range []string{
|
||||
"https://opencode.ai/zen/go/v1",
|
||||
"https://opencode.ai/zen/go/v1/",
|
||||
"https://opencode.ai:443/zen/go/v1",
|
||||
"https://opencode.ai:443/zen/go/v1/",
|
||||
"HTTPS://OPENCODE.AI:443/ZEN/GO/V1",
|
||||
} {
|
||||
require.True(t, re.MatchString(url), "SQL regex must accept %s", url)
|
||||
}
|
||||
for _, url := range []string{
|
||||
"https://opencode.ai:444/zen/go/v1",
|
||||
"https://opencode.ai/v1",
|
||||
"https://ollama.com/zen/go/v1",
|
||||
"https://opencode.ai/zen/go/v1?x=1",
|
||||
} {
|
||||
require.False(t, re.MatchString(url), "SQL regex must reject %s", url)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBulkUpdateOpenCodeGoProxyChangeClearsSnapshotOnly(t *testing.T) {
|
||||
exec := &recordingSQLExecutor{result: rowsAffectedResult(1)}
|
||||
repo := newAccountRepositoryWithSQL(nil, exec, nil)
|
||||
|
||||
proxyID := int64(9)
|
||||
_, err := repo.BulkUpdate(context.Background(), []int64{17}, service.AccountBulkUpdate{
|
||||
ProxyID: &proxyID,
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, exec.execQueries)
|
||||
query := normalizeSQLWhitespace(exec.execQueries[0])
|
||||
require.Contains(t, query, "- 'opencode_go_usage_snapshot'")
|
||||
require.NotContains(t, query, "- 'opencode_go_usage_auto_refresh'")
|
||||
}
|
||||
|
||||
func TestInvalidateProxyProbeSnapshotsClearsOpenCodeGoSnapshot(t *testing.T) {
|
||||
client, mock := newOllamaCloudUsageRepositoryTestClient(t)
|
||||
mock.ExpectQuery(`(?s)UPDATE accounts.*opencode_go_usage_snapshot.*RETURNING id`).
|
||||
WithArgs(int64(9)).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(int64(17)))
|
||||
|
||||
ids, err := invalidateProxyProbeSnapshots(context.Background(), client, 9)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []int64{17}, ids)
|
||||
require.NoError(t, mock.ExpectationsWereMet())
|
||||
}
|
||||
|
||||
@@ -81,8 +81,8 @@ func TestLockAndMergeAccountProbeExtraUsesCurrentDatabaseSnapshot(t *testing.T)
|
||||
|
||||
mock.ExpectQuery(`(?s)`+regexp.QuoteMeta("SELECT")+`.*`+regexp.QuoteMeta("FOR NO KEY UPDATE")).
|
||||
WithArgs(int64(27), service.PlatformOpenAI, service.AccountTypeAPIKey, `{"api_key":"sk-test"}`, nil).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"identity_unchanged", "ollama_group_unchanged", "ollama_proxy_unchanged", "enabled", "rate_sync_enabled", "snapshot", "ollama_session", "ollama_auto", "ollama_snapshot"}).
|
||||
AddRow(tt.identityUnchanged, false, true, tt.databaseEnabled, nil, tt.databaseSnapshot, nil, nil, nil))
|
||||
WillReturnRows(sqlmock.NewRows([]string{"identity_unchanged", "ollama_group_unchanged", "ollama_proxy_unchanged", "enabled", "rate_sync_enabled", "snapshot", "ollama_session", "ollama_auto", "ollama_snapshot", "opencode_group_unchanged", "opencode_auto", "opencode_snapshot"}).
|
||||
AddRow(tt.identityUnchanged, false, true, tt.databaseEnabled, nil, tt.databaseSnapshot, nil, nil, nil, false, nil, nil))
|
||||
|
||||
account := &service.Account{
|
||||
ID: 27,
|
||||
@@ -172,8 +172,8 @@ func TestLockAndMergeAccountProbeExtraNeverInfersProbeFromRateSync(t *testing.T)
|
||||
|
||||
mock.ExpectQuery(`(?s)`+regexp.QuoteMeta("SELECT")+`.*`+regexp.QuoteMeta("FOR NO KEY UPDATE")).
|
||||
WithArgs(int64(31), service.PlatformOpenAI, service.AccountTypeAPIKey, `{"api_key":"sk-test"}`, nil).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"identity_unchanged", "ollama_group_unchanged", "ollama_proxy_unchanged", "enabled", "rate_sync_enabled", "snapshot", "ollama_session", "ollama_auto", "ollama_snapshot"}).
|
||||
AddRow(true, false, true, tt.databaseEnabled, tt.databaseRateSync, nil, nil, nil, nil))
|
||||
WillReturnRows(sqlmock.NewRows([]string{"identity_unchanged", "ollama_group_unchanged", "ollama_proxy_unchanged", "enabled", "rate_sync_enabled", "snapshot", "ollama_session", "ollama_auto", "ollama_snapshot", "opencode_group_unchanged", "opencode_auto", "opencode_snapshot"}).
|
||||
AddRow(true, false, true, tt.databaseEnabled, tt.databaseRateSync, nil, nil, nil, nil, false, nil, nil))
|
||||
|
||||
account := &service.Account{
|
||||
ID: 31,
|
||||
@@ -211,8 +211,8 @@ func TestLockAndMergeAccountProbeExtraProtectsOllamaManagedFields(t *testing.T)
|
||||
|
||||
mock.ExpectQuery(`(?s)`+regexp.QuoteMeta("SELECT")+`.*`+regexp.QuoteMeta("FOR NO KEY UPDATE")).
|
||||
WithArgs(int64(29), service.PlatformAnthropic, service.AccountTypeAPIKey, `{"api_key":"key","base_url":"https://ollama.com"}`, nil).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"identity_unchanged", "ollama_group_unchanged", "ollama_proxy_unchanged", "enabled", "rate_sync_enabled", "snapshot", "ollama_session", "ollama_auto", "ollama_snapshot"}).
|
||||
AddRow(identityUnchanged, identityUnchanged, true, nil, nil, nil, []byte(`"local-ciphertext"`), []byte(`true`), []byte(`{"status":"ok"}`)))
|
||||
WillReturnRows(sqlmock.NewRows([]string{"identity_unchanged", "ollama_group_unchanged", "ollama_proxy_unchanged", "enabled", "rate_sync_enabled", "snapshot", "ollama_session", "ollama_auto", "ollama_snapshot", "opencode_group_unchanged", "opencode_auto", "opencode_snapshot"}).
|
||||
AddRow(identityUnchanged, identityUnchanged, true, nil, nil, nil, []byte(`"local-ciphertext"`), []byte(`true`), []byte(`{"status":"ok"}`), false, nil, nil))
|
||||
|
||||
account := &service.Account{
|
||||
ID: 29, Platform: service.PlatformAnthropic, Type: service.AccountTypeAPIKey,
|
||||
@@ -371,8 +371,8 @@ func TestUpdateWithAccountBillingSettingsRollsBackWhenOutboxFails(t *testing.T)
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectQuery(`(?s)`+regexp.QuoteMeta("SELECT")+`.*`+regexp.QuoteMeta("FOR NO KEY UPDATE")).
|
||||
WithArgs(int64(27), service.PlatformOpenAI, service.AccountTypeAPIKey, `{"api_key":"sk-test"}`, nil).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"identity_unchanged", "ollama_group_unchanged", "ollama_proxy_unchanged", "enabled", "rate_sync_enabled", "snapshot", "ollama_session", "ollama_auto", "ollama_snapshot"}).
|
||||
AddRow(true, false, true, []byte(`true`), []byte(`true`), []byte(`{"status":"ok"}`), nil, nil, nil))
|
||||
WillReturnRows(sqlmock.NewRows([]string{"identity_unchanged", "ollama_group_unchanged", "ollama_proxy_unchanged", "enabled", "rate_sync_enabled", "snapshot", "ollama_session", "ollama_auto", "ollama_snapshot", "opencode_group_unchanged", "opencode_auto", "opencode_snapshot"}).
|
||||
AddRow(true, false, true, []byte(`true`), []byte(`true`), []byte(`{"status":"ok"}`), nil, nil, nil, false, nil, nil))
|
||||
mock.ExpectExec(`(?s)UPDATE .*accounts.*SET.*WHERE .*id.*`).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectQuery(`(?s)SELECT .* FROM "accounts" WHERE "id" = \$1`).
|
||||
|
||||
@@ -226,7 +226,8 @@ func invalidateProxyProbeSnapshots(ctx context.Context, exec sqlExecutor, proxyI
|
||||
UPDATE accounts
|
||||
SET extra = COALESCE(extra, '{}'::jsonb)
|
||||
- 'upstream_billing_probe'
|
||||
- 'ollama_cloud_usage_snapshot',
|
||||
- 'ollama_cloud_usage_snapshot'
|
||||
- 'opencode_go_usage_snapshot',
|
||||
updated_at = NOW()
|
||||
WHERE proxy_id = $1
|
||||
AND type = 'apikey'
|
||||
@@ -236,6 +237,9 @@ func invalidateProxyProbeSnapshots(ctx context.Context, exec sqlExecutor, proxyI
|
||||
OR (platform IN (`+ollamaCloudUsagePlatformsSQL+`)
|
||||
AND extra ? 'ollama_cloud_usage_snapshot'
|
||||
AND extra -> 'ollama_cloud_usage_snapshot' <> 'null'::jsonb)
|
||||
OR (platform = 'openai'
|
||||
AND extra ? 'opencode_go_usage_snapshot'
|
||||
AND extra -> 'opencode_go_usage_snapshot' <> 'null'::jsonb)
|
||||
)
|
||||
AND deleted_at IS NULL
|
||||
RETURNING id
|
||||
|
||||
@@ -418,6 +418,8 @@ func buildAccountForCreate(input *CreateAccountInput, accountExtra map[string]an
|
||||
delete(accountExtra, OllamaCloudUsageSessionExtraKey)
|
||||
delete(accountExtra, OllamaCloudUsageAutoRefreshExtraKey)
|
||||
delete(accountExtra, OllamaCloudUsageSnapshotExtraKey)
|
||||
delete(accountExtra, OpenCodeGoUsageAutoRefreshExtraKey)
|
||||
delete(accountExtra, OpenCodeGoUsageSnapshotExtraKey)
|
||||
accountExtra = prepareCodexFingerprintExtraForCreate(input.Platform, input.Type, accountExtra)
|
||||
account := &Account{
|
||||
Name: input.Name,
|
||||
@@ -598,6 +600,7 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U
|
||||
}
|
||||
previousProbeIdentity := upstreamBillingProbeIdentity(account)
|
||||
previousOllamaUsageIdentity := ollamaCloudUsageIdentity(account)
|
||||
previousOpenCodeUsageIdentity := openCodeGoUsageIdentity(account)
|
||||
// 安全/身份不变量(影子账号):通用更新路径被 edit/re-auth/refresh/batch 共用,
|
||||
// 必须在此守住,否则仅在创建时的保证可被这些路径绕过。
|
||||
if account.IsCredentialShadow() {
|
||||
@@ -674,6 +677,8 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U
|
||||
delete(normalizedExtra, OllamaCloudUsageSessionExtraKey)
|
||||
delete(normalizedExtra, OllamaCloudUsageAutoRefreshExtraKey)
|
||||
delete(normalizedExtra, OllamaCloudUsageSnapshotExtraKey)
|
||||
delete(normalizedExtra, OpenCodeGoUsageAutoRefreshExtraKey)
|
||||
delete(normalizedExtra, OpenCodeGoUsageSnapshotExtraKey)
|
||||
// 保留配额用量和专用服务受管字段,防止普通账号编辑意外覆盖。
|
||||
for _, key := range []string{
|
||||
"quota_used",
|
||||
@@ -689,6 +694,8 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U
|
||||
OllamaCloudUsageAutoRefreshExtraKey,
|
||||
OllamaCloudUsageSnapshotExtraKey,
|
||||
OpenAIAutoResetCreditStateExtraKey,
|
||||
OpenCodeGoUsageAutoRefreshExtraKey,
|
||||
OpenCodeGoUsageSnapshotExtraKey,
|
||||
} {
|
||||
if v, ok := account.Extra[key]; ok {
|
||||
normalizedExtra[key] = v
|
||||
@@ -775,6 +782,17 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U
|
||||
delete(account.Extra, OllamaCloudUsageSnapshotExtraKey)
|
||||
}
|
||||
}
|
||||
// OpenCode Go 受管键:身份改变或不再 eligible 时随本次写入清除,防止跨组污染。
|
||||
// (代理变化只失效快照而保留开关,由 repository 合并层在锁定的 DB 行上裁决。)
|
||||
if account.Extra != nil {
|
||||
if !IsOpenCodeGoUsageAccount(account) {
|
||||
delete(account.Extra, OpenCodeGoUsageAutoRefreshExtraKey)
|
||||
delete(account.Extra, OpenCodeGoUsageSnapshotExtraKey)
|
||||
} else if !reflect.DeepEqual(previousOpenCodeUsageIdentity, openCodeGoUsageIdentity(account)) {
|
||||
delete(account.Extra, OpenCodeGoUsageAutoRefreshExtraKey)
|
||||
delete(account.Extra, OpenCodeGoUsageSnapshotExtraKey)
|
||||
}
|
||||
}
|
||||
// 只在指针非 nil 时更新 Concurrency(支持设置为 0)
|
||||
if input.Concurrency != nil {
|
||||
account.Concurrency = normalizeAccountConcurrency(account.Platform, account.Type, *input.Concurrency)
|
||||
@@ -910,6 +928,8 @@ func (s *adminServiceImpl) UpdateAccountExtra(ctx context.Context, id int64, upd
|
||||
delete(updates, OllamaCloudUsageSessionExtraKey)
|
||||
delete(updates, OllamaCloudUsageAutoRefreshExtraKey)
|
||||
delete(updates, OllamaCloudUsageSnapshotExtraKey)
|
||||
delete(updates, OpenCodeGoUsageAutoRefreshExtraKey)
|
||||
delete(updates, OpenCodeGoUsageSnapshotExtraKey)
|
||||
if _, exists := updates[openAILongContextBillingEnabledKey]; exists {
|
||||
account, err := s.accountRepo.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
@@ -937,6 +957,8 @@ func (s *adminServiceImpl) BulkUpdateAccounts(ctx context.Context, input *BulkUp
|
||||
delete(input.Extra, OllamaCloudUsageSessionExtraKey)
|
||||
delete(input.Extra, OllamaCloudUsageAutoRefreshExtraKey)
|
||||
delete(input.Extra, OllamaCloudUsageSnapshotExtraKey)
|
||||
delete(input.Extra, OpenCodeGoUsageAutoRefreshExtraKey)
|
||||
delete(input.Extra, OpenCodeGoUsageSnapshotExtraKey)
|
||||
|
||||
if len(input.AccountIDs) == 0 && input.Filters != nil {
|
||||
accountIDs, err := s.resolveBulkUpdateTargetIDs(ctx, input.Filters)
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/xai"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -749,3 +750,166 @@ func TestBulkUpdateAccountsKeepsProbeSnapshotForUnrelatedCredentials(t *testing.
|
||||
require.Len(t, repo.bulkUpdates, 1)
|
||||
require.NotContains(t, repo.bulkUpdates[0].Extra, UpstreamBillingProbeExtraKey)
|
||||
}
|
||||
|
||||
func openCodeGoAdminUsageAccount(id int64, apiKey string) *Account {
|
||||
previousAttempt := time.Date(2026, time.August, 15, 8, 0, 0, 0, time.UTC)
|
||||
return &Account{
|
||||
ID: id,
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeAPIKey,
|
||||
Status: StatusActive,
|
||||
Credentials: map[string]any{
|
||||
"api_key": apiKey,
|
||||
"base_url": "https://opencode.ai/zen/go/v1",
|
||||
},
|
||||
Extra: map[string]any{
|
||||
OpenCodeGoUsageAutoRefreshExtraKey: true,
|
||||
OpenCodeGoUsageSnapshotExtraKey: &OpenCodeGoUsageSnapshot{
|
||||
Status: OpenCodeGoUsageStatusOK,
|
||||
LastAttemptAt: previousAttempt,
|
||||
NextRefreshAt: previousAttempt.Add(time.Hour),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// 普通账号编辑(脱敏 extra 不带受管键)不得丢 OpenCode Go 受管状态。
|
||||
func TestUpdateAccountPreservesOpenCodeGoManagedStateForUnrelatedEdit(t *testing.T) {
|
||||
accountID := int64(210)
|
||||
repo := &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{
|
||||
accountID: openCodeGoAdminUsageAccount(accountID, "sk-1"),
|
||||
}}
|
||||
|
||||
svc := &adminServiceImpl{accountRepo: repo}
|
||||
updated, err := svc.UpdateAccount(context.Background(), accountID, &UpdateAccountInput{
|
||||
Extra: map[string]any{"custom": "value"},
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, true, updated.Extra[OpenCodeGoUsageAutoRefreshExtraKey])
|
||||
snapshot, ok := updated.Extra[OpenCodeGoUsageSnapshotExtraKey].(*OpenCodeGoUsageSnapshot)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, OpenCodeGoUsageStatusOK, snapshot.Status)
|
||||
require.Equal(t, "value", updated.Extra["custom"])
|
||||
}
|
||||
|
||||
// 客户端在 extra 里伪造受管键必须被丢弃,以数据库中的受管状态为准。
|
||||
func TestUpdateAccountRejectsInjectedOpenCodeGoManagedKeys(t *testing.T) {
|
||||
accountID := int64(211)
|
||||
repo := &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{
|
||||
accountID: openCodeGoAdminUsageAccount(accountID, "sk-1"),
|
||||
}}
|
||||
|
||||
svc := &adminServiceImpl{accountRepo: repo}
|
||||
updated, err := svc.UpdateAccount(context.Background(), accountID, &UpdateAccountInput{
|
||||
Extra: map[string]any{
|
||||
OpenCodeGoUsageAutoRefreshExtraKey: false,
|
||||
OpenCodeGoUsageSnapshotExtraKey: map[string]any{"status": "forged"},
|
||||
"custom": "value",
|
||||
},
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, true, updated.Extra[OpenCodeGoUsageAutoRefreshExtraKey])
|
||||
snapshot, ok := updated.Extra[OpenCodeGoUsageSnapshotExtraKey].(*OpenCodeGoUsageSnapshot)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, OpenCodeGoUsageStatusOK, snapshot.Status)
|
||||
require.Equal(t, "value", updated.Extra["custom"])
|
||||
}
|
||||
|
||||
// OpenCode 身份改变(api_key / base_url / type)必须清除旧受管状态,防止跨组污染。
|
||||
func TestUpdateAccountClearsOpenCodeGoManagedStateWhenIdentityChanges(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input *UpdateAccountInput
|
||||
}{
|
||||
{name: "api key", input: &UpdateAccountInput{Credentials: map[string]any{"api_key": "sk-new"}}},
|
||||
{name: "base url", input: &UpdateAccountInput{Credentials: map[string]any{"base_url": "https://api.openai.com/v1"}}},
|
||||
{name: "type", input: &UpdateAccountInput{Type: AccountTypeOAuth}},
|
||||
}
|
||||
|
||||
for i, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
accountID := int64(220 + i)
|
||||
repo := &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{
|
||||
accountID: openCodeGoAdminUsageAccount(accountID, "sk-1"),
|
||||
}}
|
||||
|
||||
updated, err := (&adminServiceImpl{accountRepo: repo}).UpdateAccount(context.Background(), accountID, tt.input)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotContains(t, updated.Extra, OpenCodeGoUsageAutoRefreshExtraKey)
|
||||
require.NotContains(t, updated.Extra, OpenCodeGoUsageSnapshotExtraKey)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateAccountExtra 是 key 级合并入口,伪造的受管键必须被剥离。
|
||||
func TestUpdateAccountExtraDropsOpenCodeGoManagedKeys(t *testing.T) {
|
||||
accountID := int64(230)
|
||||
repo := &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{
|
||||
accountID: openCodeGoAdminUsageAccount(accountID, "sk-1"),
|
||||
}}
|
||||
|
||||
svc := &adminServiceImpl{accountRepo: repo}
|
||||
err := svc.UpdateAccountExtra(context.Background(), accountID, map[string]any{
|
||||
OpenCodeGoUsageAutoRefreshExtraKey: false,
|
||||
OpenCodeGoUsageSnapshotExtraKey: map[string]any{"status": "forged"},
|
||||
"custom": "value",
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Len(t, repo.updates[accountID], 1)
|
||||
persisted := repo.updates[accountID][0]
|
||||
require.NotContains(t, persisted, OpenCodeGoUsageAutoRefreshExtraKey)
|
||||
require.NotContains(t, persisted, OpenCodeGoUsageSnapshotExtraKey)
|
||||
require.Equal(t, "value", persisted["custom"])
|
||||
}
|
||||
|
||||
// 批量更新同样不得接受伪造的受管键。
|
||||
func TestBulkUpdateAccountsDropsOpenCodeGoManagedKeys(t *testing.T) {
|
||||
accountID := int64(240)
|
||||
repo := &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{
|
||||
accountID: openCodeGoAdminUsageAccount(accountID, "sk-1"),
|
||||
}}
|
||||
|
||||
svc := &adminServiceImpl{accountRepo: repo}
|
||||
_, err := svc.BulkUpdateAccounts(context.Background(), &BulkUpdateAccountsInput{
|
||||
AccountIDs: []int64{accountID},
|
||||
Extra: map[string]any{
|
||||
OpenCodeGoUsageAutoRefreshExtraKey: false,
|
||||
OpenCodeGoUsageSnapshotExtraKey: map[string]any{"status": "forged"},
|
||||
"custom": "value",
|
||||
},
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Len(t, repo.bulkUpdates, 1)
|
||||
require.NotContains(t, repo.bulkUpdates[0].Extra, OpenCodeGoUsageAutoRefreshExtraKey)
|
||||
require.NotContains(t, repo.bulkUpdates[0].Extra, OpenCodeGoUsageSnapshotExtraKey)
|
||||
require.Equal(t, "value", repo.bulkUpdates[0].Extra["custom"])
|
||||
}
|
||||
|
||||
// 创建入口不接受伪造的受管键。
|
||||
func TestCreateAccountDropsOpenCodeGoManagedKeys(t *testing.T) {
|
||||
repo := &upstreamBillingProbeAccountRepo{}
|
||||
svc := &adminServiceImpl{accountRepo: repo}
|
||||
|
||||
created, err := svc.CreateAccount(context.Background(), &CreateAccountInput{
|
||||
Name: "opencode",
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeAPIKey,
|
||||
Credentials: map[string]any{"api_key": "sk-1", "base_url": "https://opencode.ai/zen/go/v1"},
|
||||
SkipDefaultGroupBind: true,
|
||||
Extra: map[string]any{
|
||||
OpenCodeGoUsageAutoRefreshExtraKey: true,
|
||||
OpenCodeGoUsageSnapshotExtraKey: map[string]any{"status": "forged"},
|
||||
"custom": "value",
|
||||
},
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotContains(t, created.Extra, OpenCodeGoUsageAutoRefreshExtraKey)
|
||||
require.NotContains(t, created.Extra, OpenCodeGoUsageSnapshotExtraKey)
|
||||
require.Equal(t, "value", created.Extra["custom"])
|
||||
}
|
||||
|
||||
@@ -742,6 +742,16 @@ func TestIsOpenCodeGoUsageAccount(t *testing.T) {
|
||||
account.Credentials["base_url"] = "https://opencode.ai/zen/go/v1/"
|
||||
require.True(t, IsOpenCodeGoUsageAccount(account))
|
||||
|
||||
// explicit default port :443 is allowed (parsed.Host == hostname+":443")
|
||||
account = base()
|
||||
account.Credentials["base_url"] = "https://opencode.ai:443/zen/go/v1"
|
||||
require.True(t, IsOpenCodeGoUsageAccount(account))
|
||||
|
||||
// non-default port is rejected
|
||||
account = base()
|
||||
account.Credentials["base_url"] = "https://opencode.ai:444/zen/go/v1"
|
||||
require.False(t, IsOpenCodeGoUsageAccount(account))
|
||||
|
||||
// scheme/host/path are case-insensitive
|
||||
account = base()
|
||||
account.Credentials["base_url"] = "HTTPS://OPENCODE.AI/ZEN/GO/V1"
|
||||
|
||||
Reference in New Issue
Block a user