1072 lines
50 KiB
Go
1072 lines
50 KiB
Go
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"syscall"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.ipao.vip/rogee/creator-hub/internal/creator"
|
|
"git.ipao.vip/rogee/creator-hub/internal/hub"
|
|
"git.ipao.vip/rogee/creator-hub/internal/phasea"
|
|
"git.ipao.vip/rogee/creator-hub/internal/taskstate"
|
|
"github.com/gofiber/fiber/v3"
|
|
"github.com/sirupsen/logrus"
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
func TestExecuteContextStopsOnSIGTERM(t *testing.T) {
|
|
if os.Getenv("CREATORHUB_SIGTERM_HELPER") == "1" {
|
|
command := &cobra.Command{Use: "shutdown-test", RunE: func(command *cobra.Command, _ []string) error {
|
|
_, _ = os.Stdout.WriteString("ready\n")
|
|
<-command.Context().Done()
|
|
_, _ = os.Stdout.WriteString("stopped\n")
|
|
return nil
|
|
}}
|
|
if err := execute(command); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
|
defer cancel()
|
|
process := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestExecuteContextStopsOnSIGTERM$")
|
|
process.Env = append(os.Environ(), "CREATORHUB_SIGTERM_HELPER=1")
|
|
stdout, err := process.StdoutPipe()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := process.Start(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
scanner := bufio.NewScanner(stdout)
|
|
if !scanner.Scan() || scanner.Text() != "ready" {
|
|
t.Fatalf("helper did not become ready: %q err=%v", scanner.Text(), scanner.Err())
|
|
}
|
|
if err := process.Process.Signal(syscall.SIGTERM); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !scanner.Scan() || scanner.Text() != "stopped" {
|
|
t.Fatalf("helper did not finish cleanup: %q err=%v", scanner.Text(), scanner.Err())
|
|
}
|
|
if err := process.Wait(); err != nil || ctx.Err() != nil {
|
|
t.Fatalf("process did not exit gracefully after SIGTERM: wait=%v context=%v", err, ctx.Err())
|
|
}
|
|
}
|
|
|
|
func TestLoadConfigRejectsInvalidDatabase(t *testing.T) {
|
|
t.Setenv("CONTROL_PLANE_USERNAME", "operator")
|
|
t.Setenv("CONTROL_PLANE_PASSWORD", "unit-test-password")
|
|
t.Setenv("DATABASE_URL", "file:///tmp/creatorhub.db")
|
|
if _, err := loadConfig(); err == nil {
|
|
t.Fatal("expected non-Postgres database URL to be rejected")
|
|
}
|
|
}
|
|
|
|
func TestLoadConfigRequiresControlPlaneCredentials(t *testing.T) {
|
|
t.Setenv("CONTROL_PLANE_USERNAME", "")
|
|
t.Setenv("CONTROL_PLANE_PASSWORD", "")
|
|
if _, err := loadConfig(); err == nil {
|
|
t.Fatal("expected missing control-plane credentials to be rejected")
|
|
}
|
|
}
|
|
|
|
func TestLoadConfigPasswordLength(t *testing.T) {
|
|
t.Setenv("CONTROL_PLANE_USERNAME", "operator")
|
|
t.Setenv("CREATORHUB_CREDENTIAL_MASTER_KEY", "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")
|
|
for _, test := range []struct {
|
|
password string
|
|
wantErr bool
|
|
}{
|
|
{"12345", true},
|
|
{"123456", false},
|
|
} {
|
|
t.Run(test.password, func(t *testing.T) {
|
|
t.Setenv("CONTROL_PLANE_PASSWORD", test.password)
|
|
_, err := loadConfig()
|
|
if (err != nil) != test.wantErr {
|
|
t.Fatalf("loadConfig() error = %v, wantErr %v", err, test.wantErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestLoadConfigRequiresCredentialMasterKey(t *testing.T) {
|
|
t.Setenv("CONTROL_PLANE_USERNAME", "operator")
|
|
t.Setenv("CONTROL_PLANE_PASSWORD", "unit-test-password")
|
|
for _, key := range []string{"", "not-base64", "c2hvcnQ="} {
|
|
t.Setenv("CREATORHUB_CREDENTIAL_MASTER_KEY", key)
|
|
if _, err := loadConfig(); err == nil {
|
|
t.Fatalf("accepted invalid credential master key %q", key)
|
|
}
|
|
}
|
|
}
|
|
|
|
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 := phasea.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/relations",
|
|
"/api/creator/accounts/route-account/strategies", "/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",
|
|
"/api/creator/listener-boundaries", "/api/creator/events", "/api/creator/events?page=1&page_size=10",
|
|
"/api/creator/listeners", "/api/creator/operations", "/api/creator/conversations",
|
|
} {
|
|
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 := phasea.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, phasea.Account{
|
|
ID: "route-account", Name: "Route Account", Platform: creator.PlatformDouyin,
|
|
PlatformAccountKey: "route-platform", Tags: []string{}, Cookies: "",
|
|
CredentialReference: phasea.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/strategies",
|
|
} {
|
|
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",
|
|
"/api/creator/events/missing/strategy-trace", "/api/creator/conversations/missing/messages",
|
|
} {
|
|
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",
|
|
"/api/creator/operations/missing", "/api/creator/operations/missing/verification",
|
|
} {
|
|
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())
|
|
}
|
|
}
|
|
response := do(app, http.MethodGet, "/api/creator/works/missing/material", "", "operator", "unit-test-password")
|
|
if response.Code != http.StatusBadRequest {
|
|
t.Fatalf("GET material for missing work returned %d: %s", 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/relations"},
|
|
{http.MethodPost, "/api/creator/accounts/missing/strategies"},
|
|
{http.MethodPost, "/api/creator/strategies/missing/enable"},
|
|
{http.MethodPost, "/api/creator/strategies/missing/disable"},
|
|
{http.MethodDelete, "/api/creator/strategies/missing"},
|
|
{http.MethodPut, "/api/creator/strategies/missing"},
|
|
{http.MethodPost, "/api/creator/competitors/preview"},
|
|
{http.MethodPost, "/api/creator/competitors"},
|
|
{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/xiaohongshu/search"},
|
|
{http.MethodPost, "/api/creator/xiaohongshu/detail"},
|
|
{http.MethodPost, "/api/creator/works/missing/metrics"},
|
|
{http.MethodPost, "/api/creator/works/missing/material/select"},
|
|
{http.MethodPost, "/api/creator/works/missing/material/process"},
|
|
{http.MethodPost, "/api/creator/works/missing/material/rewrite/confirm"},
|
|
{http.MethodPost, "/api/creator/works/missing/material/rewrite/generate"},
|
|
{http.MethodPut, "/api/creator/works/missing/material/rewrite"},
|
|
{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 := phasea.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, phasea.Account{
|
|
ID: "fixture-account", Name: "Fixture Account", Platform: creator.PlatformDouyin,
|
|
PlatformAccountKey: "fixture-platform", Tags: []string{}, Cookies: "",
|
|
CredentialReference: phasea.CredentialReference{ID: "fixture-credential", Provider: "os_keyring"},
|
|
CredentialKey: "creatorhub/fixture-account/cookies",
|
|
}, credentials); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := phaseAStore.CreateAccount(ctx, phasea.Account{
|
|
ID: "fixture-small", Name: "Fixture Small", Platform: creator.PlatformDouyin,
|
|
PlatformAccountKey: "fixture-small-platform", Tags: []string{}, Cookies: "",
|
|
CredentialReference: phasea.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", "/api/creator/works/" + workID + "/material"} {
|
|
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())
|
|
}
|
|
materialResponse := do(app, http.MethodPost, "/api/creator/works/"+workID+"/material/select", `{}`, "operator", "unit-test-password")
|
|
if materialResponse.Code != http.StatusCreated && materialResponse.Code != http.StatusOK {
|
|
t.Fatalf("select fixture material: %d %s", materialResponse.Code, materialResponse.Body.String())
|
|
}
|
|
if _, claimed, err := creatorStore.ClaimMaterialStep(ctx, workID, "download", "fixture-material-token"); err != nil || !claimed {
|
|
t.Fatalf("claim fixture material download: claimed=%v err=%v", claimed, err)
|
|
}
|
|
if _, err := setMaterialFailure(ctx, creatorStore, workID, "download", "fixture-material-token", errors.New("fixture download failed")); err != nil {
|
|
t.Fatalf("record fixture material failure: %v", err)
|
|
}
|
|
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)
|
|
}
|
|
if err := creatorStore.SetRelation(ctx, "fixture-account", "fixture-small", true); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
strategyBody := `{"execution_account_id":"fixture-small","position":1,"enabled":true,"event_types":["comment"],"action":"reply_comment","target_type":"comment","candidate_texts":["已收到"]}`
|
|
strategyResponse := do(app, http.MethodPost, "/api/creator/accounts/fixture-account/strategies", strategyBody, "operator", "unit-test-password")
|
|
if strategyResponse.Code != http.StatusCreated {
|
|
t.Fatalf("create fixture strategy: %d %s", strategyResponse.Code, strategyResponse.Body.String())
|
|
}
|
|
strategyID := idFrom(strategyResponse)
|
|
for _, route := range []struct {
|
|
method string
|
|
path string
|
|
body string
|
|
}{
|
|
{http.MethodPut, "/api/creator/strategies/" + strategyID, strategyBody},
|
|
{http.MethodPost, "/api/creator/strategies/" + strategyID + "/enable", `{}`},
|
|
{http.MethodPost, "/api/creator/strategies/" + strategyID + "/disable", `{}`},
|
|
} {
|
|
if response := do(app, route.method, route.path, route.body, "operator", "unit-test-password"); response.Code != http.StatusOK {
|
|
t.Fatalf("%s %s: %d %s", route.method, route.path, response.Code, response.Body.String())
|
|
}
|
|
}
|
|
if response := do(app, http.MethodDelete, "/api/creator/strategies/"+strategyID, "", "operator", "unit-test-password"); response.Code != http.StatusNoContent {
|
|
t.Fatalf("delete fixture strategy: %d %s", response.Code, response.Body.String())
|
|
}
|
|
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, phaseAStore, hubStore, competitorID, "fixture-account", true); err == nil {
|
|
t.Fatal("competitor sync without 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")
|
|
}
|
|
eventBody := `{"platform":"douyin","receiving_account_id":"fixture-account","event_key":"fixture-event","event_type":"comment","interactor_uid":"peer","work_id":"` + workID + `","comment_id":"` + commentID + `"}`
|
|
eventResponse := do(app, http.MethodPost, "/api/creator/test/events", eventBody, "operator", "unit-test-password")
|
|
if eventResponse.Code != http.StatusCreated {
|
|
t.Fatalf("create fixture event: %d %s", eventResponse.Code, eventResponse.Body.String())
|
|
}
|
|
var eventEnvelope struct {
|
|
Event struct {
|
|
ID string `json:"id"`
|
|
} `json:"event"`
|
|
}
|
|
if err := json.Unmarshal(eventResponse.Body.Bytes(), &eventEnvelope); err != nil || eventEnvelope.Event.ID == "" {
|
|
t.Fatalf("event has no ID: %s (%v)", eventResponse.Body.String(), err)
|
|
}
|
|
eventID := eventEnvelope.Event.ID
|
|
if response := do(app, http.MethodPost, "/api/creator/events/"+eventID+"/display", `{}`, "operator", "unit-test-password"); response.Code != http.StatusOK {
|
|
t.Fatalf("display fixture event: %d %s", response.Code, response.Body.String())
|
|
}
|
|
operationBody := `{"idempotency_key":"fixture-operation","source":"manual","action":"reply_comment","platform":"douyin","account_id":"fixture-account","target_uid":"peer","target_comment_id":"` + commentID + `","target_work_id":"` + workID + `","text":"reply"}`
|
|
operationResponse := do(app, http.MethodPost, "/api/creator/operations", operationBody, "operator", "unit-test-password")
|
|
if operationResponse.Code != http.StatusCreated {
|
|
t.Fatalf("create fixture operation: %d %s", operationResponse.Code, operationResponse.Body.String())
|
|
}
|
|
operationID := idFrom(operationResponse)
|
|
for _, path := range []string{"/api/creator/operations/" + operationID, "/api/creator/operations/" + operationID + "/verification"} {
|
|
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())
|
|
}
|
|
}
|
|
if response := do(app, http.MethodPost, "/api/creator/operations/"+operationID+"/execute", `{}`, "operator", "unit-test-password"); response.Code != http.StatusOK {
|
|
t.Fatalf("execute fixture operation without executor: %d %s", response.Code, response.Body.String())
|
|
}
|
|
if response := do(app, http.MethodPost, "/api/creator/test/events/process", eventBody, "operator", "unit-test-password"); response.Code != http.StatusOK && response.Code != http.StatusServiceUnavailable {
|
|
t.Fatalf("process fixture event without executor: %d %s", response.Code, response.Body.String())
|
|
}
|
|
messageBody := `{"platform":"douyin","account_id":"fixture-account","peer_uid":"peer","peer_name":"Peer","platform_message_key":"fixture-message","direction":"inbound","message_type":"text","text":"hello"}`
|
|
messageResponse := do(app, http.MethodPost, "/api/creator/test/messages", messageBody, "operator", "unit-test-password")
|
|
if messageResponse.Code != http.StatusCreated {
|
|
t.Fatalf("create fixture message: %d %s", messageResponse.Code, messageResponse.Body.String())
|
|
}
|
|
var message struct {
|
|
ConversationID string `json:"conversation_id"`
|
|
}
|
|
if err := json.Unmarshal(messageResponse.Body.Bytes(), &message); err != nil || message.ConversationID == "" {
|
|
t.Fatalf("message has no conversation: %s (%v)", messageResponse.Body.String(), err)
|
|
}
|
|
for _, path := range []string{"/api/creator/conversations/" + message.ConversationID + "/messages", "/api/creator/conversations/" + message.ConversationID + "/messages?page=1&page_size=10"} {
|
|
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())
|
|
}
|
|
}
|
|
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 TestControlPlaneAuthentication(t *testing.T) {
|
|
logger := logrus.StandardLogger()
|
|
previousOutput := logger.Out
|
|
var logs bytes.Buffer
|
|
logrus.SetOutput(&logs)
|
|
t.Cleanup(func() { logrus.SetOutput(previousOutput) })
|
|
|
|
directory := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(directory, "index.html"), []byte("index"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
app := newHandler(directory, "operator", "unit-test-password")
|
|
|
|
health, err := app.Test(httptest.NewRequest(http.MethodGet, "/healthz", nil))
|
|
if err != nil || health.StatusCode != http.StatusNoContent {
|
|
t.Fatalf("health check must remain public: status=%d err=%v", health.StatusCode, err)
|
|
}
|
|
health.Body.Close()
|
|
|
|
ready, err := app.Test(httptest.NewRequest(http.MethodGet, "/readyz", nil))
|
|
if err != nil || ready.StatusCode != http.StatusServiceUnavailable {
|
|
t.Fatalf("readiness must fail without stores: status=%d err=%v", ready.StatusCode, err)
|
|
}
|
|
ready.Body.Close()
|
|
|
|
unknown := httptest.NewRequest(http.MethodGet, "/api/not-registered", nil)
|
|
unknown.SetBasicAuth("operator", "unit-test-password")
|
|
unknownResponse, err := app.Test(unknown)
|
|
if err != nil || unknownResponse.StatusCode != http.StatusNotFound {
|
|
t.Fatalf("unknown API must be JSON 404: status=%d err=%v", unknownResponse.StatusCode, err)
|
|
}
|
|
if got := unknownResponse.Header.Get("Content-Type"); !strings.HasPrefix(got, "application/json") {
|
|
t.Fatalf("unknown API content type=%q", got)
|
|
}
|
|
unknownResponse.Body.Close()
|
|
|
|
for _, path := range []string{
|
|
"/", "/api/phase-a/accounts", "/api/browsers", "/api/network-exits", "/api/phase-a/tasks", "/api/phase-a/audit",
|
|
} {
|
|
request := httptest.NewRequest(http.MethodGet, path, nil)
|
|
response, err := app.Test(request)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
response.Body.Close()
|
|
if response.StatusCode != http.StatusUnauthorized || response.Header.Get("WWW-Authenticate") == "" {
|
|
t.Fatalf("GET %s was not protected: status=%d", path, response.StatusCode)
|
|
}
|
|
}
|
|
|
|
for name, test := range map[string]struct {
|
|
user, password string
|
|
want int
|
|
}{
|
|
"valid": {"operator", "unit-test-password", http.StatusOK},
|
|
"wrong user": {"other", "unit-test-password", http.StatusUnauthorized},
|
|
"wrong password": {"operator", "credential-must-not-be-logged", http.StatusUnauthorized},
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
request := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
request.SetBasicAuth(test.user, test.password)
|
|
response, err := app.Test(request)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
response.Body.Close()
|
|
if response.StatusCode != test.want {
|
|
t.Fatalf("status=%d want=%d", response.StatusCode, test.want)
|
|
}
|
|
})
|
|
}
|
|
if strings.Contains(logs.String(), "credential-must-not-be-logged") {
|
|
t.Fatalf("authentication credential reached logs: %s", logs.String())
|
|
}
|
|
}
|
|
|
|
type controlPlaneRouteCase struct {
|
|
method, pattern, path, body string
|
|
wantAuthenticatedStatus int
|
|
}
|
|
|
|
type testCredentialBridge struct {
|
|
values map[string]string
|
|
}
|
|
|
|
func (bridge *testCredentialBridge) Store(ctx context.Context, _ phasea.CredentialReference, key, value string) error {
|
|
if err := ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
bridge.values[key] = value
|
|
return nil
|
|
}
|
|
|
|
func (bridge *testCredentialBridge) Delete(ctx context.Context, _ phasea.CredentialReference, key string) error {
|
|
if err := ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
delete(bridge.values, key)
|
|
return nil
|
|
}
|
|
|
|
func TestControlPlaneRouteRegistrationMatrix(t *testing.T) {
|
|
app := fiber.New()
|
|
app.Use(authenticate("operator", "unit-test-password"))
|
|
registerHubWithNetwork(app, nil, nil, nil)
|
|
registerPhaseA(app, nil, nil, nil)
|
|
routes := controlPlaneRouteMatrix()
|
|
assertControlPlaneRouteMatrix(t, app, routes)
|
|
|
|
for _, route := range routes {
|
|
if response := do(app, route.method, route.path, route.body); response.Code != http.StatusUnauthorized {
|
|
t.Fatalf("registered route %s %s returned %d without credentials, want %d", route.method, route.path,
|
|
response.Code, http.StatusUnauthorized)
|
|
}
|
|
}
|
|
if response := do(app, http.MethodPost, "/api/not-registered", ""); response.Code != http.StatusUnauthorized {
|
|
t.Fatalf("unregistered route returned %d without credentials, want %d", response.Code, http.StatusUnauthorized)
|
|
}
|
|
}
|
|
|
|
func TestControlPlaneRegisteredRouteMatrix(t *testing.T) {
|
|
databaseURL := os.Getenv("CREATORHUB_POSTGRES_TEST_URL")
|
|
if databaseURL == "" {
|
|
t.Skip("set CREATORHUB_POSTGRES_TEST_URL to run control-plane route coverage")
|
|
}
|
|
databaseURL = isolatedControlPlaneDatabaseURL(t, databaseURL)
|
|
ctx := context.Background()
|
|
phaseAStore, err := phasea.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() })
|
|
|
|
webDirectory := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(webDirectory, "index.html"), []byte("index"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
app := newHandlerWithStores(webDirectory, "operator", "unit-test-password", phaseAStore, hubStore)
|
|
routes := controlPlaneRouteMatrix()
|
|
assertControlPlaneRouteMatrix(t, app, routes)
|
|
|
|
for _, route := range routes {
|
|
route := route
|
|
t.Run(route.method+" "+route.pattern, func(t *testing.T) {
|
|
if response := do(app, route.method, route.path, route.body); response.Code != http.StatusUnauthorized {
|
|
t.Fatalf("unauthenticated %s %s returned %d, want %d", route.method, route.path, response.Code, http.StatusUnauthorized)
|
|
}
|
|
response := do(app, route.method, route.path, route.body, "operator", "unit-test-password")
|
|
if response.Code != route.wantAuthenticatedStatus {
|
|
t.Fatalf("authenticated %s %s returned %d, want %d: %s", route.method, route.path, response.Code,
|
|
route.wantAuthenticatedStatus, response.Body.String())
|
|
}
|
|
})
|
|
}
|
|
|
|
for _, route := range []struct {
|
|
method, path string
|
|
want int
|
|
}{
|
|
{http.MethodPost, "/api/not-registered", http.StatusNotFound},
|
|
{http.MethodDelete, "/api/not-registered", http.StatusNotFound},
|
|
} {
|
|
t.Run("unregistered "+route.method, func(t *testing.T) {
|
|
if response := do(app, route.method, route.path, ""); response.Code != http.StatusUnauthorized {
|
|
t.Fatalf("unauthenticated unregistered %s %s returned %d, want %d", route.method, route.path, response.Code, http.StatusUnauthorized)
|
|
}
|
|
if response := do(app, route.method, route.path, "", "operator", "unit-test-password"); response.Code != route.want {
|
|
t.Fatalf("authenticated unregistered %s %s returned %d, want %d", route.method, route.path, response.Code, route.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func assertControlPlaneRouteMatrix(t *testing.T, app *fiber.App, routes []controlPlaneRouteCase) {
|
|
t.Helper()
|
|
expected := make(map[string]struct{}, len(routes))
|
|
for _, route := range routes {
|
|
key := controlPlaneRouteKey(route.method, route.pattern)
|
|
if _, exists := expected[key]; exists {
|
|
t.Fatalf("duplicate route matrix entry: %s", key)
|
|
}
|
|
expected[key] = struct{}{}
|
|
}
|
|
actual := registeredControlPlaneRoutes(app)
|
|
for key := range expected {
|
|
if _, exists := actual[key]; !exists {
|
|
t.Errorf("route matrix is missing registered route: %s", key)
|
|
}
|
|
}
|
|
for key := range actual {
|
|
if _, exists := expected[key]; !exists {
|
|
t.Errorf("registered API route is missing from route matrix: %s", key)
|
|
}
|
|
}
|
|
}
|
|
|
|
func controlPlaneRouteKey(method, path string) string { return method + " " + path }
|
|
|
|
func registeredControlPlaneRoutes(app *fiber.App) map[string]struct{} {
|
|
routes := map[string]struct{}{}
|
|
for _, methodRoutes := range app.Stack() {
|
|
for _, route := range methodRoutes {
|
|
if !strings.HasPrefix(route.Path, "/api/") {
|
|
continue
|
|
}
|
|
switch route.Method {
|
|
case http.MethodGet, http.MethodPost, http.MethodPut, http.MethodDelete:
|
|
routes[controlPlaneRouteKey(route.Method, route.Path)] = struct{}{}
|
|
}
|
|
}
|
|
}
|
|
return routes
|
|
}
|
|
|
|
func controlPlaneRouteMatrix() []controlPlaneRouteCase {
|
|
return []controlPlaneRouteCase{
|
|
{http.MethodGet, "/api/browsers", "/api/browsers", "", http.StatusOK},
|
|
{http.MethodGet, "/api/browsers/:alias", "/api/browsers/missing", "", http.StatusNotFound},
|
|
{http.MethodPost, "/api/browsers", "/api/browsers", "", http.StatusBadRequest},
|
|
{http.MethodPost, "/api/browsers/:alias/:action", "/api/browsers/missing/start", "", http.StatusNotFound},
|
|
{http.MethodDelete, "/api/browsers/:alias", "/api/browsers/missing", "", http.StatusNotFound},
|
|
|
|
{http.MethodGet, "/api/network-exits", "/api/network-exits", "", http.StatusOK},
|
|
{http.MethodGet, "/api/network-exits/:id", "/api/network-exits/missing", "", http.StatusNotFound},
|
|
{http.MethodPost, "/api/network-exits", "/api/network-exits", "", http.StatusBadRequest},
|
|
{http.MethodPost, "/api/network-exits/:id/check", "/api/network-exits/missing/check", "", http.StatusNotFound},
|
|
{http.MethodPost, "/api/network-exits/:id/disable", "/api/network-exits/missing/disable", "", http.StatusNotFound},
|
|
{http.MethodPut, "/api/network-exits/:id", "/api/network-exits/missing", "", http.StatusBadRequest},
|
|
{http.MethodPost, "/api/network-exits/:id/enable", "/api/network-exits/missing/enable", "", http.StatusNotFound},
|
|
{http.MethodDelete, "/api/network-exits/:id", "/api/network-exits/missing", "", http.StatusNotFound},
|
|
|
|
{http.MethodGet, "/api/browser-images", "/api/browser-images", "", http.StatusOK},
|
|
{http.MethodPost, "/api/browser-images", "/api/browser-images", "", http.StatusBadRequest},
|
|
{http.MethodPut, "/api/browser-images/:version", "/api/browser-images/999.0.0", `{"image_ref":"registry.example/browser:missing"}`, http.StatusNotFound},
|
|
{http.MethodDelete, "/api/browser-images/:version", "/api/browser-images/999.0.0", "", http.StatusNotFound},
|
|
|
|
{http.MethodGet, "/api/gateways", "/api/gateways", "", http.StatusOK},
|
|
{http.MethodPost, "/api/gateways", "/api/gateways", "", http.StatusBadRequest},
|
|
{http.MethodPut, "/api/gateways/:name", "/api/gateways/missing", `{"name":"gw-missing","endpoint":"http://gw-missing:8081","token":""}`, http.StatusNotFound},
|
|
{http.MethodDelete, "/api/gateways/:name", "/api/gateways/missing", "", http.StatusNotFound},
|
|
|
|
{http.MethodPost, "/api/phase-a/accounts", "/api/phase-a/accounts", "", http.StatusBadRequest},
|
|
{http.MethodGet, "/api/phase-a/accounts", "/api/phase-a/accounts", "", http.StatusOK},
|
|
{http.MethodGet, "/api/phase-a/accounts/:id", "/api/phase-a/accounts/missing", "", http.StatusNotFound},
|
|
{http.MethodPost, "/api/phase-a/accounts/:id/pause", "/api/phase-a/accounts/missing/pause", "", http.StatusNotFound},
|
|
{http.MethodPost, "/api/phase-a/accounts/:id/resume", "/api/phase-a/accounts/missing/resume", "", http.StatusNotFound},
|
|
{http.MethodPost, "/api/phase-a/accounts/:id/revoke", "/api/phase-a/accounts/missing/revoke", "", http.StatusNotFound},
|
|
|
|
{http.MethodPost, "/api/phase-a/drafts", "/api/phase-a/drafts", "", http.StatusBadRequest},
|
|
{http.MethodGet, "/api/phase-a/drafts", "/api/phase-a/drafts", "", http.StatusOK},
|
|
{http.MethodGet, "/api/phase-a/drafts/:id", "/api/phase-a/drafts/missing", "", http.StatusNotFound},
|
|
|
|
{http.MethodPost, "/api/phase-a/confirmations", "/api/phase-a/confirmations", "", http.StatusBadRequest},
|
|
{http.MethodGet, "/api/phase-a/confirmations", "/api/phase-a/confirmations", "", http.StatusOK},
|
|
{http.MethodGet, "/api/phase-a/confirmations/:id", "/api/phase-a/confirmations/missing", "", http.StatusNotFound},
|
|
|
|
{http.MethodPost, "/api/phase-a/tasks", "/api/phase-a/tasks", "", http.StatusBadRequest},
|
|
{http.MethodGet, "/api/phase-a/tasks", "/api/phase-a/tasks", "", http.StatusOK},
|
|
{http.MethodGet, "/api/phase-a/tasks/:id", "/api/phase-a/tasks/missing", "", http.StatusNotFound},
|
|
{http.MethodGet, "/api/phase-a/attempts/:id", "/api/phase-a/attempts/missing", "", http.StatusNotFound},
|
|
{http.MethodPost, "/api/phase-a/tasks/:id/cancel", "/api/phase-a/tasks/missing/cancel", "", http.StatusNotFound},
|
|
{http.MethodPost, "/api/phase-a/tasks/:id/verify", "/api/phase-a/tasks/missing/verify", "", http.StatusBadRequest},
|
|
{http.MethodPost, "/api/phase-a/tasks/:id/resume", "/api/phase-a/tasks/missing/resume", "", http.StatusNotFound},
|
|
{http.MethodPost, "/api/phase-a/tasks/:id/finish", "/api/phase-a/tasks/missing/finish", "", http.StatusNotFound},
|
|
|
|
{http.MethodPost, "/api/phase-a/mock/execute", "/api/phase-a/mock/execute", "", http.StatusBadRequest},
|
|
{http.MethodGet, "/api/phase-a/audit", "/api/phase-a/audit", "", http.StatusOK},
|
|
}
|
|
}
|
|
|
|
func TestOperatorNotificationFiltersAndRedacts(t *testing.T) {
|
|
previousLevel := logrus.GetLevel()
|
|
t.Cleanup(func() { logrus.SetLevel(previousLevel) })
|
|
var output bytes.Buffer
|
|
notify := newAttentionNotifier(&output)
|
|
|
|
notify(taskstate.Transition{State: "succeeded", ReasonCode: "task_succeeded", AccountID: "account-a", TaskID: "task-a"})
|
|
if output.Len() != 0 {
|
|
t.Fatalf("successful task emitted a notification: %s", output.String())
|
|
}
|
|
|
|
for _, level := range []logrus.Level{logrus.ErrorLevel, logrus.FatalLevel, logrus.PanicLevel} {
|
|
for _, test := range []struct{ state, reason string }{
|
|
{"policy_hold", "account_paused"}, {"needs_confirmation", "exit_unhealthy"},
|
|
} {
|
|
output.Reset()
|
|
logrus.SetLevel(level)
|
|
notify(taskstate.Transition{State: test.state, ReasonCode: test.reason, AccountID: "account-a", TaskID: "task-a"})
|
|
var entry map[string]any
|
|
if err := json.Unmarshal(output.Bytes(), &entry); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if entry["event_type"] != test.state || entry["reason_code"] != test.reason ||
|
|
entry["notification_channel"] != "structured_log" || entry["account_id"] != "account-a" || entry["task_id"] != "task-a" {
|
|
t.Fatalf("unexpected notification at LOG_LEVEL=%s: %#v", level, entry)
|
|
}
|
|
for _, forbidden := range []string{"password", "authorization", "credential", "token", "secret"} {
|
|
if strings.Contains(strings.ToLower(output.String()), forbidden) {
|
|
t.Fatalf("notification contained sensitive field %q: %s", forbidden, output.String())
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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 TestPhaseAAccountHTTPWorkflowRedactsSecrets(t *testing.T) {
|
|
databaseURL := os.Getenv("CREATORHUB_POSTGRES_TEST_URL")
|
|
if databaseURL == "" {
|
|
t.Skip("set CREATORHUB_POSTGRES_TEST_URL to run PostgreSQL integration coverage")
|
|
}
|
|
ctx := context.Background()
|
|
databaseURL = isolatedControlPlaneDatabaseURL(t, databaseURL)
|
|
store, err := phasea.Open(ctx, databaseURL)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = store.Close() })
|
|
hubStore, err := hub.Open(ctx, databaseURL)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = hubStore.Close() })
|
|
|
|
webDirectory := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(webDirectory, "index.html"), []byte("index"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
protectedApp := newHandlerWithStores(webDirectory, "operator", "unit-test-password", store, hubStore)
|
|
for _, route := range []struct{ method, path string }{
|
|
{http.MethodGet, "/api/phase-a/accounts"}, {http.MethodPost, "/api/phase-a/accounts"},
|
|
{http.MethodPost, "/api/phase-a/accounts/account-http/pause"}, {http.MethodGet, "/api/phase-a/tasks"},
|
|
{http.MethodPost, "/api/phase-a/mock/execute"}, {http.MethodGet, "/api/phase-a/audit"},
|
|
{http.MethodGet, "/api/browsers"}, {http.MethodPost, "/api/browsers/account-http/start"},
|
|
{http.MethodDelete, "/api/browsers/account-http"}, {http.MethodPut, "/api/browser-images/1"},
|
|
{http.MethodPost, "/api/network-exits/exit-http/check"}, {http.MethodPost, "/api/network-exits/exit-http/disable"},
|
|
} {
|
|
if response := do(protectedApp, route.method, route.path, ""); response.Code != http.StatusUnauthorized {
|
|
t.Fatalf("unauthenticated %s %s returned %d", route.method, route.path, response.Code)
|
|
}
|
|
}
|
|
for _, path := range []string{"/api/phase-a/accounts", "/api/browsers", "/api/phase-a/audit"} {
|
|
if response := do(protectedApp, http.MethodGet, path, "", "operator", "unit-test-password"); response.Code != http.StatusOK {
|
|
t.Fatalf("authenticated GET %s returned %d: %s", path, response.Code, response.Body.String())
|
|
}
|
|
}
|
|
app := fiber.New()
|
|
app.Use(authenticate("operator", "unit-test-password"))
|
|
credentials := &testCredentialBridge{values: map[string]string{}}
|
|
registerPhaseA(app, store, nil, credentials)
|
|
cookieHeader := "sessionid=phase-http-secret; token=value"
|
|
accountBody := `{"name":"Phase HTTP","platform":"douyin","platform_account_key":"phase-http","tags":["测试"],"cookies":"` + cookieHeader + `"}`
|
|
request := func(method, path, body string, wantStatus int) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
response := do(app, method, path, body, "operator", "unit-test-password")
|
|
if response.Code != wantStatus {
|
|
t.Fatalf("%s %s returned %d, want %d: %s", method, path, response.Code, wantStatus, response.Body.String())
|
|
}
|
|
if strings.Contains(response.Body.String(), cookieHeader) {
|
|
t.Fatalf("%s %s leaked cookies: %s", method, path, response.Body.String())
|
|
}
|
|
return response
|
|
}
|
|
|
|
created := request(http.MethodPost, "/api/phase-a/accounts", accountBody, http.StatusCreated)
|
|
var account phasea.Account
|
|
if err := json.Unmarshal(created.Body.Bytes(), &account); err != nil || account.ID == "" || account.Name != "Phase HTTP" || account.RuntimeStatus != "paused" {
|
|
t.Fatalf("unexpected account response: %#v err=%v", account, err)
|
|
}
|
|
if credentials.values["creatorhub/"+account.ID+"/cookies"] != cookieHeader {
|
|
t.Fatalf("cookies were not stored through the credential bridge: %#v", credentials.values)
|
|
}
|
|
request(http.MethodPost, "/api/phase-a/accounts", accountBody, http.StatusConflict)
|
|
request(http.MethodPost, "/api/phase-a/accounts", `{"name":"Bad","platform":"douyin","platform_account_key":"bad","tags":[],"cookies":"sessionid"}`, http.StatusBadRequest)
|
|
request(http.MethodPost, "/api/phase-a/accounts", strings.TrimSuffix(accountBody, "}")+`,"password":"secret"}`, http.StatusBadRequest)
|
|
request(http.MethodGet, "/api/phase-a/accounts", "", http.StatusOK)
|
|
request(http.MethodGet, "/api/phase-a/accounts/"+account.ID, "", http.StatusOK)
|
|
|
|
db, err := sql.Open("pgx", databaseURL)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = db.Close() })
|
|
assertControlPlaneDatabaseCount(t, db, `SELECT count(*) FROM credential_reference
|
|
WHERE id = '`+account.ID+`-cookies' AND provider = 'os_keyring'`, 1)
|
|
assertControlPlaneDatabaseCount(t, db, `SELECT count(*) FROM information_schema.columns
|
|
WHERE table_schema = current_schema() AND table_name = 'social_account' AND column_name = 'cookies'`, 0)
|
|
|
|
for _, failure := range []struct {
|
|
name string
|
|
configure func(*persistentCredentialBridge)
|
|
}{
|
|
{name: "chmod", configure: func(bridge *persistentCredentialBridge) {
|
|
bridge.chmod = func(*os.File, os.FileMode) error { return errors.New("chmod unavailable") }
|
|
}},
|
|
{name: "directory-sync", configure: func(bridge *persistentCredentialBridge) {
|
|
calls := 0
|
|
bridge.syncDirectory = func(path string) error {
|
|
calls++
|
|
if calls == 1 {
|
|
return errors.New("directory sync unavailable")
|
|
}
|
|
return syncCredentialDirectory(path)
|
|
}
|
|
}},
|
|
} {
|
|
failureDirectory := t.TempDir()
|
|
failureCredentials, err := newPersistentCredentialBridge(failureDirectory, []byte("0123456789abcdef0123456789abcdef"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
failure.configure(failureCredentials)
|
|
failureApp := fiber.New()
|
|
failureApp.Use(authenticate("operator", "unit-test-password"))
|
|
registerPhaseA(failureApp, store, nil, failureCredentials)
|
|
platformKey := "phase-http-store-" + failure.name
|
|
failureBody := `{"name":"Store failure","platform":"douyin","platform_account_key":"` + platformKey + `","tags":[],"cookies":"sessionid=value"}`
|
|
failureResponse := do(failureApp, http.MethodPost, "/api/phase-a/accounts", failureBody, "operator", "unit-test-password")
|
|
if failureResponse.Code != http.StatusInternalServerError {
|
|
t.Fatalf("%s failure returned %d, want %d: %s", failure.name, failureResponse.Code, http.StatusInternalServerError, failureResponse.Body.String())
|
|
}
|
|
var failedAccounts int
|
|
if err := db.QueryRowContext(ctx, `SELECT count(*) FROM social_account WHERE platform_account_key = $1`, platformKey).Scan(&failedAccounts); err != nil || failedAccounts != 0 {
|
|
t.Fatalf("%s failure persisted %d accounts: %v", failure.name, failedAccounts, err)
|
|
}
|
|
entries, err := os.ReadDir(failureDirectory)
|
|
if err != nil || len(entries) != 0 {
|
|
t.Fatalf("%s failure left ciphertext: entries=%v err=%v", failure.name, entries, err)
|
|
}
|
|
}
|
|
|
|
if _, err := db.ExecContext(ctx, `
|
|
INSERT INTO gateway (name, endpoint, token) VALUES ('phase-http', 'http://127.0.0.1:8081', 'phase-http-gateway-token');
|
|
INSERT INTO browser_image (version, image_ref) VALUES ('1', 'example/browser:1');
|
|
INSERT INTO browser_env (alias, name, gateway_name, image_version, fingerprint)
|
|
VALUES ('account-http', 'Phase HTTP', 'phase-http', '1', '{"seed":1}');
|
|
INSERT INTO network_exit (id, protocol, host, port, health_status)
|
|
VALUES ('exit-http', 'socks5', '127.0.0.1', 1080, 'healthy')`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := db.ExecContext(ctx, `INSERT INTO environment_binding (id, account_id, browser_env_alias, network_exit_id)
|
|
VALUES ('binding-http', $1, 'account-http', 'exit-http')`, account.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
request(http.MethodPost, "/api/phase-a/accounts/"+account.ID+"/resume", "", http.StatusNoContent)
|
|
request(http.MethodPost, "/api/phase-a/accounts/"+account.ID+"/pause", "", http.StatusNoContent)
|
|
request(http.MethodPost, "/api/phase-a/accounts/"+account.ID+"/resume", "", http.StatusNoContent)
|
|
request(http.MethodPost, "/api/phase-a/accounts/"+account.ID+"/revoke", "", http.StatusNoContent)
|
|
blocked := request(http.MethodPost, "/api/phase-a/accounts/"+account.ID+"/resume", "", http.StatusConflict)
|
|
if !strings.Contains(blocked.Body.String(), `"reason_code":"account_revoked"`) {
|
|
t.Fatalf("revoked resume did not return a stable conflict reason: %s", blocked.Body.String())
|
|
}
|
|
}
|
|
|
|
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 TestSPAServesFileAndIndexFallback(t *testing.T) {
|
|
directory := t.TempDir()
|
|
files := map[string]string{
|
|
"index.html": "index",
|
|
"app.js": "asset",
|
|
"hello world.js": "space",
|
|
"应用.js": "unicode",
|
|
}
|
|
for name, body := range files {
|
|
if err := os.WriteFile(filepath.Join(directory, name), []byte(body), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if err := os.WriteFile(filepath.Join(directory, "..", "outside-secret.txt"), []byte("secret"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
app := newHandler(directory, "operator", "unit-test-password")
|
|
for path, want := range map[string]string{
|
|
"/app.js": "asset",
|
|
"/hello%20world.js": "space",
|
|
"/%E5%BA%94%E7%94%A8.js": "unicode",
|
|
"/%2e%2e%2foutside-secret.txt": "index",
|
|
"/dashboard": "index",
|
|
} {
|
|
request := httptest.NewRequest(http.MethodGet, path, nil)
|
|
request.SetBasicAuth("operator", "unit-test-password")
|
|
response, err := app.Test(request)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
body, readErr := io.ReadAll(response.Body)
|
|
response.Body.Close()
|
|
if readErr != nil || response.StatusCode != http.StatusOK || string(body) != want {
|
|
t.Fatalf("GET %s: status=%d body=%q err=%v", path, response.StatusCode, body, readErr)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestStartupLogIncludesListenAddress(t *testing.T) {
|
|
logger := logrus.StandardLogger()
|
|
previousOutput, previousFormatter, previousLevel := logger.Out, logger.Formatter, logger.Level
|
|
t.Cleanup(func() {
|
|
logrus.SetOutput(previousOutput)
|
|
logrus.SetFormatter(previousFormatter)
|
|
logrus.SetLevel(previousLevel)
|
|
})
|
|
var output bytes.Buffer
|
|
logrus.SetOutput(&output)
|
|
logrus.SetFormatter(&logrus.JSONFormatter{})
|
|
logrus.SetLevel(logrus.InfoLevel)
|
|
|
|
logStartup(config{listenAddr: ":8080"})
|
|
var entry map[string]any
|
|
if err := json.Unmarshal(output.Bytes(), &entry); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if entry["listen_addr"] != ":8080" || entry["service"] != "control-plane" {
|
|
t.Fatalf("unexpected startup log: %#v", entry)
|
|
}
|
|
}
|