package api import ( "context" "crypto/sha256" "crypto/subtle" "encoding/base64" "encoding/json" "errors" "io" "net/http" "net/http/httptest" "os" "strings" "testing" "time" account "git.ipao.vip/rogee/creator-hub/internal/account" "git.ipao.vip/rogee/creator-hub/internal/creator" hub "git.ipao.vip/rogee/creator-hub/internal/environment" "github.com/gofiber/fiber/v3" ) func TestCreatorReadRoutesAgainstPostgres(t *testing.T) { databaseURL := os.Getenv("CREATORHUB_POSTGRES_TEST_URL") if databaseURL == "" { t.Skip("set CREATORHUB_POSTGRES_TEST_URL to run creator route coverage") } ctx := context.Background() databaseURL = isolatedControlPlaneDatabaseURL(t, databaseURL) phaseAStore, err := account.Open(ctx, databaseURL) if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = phaseAStore.Close() }) hubStore, err := hub.Open(ctx, databaseURL) if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = hubStore.Close() }) creatorStore, err := creator.Open(ctx, databaseURL) if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = creatorStore.Close() }) app := newHandlerWithCreator(t.TempDir(), "operator", "unit-test-password", phaseAStore, hubStore, nil, creatorStore) for _, path := range []string{ "/api/creator/settings", "/api/creator/accounts", "/api/creator/competitors", "/api/creator/rules", "/api/creator/rule-results", "/api/creator/leads", "/api/creator/works", "/api/creator/works?page=1&page_size=10", "/api/creator/comments", "/api/creator/comments?page=1&page_size=10", } { response := do(app, http.MethodGet, path, "", "operator", "unit-test-password") if response.Code != http.StatusOK { t.Fatalf("GET %s returned %d: %s", path, response.Code, response.Body.String()) } } } func TestCreatorRouteValidationCoverage(t *testing.T) { databaseURL := os.Getenv("CREATORHUB_POSTGRES_TEST_URL") if databaseURL == "" { t.Skip("set CREATORHUB_POSTGRES_TEST_URL to run creator route coverage") } ctx := context.Background() databaseURL = isolatedControlPlaneDatabaseURL(t, databaseURL) phaseAStore, err := account.Open(ctx, databaseURL) if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = phaseAStore.Close() }) hubStore, err := hub.Open(ctx, databaseURL) if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = hubStore.Close() }) creatorStore, err := creator.Open(ctx, databaseURL) if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = creatorStore.Close() }) credentials := &testCredentialBridge{values: make(map[string]string)} if err := phaseAStore.CreateAccount(ctx, account.Account{ ID: "route-account", Name: "Route Account", Platform: creator.PlatformDouyin, PlatformAccountKey: "route-platform", Tags: []string{}, Cookies: "", CredentialReference: account.CredentialReference{ID: "route-account-credential", Provider: "os_keyring"}, CredentialKey: "creatorhub/route-account/cookies", }, credentials); err != nil { t.Fatal(err) } app := newHandlerWithCreator(t.TempDir(), "operator", "unit-test-password", phaseAStore, hubStore, nil, creatorStore) for _, path := range []string{ "/api/creator/accounts/route-account/profile", "/api/creator/accounts/route-account/collection-status", "/api/creator/accounts/route-account/metrics", } { response := do(app, http.MethodGet, path, "", "operator", "unit-test-password") if response.Code != http.StatusOK { t.Fatalf("GET %s returned %d: %s", path, response.Code, response.Body.String()) } } for _, path := range []string{ "/api/creator/works/missing/metrics", "/api/creator/rule-results?comment_id=missing", } { response := do(app, http.MethodGet, path, "", "operator", "unit-test-password") if response.Code != http.StatusOK { t.Fatalf("GET %s returned %d: %s", path, response.Code, response.Body.String()) } } for _, path := range []string{ "/api/creator/competitors/missing", "/api/creator/works/missing", "/api/creator/comments/missing", "/api/creator/rules/missing", } { response := do(app, http.MethodGet, path, "", "operator", "unit-test-password") if response.Code != http.StatusNotFound { t.Fatalf("GET %s returned %d: %s", path, response.Code, response.Body.String()) } } for _, route := range []struct { method string path string }{ {http.MethodPut, "/api/creator/settings"}, {http.MethodPut, "/api/creator/accounts/missing/profile"}, {http.MethodPost, "/api/creator/accounts/missing/login-result"}, {http.MethodPost, "/api/creator/accounts/missing/big-account"}, {http.MethodPost, "/api/creator/competitor-share-jobs"}, {http.MethodPost, "/api/creator/competitors/missing/pause"}, {http.MethodPost, "/api/creator/competitors/missing/resume"}, {http.MethodPost, "/api/creator/competitors/missing/sync"}, {http.MethodPost, "/api/creator/accounts/missing/sync"}, {http.MethodPost, "/api/creator/works/missing/metrics"}, {http.MethodPost, "/api/creator/rules"}, {http.MethodPut, "/api/creator/rules/missing"}, {http.MethodPost, "/api/creator/rules/missing/enable"}, {http.MethodPost, "/api/creator/rules/missing/disable"}, {http.MethodPost, "/api/creator/comments/analyze"}, {http.MethodPost, "/api/creator/comments/missing/analyze"}, {http.MethodPost, "/api/creator/events/missing/display"}, {http.MethodPost, "/api/creator/operations"}, {http.MethodPost, "/api/creator/operations/missing/execute"}, {http.MethodPost, "/api/creator/conversations/missing/sync"}, {http.MethodPost, "/api/creator/messages/send"}, } { response := do(app, route.method, route.path, "{", "operator", "unit-test-password") if response.Code != http.StatusBadRequest && response.Code != http.StatusNotFound && response.Code != http.StatusNoContent && response.Code != http.StatusServiceUnavailable { t.Fatalf("%s %s returned %d for invalid JSON: %s", route.method, route.path, response.Code, response.Body.String()) } } for _, path := range []string{ "/api/creator/works", "/api/creator/comments", "/api/creator/events", "/api/creator/events/process", "/api/creator/messages", } { response := do(app, http.MethodPost, path, `{}`, "operator", "unit-test-password") if response.Code != http.StatusConflict { t.Fatalf("POST %s accepted public platform input: %d", path, response.Code) } } } func TestCreatorFixtureRoutesPostgres(t *testing.T) { databaseURL := os.Getenv("CREATORHUB_POSTGRES_TEST_URL") if databaseURL == "" { t.Skip("set CREATORHUB_POSTGRES_TEST_URL to run creator fixture coverage") } ctx := context.Background() databaseURL = isolatedControlPlaneDatabaseURL(t, databaseURL) phaseAStore, err := account.Open(ctx, databaseURL) if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = phaseAStore.Close() }) hubStore, err := hub.Open(ctx, databaseURL) if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = hubStore.Close() }) creatorStore, err := creator.Open(ctx, databaseURL) if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = creatorStore.Close() }) credentials := &testCredentialBridge{values: make(map[string]string)} if err := phaseAStore.CreateAccount(ctx, account.Account{ ID: "fixture-account", Name: "Fixture Account", Platform: creator.PlatformDouyin, PlatformAccountKey: "fixture-platform", Tags: []string{}, Cookies: "", CredentialReference: account.CredentialReference{ID: "fixture-credential", Provider: "os_keyring"}, CredentialKey: "creatorhub/fixture-account/cookies", }, credentials); err != nil { t.Fatal(err) } if err := phaseAStore.CreateAccount(ctx, account.Account{ ID: "fixture-small", Name: "Fixture Small", Platform: creator.PlatformDouyin, PlatformAccountKey: "fixture-small-platform", Tags: []string{}, Cookies: "", CredentialReference: account.CredentialReference{ID: "fixture-small-credential", Provider: "os_keyring"}, CredentialKey: "creatorhub/fixture-small/cookies", }, credentials); err != nil { t.Fatal(err) } if err := creatorStore.EnsureAccountProfile(ctx, "fixture-small"); err != nil { t.Fatal(err) } app := newHandlerWithCreator(t.TempDir(), "operator", "unit-test-password", phaseAStore, hubStore, nil, creatorStore) idFrom := func(response *httptest.ResponseRecorder) string { var value struct { ID string `json:"id"` } if err := json.Unmarshal(response.Body.Bytes(), &value); err != nil || value.ID == "" { t.Fatalf("response has no ID: %s (%v)", response.Body.String(), err) } return value.ID } workBody := `{"platform":"douyin","work_key":"fixture-work","source_type":"owned","source_id":"fixture-account","author_name":"author","title":"title","body":"body","published_at":"2024-01-01T00:00:00Z","published_at_status":"verified"}` workResponse := do(app, http.MethodPost, "/api/creator/test/works", workBody, "operator", "unit-test-password") if workResponse.Code != http.StatusCreated { t.Fatalf("create fixture work: %d %s", workResponse.Code, workResponse.Body.String()) } workID := idFrom(workResponse) if response := do(app, http.MethodPost, "/api/creator/test/works", workBody, "operator", "unit-test-password"); response.Code != http.StatusOK { t.Fatalf("deduplicate fixture work: %d %s", response.Code, response.Body.String()) } for _, path := range []string{"/api/creator/works/" + workID, "/api/creator/works/" + workID + "/metrics"} { if response := do(app, http.MethodGet, path, "", "operator", "unit-test-password"); response.Code != http.StatusOK { t.Fatalf("GET %s: %d %s", path, response.Code, response.Body.String()) } } metricBody := `{"likes":2,"comments_count":1,"shares":1}` if response := do(app, http.MethodPost, "/api/creator/works/"+workID+"/metrics", metricBody, "operator", "unit-test-password"); response.Code != http.StatusOK { t.Fatalf("record fixture metric: %d %s", response.Code, response.Body.String()) } commentBody := `{"platform":"douyin","comment_key":"fixture-comment","work_id":"` + workID + `","author_uid":"peer","author_name":"Peer","content":"hello","comment_type":"top_level","published_at":"2024-01-01T00:00:00Z"}` commentResponse := do(app, http.MethodPost, "/api/creator/test/comments", commentBody, "operator", "unit-test-password") if commentResponse.Code != http.StatusCreated { t.Fatalf("create fixture comment: %d %s", commentResponse.Code, commentResponse.Body.String()) } commentID := idFrom(commentResponse) if response := do(app, http.MethodPost, "/api/creator/test/comments", commentBody, "operator", "unit-test-password"); response.Code != http.StatusOK { t.Fatalf("deduplicate fixture comment: %d %s", response.Code, response.Body.String()) } if response := do(app, http.MethodGet, "/api/creator/comments/"+commentID, "", "operator", "unit-test-password"); response.Code != http.StatusOK { t.Fatalf("GET fixture comment: %d %s", response.Code, response.Body.String()) } profileBody := `{"real_name_status":"unknown","business_status":"normal","cooldown_seconds":60}` if response := do(app, http.MethodPut, "/api/creator/accounts/fixture-account/profile", profileBody, "operator", "unit-test-password"); response.Code != http.StatusOK { t.Fatalf("update fixture profile: %d %s", response.Code, response.Body.String()) } if response := do(app, http.MethodPost, "/api/creator/accounts/fixture-account/big-account", `{"enabled":true}`, "operator", "unit-test-password"); response.Code != http.StatusOK { t.Fatalf("enable fixture big account: %d %s", response.Code, response.Body.String()) } for _, accountID := range []string{"fixture-account", "fixture-small"} { if _, err := creatorStore.UpdateAccountProfile(ctx, accountID, creator.AccountProfileUpdate{RealNameStatus: "unknown", BusinessStatus: "normal", BigAccount: accountID == "fixture-account", CooldownSeconds: 60}); err != nil { t.Fatal(err) } } if _, err := creatorStore.RecordVerifiedLoginResult(ctx, "fixture-account", "fixture-platform"); err != nil { t.Fatal(err) } if _, err := creatorStore.RecordVerifiedLoginResult(ctx, "fixture-small", "fixture-small-platform"); err != nil { t.Fatal(err) } ruleBody := `{"name":"fixture-rule","enabled":true,"source_type":"owned","topic":"title","include_keywords":["hello"],"ai_requirement":"lead"}` ruleResponse := do(app, http.MethodPost, "/api/creator/rules", ruleBody, "operator", "unit-test-password") if ruleResponse.Code != http.StatusCreated { t.Fatalf("create fixture rule: %d %s", ruleResponse.Code, ruleResponse.Body.String()) } ruleID := idFrom(ruleResponse) if response := do(app, http.MethodGet, "/api/creator/rules/"+ruleID, "", "operator", "unit-test-password"); response.Code != http.StatusOK { t.Fatalf("GET fixture rule: %d %s", response.Code, response.Body.String()) } if response := do(app, http.MethodPost, "/api/creator/comments/"+commentID+"/analyze", `{"rule_id":"`+ruleID+`"}`, "operator", "unit-test-password"); response.Code != http.StatusServiceUnavailable { t.Fatalf("analyze fixture comment without AI: %d %s", response.Code, response.Body.String()) } competitor, err := creatorStore.CreateCompetitor(ctx, creator.CompetitorInput{Platform: creator.PlatformDouyin, PlatformAccountKey: "competitor-key", Nickname: "Competitor", HomepageURL: "https://www.douyin.com/user/competitor-key"}) if err != nil { t.Fatal(err) } competitorID := competitor.ID if response := do(app, http.MethodGet, "/api/creator/competitors/"+competitorID, "", "operator", "unit-test-password"); response.Code != http.StatusOK { t.Fatalf("GET fixture competitor: %d %s", response.Code, response.Body.String()) } for _, action := range []string{"pause", "resume", "sync"} { if response := do(app, http.MethodPost, "/api/creator/competitors/"+competitorID+"/"+action, `{}`, "operator", "unit-test-password"); response.Code != http.StatusOK && response.Code != http.StatusServiceUnavailable { t.Fatalf("competitor %s: %d %s", action, response.Code, response.Body.String()) } } if _, err := syncCreatorCompetitorWithClaim(ctx, creatorStore, hubStore, competitorID, true); err == nil { t.Fatal("competitor sync without anonymous browser unexpectedly succeeded") } if _, err := previewDouyinCompetitor(ctx, creatorStore, phaseAStore, hubStore, "fixture-account", creator.CompetitorInput{Platform: creator.PlatformDouyin, PlatformAccountKey: "preview-key", Nickname: "Preview", HomepageURL: "https://www.douyin.com/user/preview-key"}); err == nil { t.Fatal("competitor preview without browser unexpectedly succeeded") } // 账号即环境:给 fixture-account 绑定网关环境(fixture-small 保持未绑定),验证 monitor-views 内联 environment。 if _, err := hubStore.CreateGateway(ctx, "fixture-gateway", "http://127.0.0.1:1", ""); err != nil { t.Fatal(err) } if _, _, err := hubStore.CreateBoundEnv(ctx, hub.Env{Alias: "fixture-account", Name: "fixture-account-env", Gateway: "fixture-gateway"}, "fixture-account", ""); err != nil { t.Fatal(err) } // 自有账号监控:列表带作品聚合统计(fixture 已有 1 个 owned 作品)+ 内联运行环境视图。 viewResponse := do(app, http.MethodGet, "/api/creator/accounts/monitor-views", "", "operator", "unit-test-password") if viewResponse.Code != http.StatusOK { t.Fatalf("GET monitor views: %d %s", viewResponse.Code, viewResponse.Body.String()) } var monitorViews []struct { ID string `json:"id"` WorkCount int64 `json:"work_count"` LatestPublishedAt *string `json:"latest_published_at"` Environment *struct { Gateway string `json:"gateway"` Name string `json:"name"` RuntimeID string `json:"runtime_id"` NetworkExitID string `json:"network_exit_id"` } `json:"environment"` } if err := json.Unmarshal(viewResponse.Body.Bytes(), &monitorViews); err != nil { t.Fatal(err) } viewByAccount := map[string]bool{} for _, view := range monitorViews { viewByAccount[view.ID] = true if view.ID == "fixture-account" { if view.WorkCount < 1 || view.LatestPublishedAt == nil { t.Fatalf("monitor view aggregates missing for fixture-account: %+v", view) } if view.Environment == nil || view.Environment.Gateway != "fixture-gateway" || view.Environment.Name != "fixture-account-env" { t.Fatalf("monitor view environment missing for fixture-account: %+v", view) } } if view.ID == "fixture-small" && view.Environment != nil { t.Fatalf("unbound account should not inline environment: %+v", view) } } if !viewByAccount["fixture-account"] { t.Fatal("monitor views missing fixture-account") } // 采集状态:无 checkpoint 时 pending;作品采集完成后 succeeded 且带窗口。 statusResponse := do(app, http.MethodGet, "/api/creator/accounts/fixture-small/collection-status", "", "operator", "unit-test-password") if statusResponse.Code != http.StatusOK { t.Fatalf("GET collection status: %d %s", statusResponse.Code, statusResponse.Body.String()) } var collectionStatus struct { Works struct { Status string `json:"status"` } `json:"works"` } if err := json.Unmarshal(statusResponse.Body.Bytes(), &collectionStatus); err != nil { t.Fatal(err) } if collectionStatus.Works.Status != "pending" { t.Fatalf("expected pending checkpoint for untouched account: %s", statusResponse.Body.String()) } statusResponse = do(app, http.MethodGet, "/api/creator/accounts/fixture-account/collection-status", "", "operator", "unit-test-password") if statusResponse.Code != http.StatusOK { t.Fatalf("GET collection status: %d %s", statusResponse.Code, statusResponse.Body.String()) } var fixtureStatus struct { Works struct { Status string `json:"status"` NextWindowStart *string `json:"next_window_start"` NextWindowEnd *string `json:"next_window_end"` } `json:"works"` } if err := json.Unmarshal(statusResponse.Body.Bytes(), &fixtureStatus); err != nil { t.Fatal(err) } // fixture 流程没有跑过调度器 checkpoint,状态可能仍为 pending;两种都合法。 if fixtureStatus.Works.Status != "pending" && fixtureStatus.Works.Status != "succeeded" { t.Fatalf("unexpected works checkpoint status: %s", statusResponse.Body.String()) } // 账号画像时序:初始为空数组。 metricsResponse := do(app, http.MethodGet, "/api/creator/accounts/fixture-account/metrics", "", "operator", "unit-test-password") if metricsResponse.Code != http.StatusOK || strings.TrimSpace(metricsResponse.Body.String()) != "[]" { t.Fatalf("GET account metrics: %d %s", metricsResponse.Code, metricsResponse.Body.String()) } // 未登录账号强制同步:账号存在但未通过登录/环境校验,必须 409/503 且不吞错。 smallSync := do(app, http.MethodPost, "/api/creator/accounts/fixture-small/sync", ``, "operator", "unit-test-password") if smallSync.Code != http.StatusConflict && smallSync.Code != http.StatusServiceUnavailable { t.Fatalf("sync not-logged-in account must be blocked: %d %s", smallSync.Code, smallSync.Body.String()) } if _, err := creatorStore.RecordVerifiedLoginResult(ctx, "fixture-small", "fixture-small-platform"); err != nil { t.Fatal(err) } loggedSync := do(app, http.MethodPost, "/api/creator/accounts/fixture-small/sync", ``, "operator", "unit-test-password") if loggedSync.Code != http.StatusConflict && loggedSync.Code != http.StatusServiceUnavailable { t.Fatalf("sync without runtime environment must be blocked: %d %s", loggedSync.Code, loggedSync.Body.String()) } missingSync := do(app, http.MethodPost, "/api/creator/accounts/fixture-missing/sync", ``, "operator", "unit-test-password") if missingSync.Code != http.StatusNotFound && missingSync.Code != http.StatusConflict && missingSync.Code != http.StatusServiceUnavailable { t.Fatalf("sync missing account: %d %s", missingSync.Code, missingSync.Body.String()) } if err := runCreatorScheduleOnce(ctx, creatorStore, phaseAStore, hubStore); err != nil && !errors.Is(err, creator.ErrUnavailable) { t.Fatalf("creator schedule fixture: %v", err) } if err := runCreatorMetricScheduleOnce(ctx, creatorStore, phaseAStore, hubStore, time.Now().UTC()); err != nil && !errors.Is(err, creator.ErrUnavailable) { t.Fatalf("creator metric schedule fixture: %v", err) } storedWork, err := creatorStore.GetWork(ctx, workID) if err != nil { t.Fatal(err) } settings, err := creatorStore.GetSettings(ctx) if err != nil { t.Fatal(err) } if err := refreshCreatorMetricWork(ctx, creatorStore, phaseAStore, hubStore, storedWork, "fixture-account", settings, time.Now().UTC()); err == nil { t.Fatal("metric refresh without browser unexpectedly succeeded") } if _, err := verifyCreatorAccount(ctx, creatorStore, phaseAStore, hubStore, "fixture-account"); err == nil { t.Fatal("account verification without browser unexpectedly succeeded") } } func TestPhaseAAccountRequestRejectsUnknownFields(t *testing.T) { for name, body := range map[string]string{ "client id": `{"id":"account-a","name":"账号 A","platform":"douyin","platform_account_key":"a","tags":[],"cookies":"sid=value"}`, "authorization kind": `{"name":"账号 A","platform":"douyin","platform_account_key":"a","tags":[],"cookies":"sid=value","authorization_kind":"owned"}`, "credential reference": `{"name":"账号 A","platform":"douyin","platform_account_key":"a","tags":[],"cookies":"sid=value","credential_reference":{}}`, } { t.Run(name, func(t *testing.T) { app := fiber.New() app.Post("/", func(c fiber.Ctx) error { var input accountRequest if err := decodePhaseA(c, &input); err != nil { return phaseAError(c, err) } return c.SendStatus(http.StatusNoContent) }) response, err := app.Test(httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body))) if err != nil { t.Fatal(err) } defer response.Body.Close() if response.StatusCode != http.StatusBadRequest { t.Fatalf("expected sensitive or unknown field to be rejected, got %d", response.StatusCode) } }) } } func TestPhaseAErrorRedactsInternalDetails(t *testing.T) { app := fiber.New() app.Get("/", func(c fiber.Ctx) error { return phaseAError(c, errors.New("database exposed a secret value")) }) response, err := app.Test(httptest.NewRequest(http.MethodGet, "/", nil)) if err != nil { t.Fatal(err) } defer response.Body.Close() body, _ := io.ReadAll(response.Body) if response.StatusCode != http.StatusInternalServerError || strings.Contains(string(body), "secret") || !strings.Contains(string(body), "phase A operation failed") { t.Fatalf("internal error was not redacted: status=%d body=%s", response.StatusCode, body) } } func newHandlerWithStores(_ string, _ string, _ string, accountStore *account.Store, environmentStore *hub.Store) *fiber.App { appInstance := fiber.New() if environmentStore != nil { RegisterEnvironments(appInstance, environmentStore) } return appInstance } func newHandlerWithCreator(_ string, _ string, _ string, accountStore *account.Store, environmentStore *hub.Store, _ account.CredentialBridge, creatorStore *creator.Store) *fiber.App { appInstance := newHandlerWithStores("", "", "", accountStore, environmentStore) if creatorStore != nil { RegisterCreator(appInstance, creatorStore, accountStore, environmentStore) } return appInstance } func authenticate(username, password string) fiber.Handler { wantUser, wantPassword := sha256.Sum256([]byte(username)), sha256.Sum256([]byte(password)) return func(c fiber.Ctx) error { encoded, ok := strings.CutPrefix(c.Get(fiber.HeaderAuthorization), "Basic ") decoded, err := base64.StdEncoding.DecodeString(encoded) user, suppliedPassword, found := strings.Cut(string(decoded), ":") gotUser, gotPassword := sha256.Sum256([]byte(user)), sha256.Sum256([]byte(suppliedPassword)) if !ok || err != nil || !found || subtle.ConstantTimeCompare(gotUser[:], wantUser[:]) != 1 || subtle.ConstantTimeCompare(gotPassword[:], wantPassword[:]) != 1 { return c.Status(fiber.StatusUnauthorized).JSON(map[string]string{"error": "authentication required"}) } return c.Next() } } func TestCreatorFingerprintRouteAgainstPostgres(t *testing.T) { databaseURL := os.Getenv("CREATORHUB_POSTGRES_TEST_URL") if databaseURL == "" { t.Skip("set CREATORHUB_POSTGRES_TEST_URL to run creator route coverage") } ctx := context.Background() databaseURL = isolatedControlPlaneDatabaseURL(t, databaseURL) phaseAStore, err := account.Open(ctx, databaseURL) if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = phaseAStore.Close() }) hubStore, err := hub.Open(ctx, databaseURL) if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = hubStore.Close() }) creatorStore, err := creator.Open(ctx, databaseURL) if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = creatorStore.Close() }) credentials := &testCredentialBridge{values: make(map[string]string)} if err := phaseAStore.CreateAccount(ctx, account.Account{ ID: "fp-account", Name: "Fingerprint Account", Platform: creator.PlatformDouyin, PlatformAccountKey: "fp-platform", Tags: []string{}, Cookies: "", CredentialReference: account.CredentialReference{ID: "fp-account-credential", Provider: "os_keyring"}, CredentialKey: "creatorhub/fp-account/cookies", }, credentials); err != nil { t.Fatal(err) } // 账号即环境:网关 + 绑定环境(与生产创建链路同构,账号默认 paused 满足就绪门禁)。 if _, err := hubStore.CreateGateway(ctx, "gw-fp", "http://127.0.0.1:8081", "unit-test-gateway-token"); err != nil { t.Fatal(err) } if _, created, err := hubStore.CreateBoundEnv(ctx, hub.Env{Alias: "fp-account", Name: "fp-account", Gateway: "gw-fp"}, "fp-account", ""); err != nil || !created { t.Fatalf("bind account environment: created=%v err=%v", created, err) } app := newHandlerWithCreator(t.TempDir(), "operator", "unit-test-password", phaseAStore, hubStore, nil, creatorStore) // 指纹保存:合法入参 → 200,且响应内联回显更新后的指纹。 body := `{"platform":"windows","timezone":"Asia/Shanghai","lang":"zh-CN","brand":"Edge","hardware_concurrency":8,"disable_spoofing":"font,gpu"}` response := do(app, http.MethodPut, "/api/creator/accounts/fp-account/fingerprint", body, "operator", "unit-test-password") if response.Code != http.StatusOK { t.Fatalf("update fingerprint: %d %s", response.Code, response.Body.String()) } for _, fragment := range []string{`"fingerprint"`, `"platform":"windows"`, `"timezone":"Asia/Shanghai"`, `"brand":"Edge"`, `"disable_spoofing":"font,gpu"`} { if !strings.Contains(response.Body.String(), fragment) { t.Fatalf("fingerprint view must echo updated values (%s): %s", fragment, response.Body.String()) } } // 持久化:重新从 store 读取,指纹保持;seed 保持账号派生值不被表单覆盖。 environment, err := hubStore.GetEnvironmentContextForAccount(ctx, "fp-account") if err != nil { t.Fatal(err) } if environment.Fingerprint.Platform != "windows" || environment.Fingerprint.Timezone != "Asia/Shanghai" || environment.Fingerprint.Brand != "Edge" || environment.Fingerprint.Seed < 1 { t.Fatalf("fingerprint not persisted: %+v", environment.Fingerprint) } // 非法入参(平台值越界)→ 400,且不污染已保存指纹。 badResponse := do(app, http.MethodPut, "/api/creator/accounts/fp-account/fingerprint", `{"platform":"android"}`, "operator", "unit-test-password") if badResponse.Code != http.StatusBadRequest { t.Fatalf("invalid fingerprint must be rejected: %d %s", badResponse.Code, badResponse.Body.String()) } environment, err = hubStore.GetEnvironmentContextForAccount(ctx, "fp-account") if err != nil || environment.Fingerprint.Platform != "windows" { t.Fatalf("failed update must not persist: %+v err=%v", environment.Fingerprint, err) } // 未知账号 → 404。 missingResponse := do(app, http.MethodPut, "/api/creator/accounts/missing/fingerprint", `{}`, "operator", "unit-test-password") if missingResponse.Code != http.StatusNotFound { t.Fatalf("unknown account must 404: %d %s", missingResponse.Code, missingResponse.Body.String()) } }