account_repo_ollama_cloud_usage_test.go 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382
  1. package repository
  2. import (
  3. "context"
  4. "encoding/json"
  5. "regexp"
  6. "strings"
  7. "testing"
  8. "time"
  9. "github.com/DATA-DOG/go-sqlmock"
  10. dbent "github.com/Wei-Shaw/sub2api/ent"
  11. "github.com/Wei-Shaw/sub2api/internal/service"
  12. "github.com/stretchr/testify/require"
  13. "entgo.io/ent/dialect"
  14. entsql "entgo.io/ent/dialect/sql"
  15. )
  16. func newOllamaCloudUsageRepositoryTestClient(t *testing.T) (*dbent.Client, sqlmock.Sqlmock) {
  17. t.Helper()
  18. db, mock, err := sqlmock.New()
  19. require.NoError(t, err)
  20. t.Cleanup(func() { _ = db.Close() })
  21. client := dbent.NewClient(dbent.Driver(entsql.OpenDB(dialect.Postgres, db)))
  22. t.Cleanup(func() { _ = client.Close() })
  23. return client, mock
  24. }
  25. func ollamaCloudUsageRepositoryAccount() *service.Account {
  26. return &service.Account{
  27. ID: 17, Platform: service.PlatformOpenAI, Type: service.AccountTypeAPIKey,
  28. Credentials: map[string]any{"api_key": "key", "base_url": "https://ollama.com"},
  29. Extra: map[string]any{
  30. service.OllamaCloudUsageSessionExtraKey: "cipher:wos-session=secret",
  31. service.OllamaCloudUsageAutoRefreshExtraKey: true,
  32. },
  33. }
  34. }
  35. func TestUpdateOllamaCloudUsageSnapshotRowsAffectedZeroIsIdentityConflict(t *testing.T) {
  36. client, mock := newOllamaCloudUsageRepositoryTestClient(t)
  37. mock.ExpectBegin()
  38. expectOllamaCloudUsageGroupLock(mock, ollamaCloudUsageRepositoryAccount(), true,
  39. `"cipher:wos-session=secret"`, `true`, `null`)
  40. mock.ExpectExec(`(?s)`+regexp.QuoteMeta("UPDATE accounts")).
  41. WithArgs(sqlmock.AnyArg(), "key", sqlmock.AnyArg()).
  42. WillReturnResult(sqlmock.NewResult(0, 0))
  43. mock.ExpectRollback()
  44. repo := newAccountRepositoryWithSQL(client, nil, nil)
  45. err := repo.UpdateOllamaCloudUsageSnapshot(context.Background(), ollamaCloudUsageRepositoryAccount(), &service.OllamaCloudUsageSnapshot{
  46. Status: service.OllamaCloudUsageStatusOK,
  47. LastAttemptAt: time.Now(),
  48. NextRefreshAt: time.Now().Add(time.Hour),
  49. })
  50. require.ErrorIs(t, err, service.ErrOllamaCloudUsageIdentityChanged)
  51. require.NoError(t, mock.ExpectationsWereMet())
  52. }
  53. func expectOllamaCloudUsageGroupLock(
  54. mock sqlmock.Sqlmock,
  55. account *service.Account,
  56. anchorMatches bool,
  57. sessionJSON, autoJSON, snapshotJSON string,
  58. ) {
  59. apiKey, _ := account.Credentials["api_key"].(string)
  60. credentials, _ := json.Marshal(normalizeJSONMap(account.Credentials))
  61. var proxyID any
  62. if account.ProxyID != nil {
  63. proxyID = *account.ProxyID
  64. }
  65. mock.ExpectQuery(`(?s)`+regexp.QuoteMeta("SELECT")+`.*`+regexp.QuoteMeta("FOR NO KEY UPDATE")).
  66. WithArgs(apiKey, account.ID, account.Platform, account.Type, string(credentials), proxyID).
  67. WillReturnRows(sqlmock.NewRows([]string{"id", "anchor_matches", "session", "auto_refresh", "snapshot"}).
  68. AddRow(account.ID, anchorMatches, sessionJSON, autoJSON, snapshotJSON))
  69. }
  70. func TestOllamaCloudUsageManagedWriteRejectsChangedProxyIdentity(t *testing.T) {
  71. client, mock := newOllamaCloudUsageRepositoryTestClient(t)
  72. mock.ExpectBegin()
  73. mock.ExpectQuery(`(?s)` + regexp.QuoteMeta("SELECT protocol, host, port") + `.*` + regexp.QuoteMeta("FOR SHARE")).
  74. WithArgs(int64(9)).
  75. WillReturnRows(sqlmock.NewRows([]string{"protocol", "host", "port", "username", "password", "status"}).
  76. AddRow("http", "new.example", 3128, "user", "pass", service.StatusActive))
  77. mock.ExpectRollback()
  78. account := ollamaCloudUsageRepositoryAccount()
  79. proxyID := int64(9)
  80. account.ProxyID = &proxyID
  81. account.Proxy = &service.Proxy{
  82. ID: proxyID, Protocol: "http", Host: "old.example", Port: 3128,
  83. Username: "user", Password: "pass", Status: service.StatusActive,
  84. }
  85. repo := newAccountRepositoryWithSQL(client, nil, nil)
  86. err := repo.SaveOllamaCloudUsageSession(context.Background(), account, "cipher:wos-session=replacement", true)
  87. require.ErrorIs(t, err, service.ErrOllamaCloudUsageIdentityChanged)
  88. require.NoError(t, mock.ExpectationsWereMet())
  89. }
  90. func TestSaveAndDeleteOllamaCloudUsageSessionKeepCiphertextOutOfSQL(t *testing.T) {
  91. var capturedSQL []string
  92. matcher := sqlmock.QueryMatcherFunc(func(expectedSQL, actualSQL string) error {
  93. capturedSQL = append(capturedSQL, actualSQL)
  94. return sqlmock.QueryMatcherRegexp.Match(expectedSQL, actualSQL)
  95. })
  96. db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(matcher))
  97. require.NoError(t, err)
  98. t.Cleanup(func() { _ = db.Close() })
  99. client := dbent.NewClient(dbent.Driver(entsql.OpenDB(dialect.Postgres, db)))
  100. t.Cleanup(func() { _ = client.Close() })
  101. repo := newAccountRepositoryWithSQL(client, db, nil)
  102. account := ollamaCloudUsageRepositoryAccount()
  103. const replacement = "cipher:wos-session=browser-cookie-secret"
  104. mock.ExpectBegin()
  105. expectOllamaCloudUsageGroupLock(mock, account, true, `"cipher:wos-session=secret"`, `true`, `null`)
  106. mock.ExpectExec(`(?s)UPDATE accounts.*ollama_cloud_usage_session.*ollama_cloud_usage_auto_refresh.*ollama_cloud_usage_snapshot`).
  107. WithArgs(`{"ollama_cloud_usage_auto_refresh":true,"ollama_cloud_usage_session":"cipher:wos-session=browser-cookie-secret"}`, "key", sqlmock.AnyArg()).
  108. WillReturnResult(sqlmock.NewResult(0, 1))
  109. mock.ExpectCommit()
  110. require.NoError(t, repo.SaveOllamaCloudUsageSession(context.Background(), account, replacement, true))
  111. account.Extra[service.OllamaCloudUsageSessionExtraKey] = replacement
  112. mock.ExpectBegin()
  113. expectOllamaCloudUsageGroupLock(mock, account, true, `"cipher:wos-session=browser-cookie-secret"`, `true`, `null`)
  114. mock.ExpectExec(`(?s)UPDATE accounts.*ollama_cloud_usage_session.*ollama_cloud_usage_auto_refresh.*ollama_cloud_usage_snapshot`).
  115. WithArgs(`{}`, "key", sqlmock.AnyArg()).
  116. WillReturnResult(sqlmock.NewResult(0, 1))
  117. mock.ExpectCommit()
  118. require.NoError(t, repo.DeleteOllamaCloudUsageSession(context.Background(), account))
  119. require.NotEmpty(t, capturedSQL)
  120. for _, query := range capturedSQL {
  121. require.NotContains(t, query, "browser-cookie-secret")
  122. }
  123. require.NoError(t, mock.ExpectationsWereMet())
  124. }
  125. func TestOllamaCloudBaseURLSQLRegexMatchesServiceSemantics(t *testing.T) {
  126. for _, baseURL := range []string{
  127. "https://ollama.com",
  128. "HTTPS://WWW.OLLAMA.COM:443/v1",
  129. "https://ollama.com/V1",
  130. "https://ollama.com/v1/",
  131. "https://ollama.com.evil.test/v1",
  132. } {
  133. t.Run(baseURL, func(t *testing.T) {
  134. matched, err := regexp.MatchString(ollamaCloudBaseURLRegexSQL, baseURL)
  135. require.NoError(t, err)
  136. account := ollamaCloudUsageRepositoryAccount()
  137. account.Credentials["base_url"] = baseURL
  138. require.Equal(t, service.IsOllamaCloudUsageAccount(account), matched)
  139. })
  140. }
  141. }
  142. func TestListOllamaCloudUsageGroupAccountsUsesOneStrictBatchQuery(t *testing.T) {
  143. db, mock, err := sqlmock.New()
  144. require.NoError(t, err)
  145. t.Cleanup(func() { _ = db.Close() })
  146. var capturedSQL string
  147. mock.ExpectQuery("SELECT id").
  148. WithArgs(sqlmock.AnyArg()).
  149. WillReturnRows(sqlmock.NewRows([]string{"id"}))
  150. repo := newAccountRepositoryWithSQL(nil, captureQuerySQL{db: db, captured: &capturedSQL}, nil)
  151. first := ollamaCloudUsageRepositoryAccount()
  152. second := ollamaCloudUsageRepositoryAccount()
  153. second.ID = 18
  154. second.Platform = service.PlatformAnthropic
  155. second.Credentials = map[string]any{"api_key": "key", "base_url": "https://www.ollama.com:443/v1"}
  156. accounts, err := repo.ListOllamaCloudUsageGroupAccounts(context.Background(), []*service.Account{first, second})
  157. require.NoError(t, err)
  158. require.Empty(t, accounts)
  159. query := normalizeSQLWhitespace(capturedSQL)
  160. require.Contains(t, query, "credentials ->> 'api_key' = ANY($1)")
  161. require.Contains(t, query, "platform IN ('openai', 'anthropic', 'kimi', 'zhipu', 'deepseek', 'minimax')")
  162. require.Contains(t, query, "jsonb_typeof(credentials -> 'api_key') = 'string'")
  163. require.Contains(t, query, ollamaCloudBaseURLMatchesSQL("credentials ->> 'base_url'"))
  164. require.NotContains(t, query, "~*")
  165. require.NoError(t, mock.ExpectationsWereMet())
  166. }
  167. func TestListDueOllamaCloudUsageAccountsFiltersOrdersAndLimits(t *testing.T) {
  168. db, mock, err := sqlmock.New()
  169. require.NoError(t, err)
  170. t.Cleanup(func() { _ = db.Close() })
  171. now := time.Date(2026, time.July, 22, 12, 0, 0, 0, time.UTC)
  172. debounce := time.Minute
  173. maxWait := time.Hour
  174. var capturedSQL string
  175. mock.ExpectQuery("WITH eligible AS").
  176. WithArgs(now.UTC(), debounce.Seconds(), maxWait.Seconds(), 20, service.OllamaCloudUsageMinFetchInterval.Seconds()).
  177. WillReturnRows(sqlmock.NewRows([]string{"id", "group_last_used_at"}))
  178. repo := newAccountRepositoryWithSQL(nil, captureQuerySQL{db: db, captured: &capturedSQL}, nil)
  179. accounts, err := repo.ListDueOllamaCloudUsageAccounts(context.Background(), now, debounce, maxWait, 20)
  180. require.NoError(t, err)
  181. require.Empty(t, accounts)
  182. normalized := normalizeSQLWhitespace(capturedSQL)
  183. for _, clause := range []string{
  184. "deleted_at IS NULL",
  185. "status = 'active'",
  186. "platform IN ('openai', 'anthropic', 'kimi', 'zhipu', 'deepseek', 'minimax')",
  187. "type = 'apikey'",
  188. ollamaCloudBaseURLMatchesSQL("credentials ->> 'base_url'"),
  189. "jsonb_typeof(extra -> 'ollama_cloud_usage_session') = 'string'",
  190. `extra @> '{"ollama_cloud_usage_auto_refresh": true}'::jsonb`,
  191. "MAX(last_used_at) AS group_last_used_at",
  192. "PARTITION BY api_key",
  193. "WHERE group_rank = 1",
  194. "LIMIT $4",
  195. "make_interval(secs => $2::double precision)",
  196. "make_interval(secs => $3::double precision)",
  197. // Minimum interval floor between successful fetches.
  198. "make_interval(secs => $5::double precision)",
  199. // jsonpath .datetime() only accepts the ISO-8601 "Z" designator from
  200. // PostgreSQL 17 on, and this service writes UTC timestamps. Without this
  201. // rewrite every parsed_* column is NULL on 14-16 and the due filter
  202. // collapses into its fail-open branch.
  203. `regexp_replace( regexp_replace( fetched_at, '(\.[0-9]{6})[0-9]+(Z|[+-][0-9]{2}:[0-9]{2})$', '\1\2' ), 'Z$', '+00:00' )`,
  204. "group_last_used_at > parsed_fetched_at::timestamptz",
  205. "group_last_used_at > parsed_last_attempt_at::timestamptz",
  206. "$1 >= activity_due_at",
  207. "COALESCE(parsed_next_refresh_at::timestamptz, '-infinity'::timestamptz)",
  208. "ORDER BY due_class, due_at NULLS FIRST, id",
  209. } {
  210. require.Contains(t, normalized, clause)
  211. }
  212. require.NotContains(t, normalized, "~*")
  213. require.NoError(t, mock.ExpectationsWereMet())
  214. }
  215. func TestBulkUpdateOllamaIdentityCleanupIsValueConditional(t *testing.T) {
  216. exec := &recordingSQLExecutor{result: rowsAffectedResult(1)}
  217. repo := newAccountRepositoryWithSQL(nil, exec, nil)
  218. _, err := repo.BulkUpdate(context.Background(), []int64{17}, service.AccountBulkUpdate{
  219. Credentials: map[string]any{"base_url": "https://www.ollama.com:443/v1"},
  220. })
  221. require.NoError(t, err)
  222. require.NotEmpty(t, exec.execQueries)
  223. query := normalizeSQLWhitespace(exec.execQueries[0])
  224. require.Contains(t, query, "NOT ("+ollamaCloudBaseURLMatchesSQL("credentials ->> 'base_url'"))
  225. require.Contains(t, query, ollamaCloudBaseURLMatchesSQL("$1::jsonb ->> 'base_url'"))
  226. require.NotContains(t, query, "~*")
  227. require.Contains(t, query, "platform IN ('openai', 'anthropic', 'kimi', 'zhipu', 'deepseek', 'minimax') AND type = 'apikey'")
  228. require.Contains(t, query, "- 'ollama_cloud_usage_session' - 'ollama_cloud_usage_auto_refresh' - 'ollama_cloud_usage_snapshot'")
  229. payload, ok := exec.execArgs[0][0].([]byte)
  230. require.True(t, ok)
  231. require.NotContains(t, string(payload), service.OllamaCloudUsageSnapshotExtraKey)
  232. }
  233. func TestUpdateCredentialsIdentityChangeClearsAllOllamaManagedExtra(t *testing.T) {
  234. client, mock := newOllamaCloudUsageRepositoryTestClient(t)
  235. mock.ExpectBegin()
  236. mock.ExpectExec(`(?s)UPDATE accounts.*credentials -> 'api_key' IS DISTINCT FROM.*ollama_cloud_usage_session.*ollama_cloud_usage_auto_refresh.*ollama_cloud_usage_snapshot`).
  237. WithArgs(`{"api_key":"new-key","base_url":"https://ollama.com"}`, int64(17)).
  238. WillReturnResult(sqlmock.NewResult(0, 1))
  239. mock.ExpectExec(regexp.QuoteMeta("INSERT INTO scheduler_outbox")).
  240. WithArgs(service.SchedulerOutboxEventAccountChanged, int64(17), nil, nil, sqlmock.AnyArg()).
  241. WillReturnResult(sqlmock.NewResult(1, 1))
  242. mock.ExpectCommit()
  243. repo := newAccountRepositoryWithSQL(client, nil, nil)
  244. err := repo.UpdateCredentials(context.Background(), 17, map[string]any{
  245. "api_key": "new-key", "base_url": "https://ollama.com",
  246. })
  247. require.NoError(t, err)
  248. require.NoError(t, mock.ExpectationsWereMet())
  249. }
  250. func TestDisableOllamaCloudUsageAutoRefreshUsesGroupIdentityCAS(t *testing.T) {
  251. client, mock := newOllamaCloudUsageRepositoryTestClient(t)
  252. account := ollamaCloudUsageRepositoryAccount()
  253. mock.ExpectBegin()
  254. expectOllamaCloudUsageGroupLock(mock, account, true, `"cipher:wos-session=secret"`, `true`, `null`)
  255. mock.ExpectExec(`(?s)UPDATE accounts.*ollama_cloud_usage_auto_refresh`).
  256. WithArgs(`{"ollama_cloud_usage_auto_refresh":false,"ollama_cloud_usage_session":"cipher:wos-session=secret"}`, "key", sqlmock.AnyArg()).
  257. WillReturnResult(sqlmock.NewResult(0, 1))
  258. mock.ExpectCommit()
  259. repo := newAccountRepositoryWithSQL(client, nil, nil)
  260. err := repo.DisableOllamaCloudUsageAutoRefresh(context.Background(), account)
  261. require.NoError(t, err)
  262. require.NoError(t, mock.ExpectationsWereMet())
  263. }
  264. // Ollama 清理分支必须带顶层 credentials DISTINCT 守卫:没有它,非 Ollama 的
  265. // openai/anthropic apikey 账号在凭证未变化的持久化上也会误清探测快照。
  266. func TestUpdateCredentialsCleanupBranchRequiresChangedCredentials(t *testing.T) {
  267. client, mock := newOllamaCloudUsageRepositoryTestClient(t)
  268. mock.ExpectBegin()
  269. mock.ExpectExec(`(?s)UPDATE accounts.*CASE.*AND credentials IS DISTINCT FROM \$1::jsonb\s+AND \(\s+credentials -> 'api_key' IS DISTINCT FROM`).
  270. WithArgs(`{"api_key":"same-key","base_url":"https://relay.example.com/v1"}`, int64(17)).
  271. WillReturnResult(sqlmock.NewResult(0, 1))
  272. mock.ExpectExec(regexp.QuoteMeta("INSERT INTO scheduler_outbox")).
  273. WithArgs(service.SchedulerOutboxEventAccountChanged, int64(17), nil, nil, sqlmock.AnyArg()).
  274. WillReturnResult(sqlmock.NewResult(1, 1))
  275. mock.ExpectCommit()
  276. repo := newAccountRepositoryWithSQL(client, nil, nil)
  277. err := repo.UpdateCredentials(context.Background(), 17, map[string]any{
  278. "api_key": "same-key", "base_url": "https://relay.example.com/v1",
  279. })
  280. require.NoError(t, err)
  281. require.NoError(t, mock.ExpectationsWereMet())
  282. }
  283. // SQL 平台白名单常量与 service 判定必须互为镜像:对每个已知平台,常量里的
  284. // 成员关系都要与 IsOllamaCloudUsageAccount(apikey + 官方 ollama.com)一致,
  285. // 防止两侧平台列表各自漂移。
  286. func TestOllamaCloudUsagePlatformWhitelistMatchesServicePredicate(t *testing.T) {
  287. matches := regexp.MustCompile(`'([^']+)'`).FindAllStringSubmatch(ollamaCloudUsagePlatformsSQL, -1)
  288. sqlPlatforms := make(map[string]struct{}, len(matches))
  289. for _, match := range matches {
  290. sqlPlatforms[match[1]] = struct{}{}
  291. }
  292. require.Len(t, sqlPlatforms, 6)
  293. for _, platform := range []string{
  294. service.PlatformOpenAI, service.PlatformAnthropic,
  295. service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformMiniMax,
  296. service.PlatformGemini, service.PlatformGrok, service.PlatformAntigravity,
  297. service.PlatformComposite, "kiro",
  298. } {
  299. account := ollamaCloudUsageRepositoryAccount()
  300. account.Platform = platform
  301. _, inSQL := sqlPlatforms[platform]
  302. require.Equal(t, inSQL, service.IsOllamaCloudUsageAccount(account), platform)
  303. }
  304. }
  305. // 语义等价性:平台放开后,普通(非 ollama)kimi apikey 账号改凭证会从通用
  306. // apikey 分支落到 Ollama 分支——多减三个它本来就不存在的 ollama 键。分支必须
  307. // 仍然清掉 probe 快照,且不得减其它任何 extra 键。
  308. func TestUpdateCredentialsPlainCNAPIKeyAccountCleanupStaysSemanticallyEquivalent(t *testing.T) {
  309. var capturedSQL string
  310. matcher := sqlmock.QueryMatcherFunc(func(expectedSQL, actualSQL string) error {
  311. if strings.Contains(actualSQL, "UPDATE accounts") {
  312. capturedSQL = actualSQL
  313. }
  314. return sqlmock.QueryMatcherRegexp.Match(expectedSQL, actualSQL)
  315. })
  316. db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(matcher))
  317. require.NoError(t, err)
  318. t.Cleanup(func() { _ = db.Close() })
  319. client := dbent.NewClient(dbent.Driver(entsql.OpenDB(dialect.Postgres, db)))
  320. t.Cleanup(func() { _ = client.Close() })
  321. mock.ExpectBegin()
  322. mock.ExpectExec(`(?s)UPDATE accounts.*- 'upstream_billing_probe'.*- 'ollama_cloud_usage_session'.*- 'ollama_cloud_usage_auto_refresh'.*- 'ollama_cloud_usage_snapshot'`).
  323. WithArgs(`{"api_key":"rotated-key","base_url":"https://api.moonshot.cn/v1"}`, int64(17)).
  324. WillReturnResult(sqlmock.NewResult(0, 1))
  325. mock.ExpectExec(regexp.QuoteMeta("INSERT INTO scheduler_outbox")).
  326. WithArgs(service.SchedulerOutboxEventAccountChanged, int64(17), nil, nil, sqlmock.AnyArg()).
  327. WillReturnResult(sqlmock.NewResult(1, 1))
  328. mock.ExpectCommit()
  329. repo := newAccountRepositoryWithSQL(client, db, nil)
  330. err = repo.UpdateCredentials(context.Background(), 17, map[string]any{
  331. "api_key": "rotated-key", "base_url": "https://api.moonshot.cn/v1",
  332. })
  333. require.NoError(t, err)
  334. query := normalizeSQLWhitespace(capturedSQL)
  335. require.Contains(t, query,
  336. "platform IN ('openai', 'anthropic', 'kimi', 'zhipu', 'deepseek', 'minimax') AND type = 'apikey' AND credentials IS DISTINCT FROM $1::jsonb")
  337. require.Contains(t, query,
  338. "THEN COALESCE(extra, '{}'::jsonb) - 'upstream_billing_probe' - 'ollama_cloud_usage_session' - 'ollama_cloud_usage_auto_refresh' - 'ollama_cloud_usage_snapshot'")
  339. require.NotContains(t, query, "- 'upstream_billing_probe_enabled'")
  340. require.NotContains(t, query, "- 'upstream_billing_rate_sync_enabled'")
  341. require.NoError(t, mock.ExpectationsWereMet())
  342. }