From f054d04321abe922e208b84e3f9623c127c6f450 Mon Sep 17 00:00:00 2001 From: Rogee Date: Wed, 2 Sep 2026 02:03:36 +0800 Subject: [PATCH] HH-912: simplify social account creation (#36) --- .env.example | 1 + Dockerfile | 3 +- README.md | 3 +- cmd/control-plane/credential.go | 135 ++++++++++++++ cmd/control-plane/credential_test.go | 102 +++++++++++ cmd/control-plane/hub_test.go | 26 +-- cmd/control-plane/main.go | 42 +++-- cmd/control-plane/main_test.go | 129 ++++++++++--- cmd/control-plane/network.go | 9 +- cmd/control-plane/phasea.go | 41 +++-- compose.yaml | 4 + docs/architecture/container-control.md | 4 +- docs/deployment.md | 28 +-- internal/hub/migration_test.go | 91 ++++++++- .../hub/migrations/013_account_creation.sql | 17 ++ .../014_account_creation_compatibility.sql | 20 ++ internal/hub/store.go | 8 +- internal/phasea/store.go | 137 ++++++++++---- internal/phasea/store_test.go | 172 +++++++++++++++--- web/src/AccountList.jsx | 54 +++--- web/src/AccountList.test.jsx | 45 ++--- web/src/dataProvider.test.js | 3 +- web/tests/responsive.e2e.js | 26 ++- 23 files changed, 885 insertions(+), 215 deletions(-) create mode 100644 cmd/control-plane/credential.go create mode 100644 cmd/control-plane/credential_test.go create mode 100644 internal/hub/migrations/013_account_creation.sql create mode 100644 internal/hub/migrations/014_account_creation_compatibility.sql diff --git a/.env.example b/.env.example index f50a8c8..ff4e60a 100644 --- a/.env.example +++ b/.env.example @@ -3,3 +3,4 @@ CREATORHUB_PORT=8082 GATEWAY_TOKEN=dev-creatorhub-gateway-token CONTROL_PLANE_USERNAME= CONTROL_PLANE_PASSWORD= +CREATORHUB_CREDENTIAL_MASTER_KEY= diff --git a/Dockerfile b/Dockerfile index 123e6b1..c540fef 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,7 +15,8 @@ RUN CGO_ENABLED=0 go build -buildvcs=false -trimpath -ldflags='-s -w' -o /out/co && CGO_ENABLED=0 go build -buildvcs=false -trimpath -ldflags='-s -w' -o /out/docker-gateway ./cmd/docker-gateway FROM alpine:3.22@sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695dce -RUN addgroup -g 65532 app && adduser -D -u 65532 -G app app +RUN addgroup -g 65532 app && adduser -D -u 65532 -G app app \ + && install -d -o app -g app -m 0700 /var/lib/creatorhub/credentials WORKDIR /app COPY --from=go /out/control-plane /out/docker-gateway /app/ COPY --from=web /src/web/dist /app/web diff --git a/README.md b/README.md index 3324cc4..ed5e09a 100644 --- a/README.md +++ b/README.md @@ -16,10 +16,11 @@ ```bash export CONTROL_PLANE_USERNAME=creatorhub export CONTROL_PLANE_PASSWORD="$(openssl rand -hex 24)" +export CREATORHUB_CREDENTIAL_MASTER_KEY="$(openssl rand -base64 32)" DOCKER_GID=$(stat -c %g /var/run/docker.sock) docker compose up --build ``` -两个控制面凭据变量均为必填;Compose 不提供默认凭据。开发环境可复制 `.env.example` 后显式填写本地值。 +三个控制面凭据变量均为必填;Compose 不提供默认凭据。`CREATORHUB_CREDENTIAL_MASTER_KEY` 必须在重启后保持不变。开发环境可复制 `.env.example` 后显式填写本地值。 打开 ,使用 `CONTROL_PLANE_USERNAME` / `CONTROL_PLANE_PASSWORD` 登录;局域网内用宿主机 IP 访问同一端口。首次使用:在「网关管理」用 Compose 里的 `GATEWAY_TOKEN` 注册 `http://docker-gateway:8081`,在「镜像版本」添加可用的指纹浏览器镜像引用,即可创建环境;网关会在镜像缺失时自动拉取。架构、API 契约、失败语义和 `docker.sock` 风险边界见 [《浏览器容器控制面》](docs/architecture/container-control.md)。 diff --git a/cmd/control-plane/credential.go b/cmd/control-plane/credential.go new file mode 100644 index 0000000..bdfc707 --- /dev/null +++ b/cmd/control-plane/credential.go @@ -0,0 +1,135 @@ +package main + +import ( + "context" + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "errors" + "io" + "os" + "path/filepath" + + "git.ipao.vip/rogee/creator-hub/internal/phasea" +) + +const credentialFileVersion byte = 1 + +type persistentCredentialBridge struct { + directory string + key [32]byte + chmod func(*os.File, os.FileMode) error + syncDirectory func(string) error +} + +func newPersistentCredentialBridge(directory string, key []byte) (*persistentCredentialBridge, error) { + if !filepath.IsAbs(directory) || len(key) != 32 { + return nil, errors.New("invalid credential provider configuration") + } + if err := os.MkdirAll(directory, 0o700); err != nil || os.Chmod(directory, 0o700) != nil { + return nil, errors.New("initialize credential provider") + } + bridge := &persistentCredentialBridge{ + directory: directory, chmod: (*os.File).Chmod, syncDirectory: syncCredentialDirectory, + } + copy(bridge.key[:], key) + return bridge, nil +} + +func (bridge *persistentCredentialBridge) Store(ctx context.Context, reference phasea.CredentialReference, key, value string) error { + if err := ctx.Err(); err != nil { + return err + } + if !validCredentialReference(reference.Provider, key) || value == "" || len(value) > 64<<10 { + return errors.New("invalid credential") + } + aead, err := bridge.aead() + if err != nil { + return err + } + nonce := make([]byte, aead.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return errors.New("encrypt credential") + } + payload := append([]byte{credentialFileVersion}, nonce...) + payload = aead.Seal(payload, nonce, []byte(value), credentialAAD(reference.Provider, key)) + temporary, err := os.CreateTemp(bridge.directory, ".credential-*") + if err != nil { + return errors.New("store credential") + } + temporaryName := temporary.Name() + defer os.Remove(temporaryName) + if err = bridge.chmod(temporary, 0o600); err == nil { + _, err = temporary.Write(payload) + } + if err == nil { + err = temporary.Sync() + } + if closeErr := temporary.Close(); err == nil { + err = closeErr + } + if err != nil { + return errors.New("store credential") + } + target := bridge.path(reference.Provider, key) + if os.Rename(temporaryName, target) != nil { + return errors.New("store credential") + } + if bridge.syncDirectory(bridge.directory) != nil { + cleanupErr := os.Remove(target) + if cleanupErr == nil { + cleanupErr = bridge.syncDirectory(bridge.directory) + } + if cleanupErr != nil { + return errors.Join(errors.New("store credential"), errors.New("delete incomplete credential")) + } + return errors.New("store credential") + } + return nil +} + +func (bridge *persistentCredentialBridge) Delete(ctx context.Context, reference phasea.CredentialReference, key string) error { + if err := ctx.Err(); err != nil { + return err + } + if !validCredentialReference(reference.Provider, key) { + return errors.New("invalid credential reference") + } + if err := os.Remove(bridge.path(reference.Provider, key)); err != nil && !errors.Is(err, os.ErrNotExist) { + return errors.New("delete credential") + } + if err := bridge.syncDirectory(bridge.directory); err != nil { + return errors.New("delete credential") + } + return nil +} + +func (bridge *persistentCredentialBridge) aead() (cipher.AEAD, error) { + block, err := aes.NewCipher(bridge.key[:]) + if err != nil { + return nil, errors.New("initialize credential encryption") + } + return cipher.NewGCM(block) +} + +func (bridge *persistentCredentialBridge) path(provider, key string) string { + digest := sha256.Sum256(credentialAAD(provider, key)) + return filepath.Join(bridge.directory, hex.EncodeToString(digest[:])+".credential") +} + +func credentialAAD(provider, key string) []byte { return []byte(provider + "\x00" + key) } + +func validCredentialReference(provider, key string) bool { + return (provider == "os_keyring" || provider == "secret_manager") && key != "" && len(key) <= 192 +} + +func syncCredentialDirectory(path string) error { + directory, err := os.Open(path) + if err != nil { + return err + } + defer directory.Close() + return directory.Sync() +} diff --git a/cmd/control-plane/credential_test.go b/cmd/control-plane/credential_test.go new file mode 100644 index 0000000..5dfd3af --- /dev/null +++ b/cmd/control-plane/credential_test.go @@ -0,0 +1,102 @@ +package main + +import ( + "context" + "errors" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "git.ipao.vip/rogee/creator-hub/internal/phasea" +) + +const testCredentialValue = "sessionid=restart-secret; token=second" + +var testCredentialReference = phasea.CredentialReference{ID: "account-a-cookies", Provider: "os_keyring"} + +func TestPersistentCredentialBridgeStoreFailureLeavesNoFile(t *testing.T) { + for _, failure := range []string{"chmod", "directory sync"} { + t.Run(failure, func(t *testing.T) { + directory := t.TempDir() + bridge, err := newPersistentCredentialBridge(directory, []byte("0123456789abcdef0123456789abcdef")) + if err != nil { + t.Fatal(err) + } + var chmodFile *os.File + switch failure { + case "chmod": + bridge.chmod = func(file *os.File, _ os.FileMode) error { + chmodFile = file + return errors.New("chmod unavailable") + } + case "directory sync": + calls := 0 + bridge.syncDirectory = func(path string) error { + calls++ + if calls == 1 { + return errors.New("directory sync unavailable") + } + return syncCredentialDirectory(path) + } + } + if err := bridge.Store(context.Background(), testCredentialReference, + "creatorhub/account-a/cookies", testCredentialValue); err == nil { + t.Fatal("credential store failure returned success") + } + if chmodFile != nil { + if _, err := chmodFile.Write([]byte("still-open")); err == nil { + t.Fatal("credential temporary file remained open after chmod failure") + } + } + entries, err := os.ReadDir(directory) + if err != nil || len(entries) != 0 { + t.Fatalf("credential store failure left files behind: entries=%v err=%v", entries, err) + } + }) + } +} + +func TestPersistentCredentialBridgeStoresEncryptedCredentialAcrossProcesses(t *testing.T) { + if action := os.Getenv("CREATORHUB_CREDENTIAL_HELPER_ACTION"); action != "" { + bridge, err := newPersistentCredentialBridge(os.Getenv("CREATORHUB_CREDENTIAL_HELPER_DIR"), []byte("0123456789abcdef0123456789abcdef")) + if err != nil { + t.Fatal(err) + } + switch action { + case "store": + err = bridge.Store(context.Background(), testCredentialReference, "creatorhub/account-a/cookies", testCredentialValue) + case "delete": + err = bridge.Delete(context.Background(), testCredentialReference, "creatorhub/account-a/cookies") + } + if err != nil { + t.Fatal(err) + } + return + } + + directory := t.TempDir() + run := func(action string) { + t.Helper() + process := exec.Command(os.Args[0], "-test.run=^TestPersistentCredentialBridgeStoresEncryptedCredentialAcrossProcesses$") + process.Env = append(os.Environ(), "CREATORHUB_CREDENTIAL_HELPER_ACTION="+action, + "CREATORHUB_CREDENTIAL_HELPER_DIR="+directory) + if output, err := process.CombinedOutput(); err != nil { + t.Fatalf("credential helper %s: %v %s", action, err, output) + } + } + run("store") + files, err := filepath.Glob(filepath.Join(directory, "*.credential")) + if err != nil || len(files) != 1 { + t.Fatalf("persistent credential file missing: files=%v err=%v", files, err) + } + payload, err := os.ReadFile(files[0]) + if err != nil || strings.Contains(string(payload), "restart-secret") { + t.Fatalf("credential file is unreadable or contains plaintext: err=%v", err) + } + run("delete") + if _, err := os.Stat(files[0]); !os.IsNotExist(err) { + t.Fatalf("credential file remained after delete: %v", err) + } +} diff --git a/cmd/control-plane/hub_test.go b/cmd/control-plane/hub_test.go index 3d01125..986c17c 100644 --- a/cmd/control-plane/hub_test.go +++ b/cmd/control-plane/hub_test.go @@ -501,7 +501,7 @@ func (s *memoryStore) AppendEnvironmentAction(_ context.Context, _ string, actio func TestPhaseARuntimeLeaseRoutesAreInternal(t *testing.T) { app := fiber.New() - registerPhaseA(app, nil, nil) + registerPhaseA(app, nil, nil, nil) for _, request := range []struct { method, path string }{ @@ -534,7 +534,7 @@ func TestPauseSerializesResumeAndRetainsClaimGateOnUnknownStop(t *testing.T) { } blocking := &blockingRuntimeStopStore{Store: fixture.store, entered: make(chan struct{}, 1), release: make(chan struct{})} app := fiber.New() - registerPhaseA(app, accountStore, blocking) + registerPhaseA(app, accountStore, blocking, nil) pauseDone := make(chan *httptest.ResponseRecorder, 1) resumeDone := make(chan *httptest.ResponseRecorder, 1) go func() { pauseDone <- do(app, http.MethodPost, "/api/phase-a/accounts/account-a/pause", "") }() @@ -1547,10 +1547,11 @@ func TestUpgradeBrowserUsesCommittedPostgresBinding(t *testing.T) { t.Fatal(err) } if err := accountStore.CreateAccount(ctx, phasea.Account{ - ID: "account-a", Platform: "mock", PlatformAccountKey: "account-a", AuthorizationKind: "owned", - CredentialReference: phasea.CredentialReference{ID: "credential-account", Provider: "os_keyring"}, - CredentialKey: "creatorhub/account-a", - }); err != nil { + ID: "account-a", Name: "账号 A", Platform: "douyin", PlatformAccountKey: "account-a", + Tags: []string{}, Cookies: "sessionid=value", + CredentialReference: phasea.CredentialReference{ID: "account-a-cookies", Provider: "os_keyring"}, + CredentialKey: "creatorhub/account-a/cookies", + }, &testCredentialBridge{values: map[string]string{}}); err != nil { t.Fatal(err) } if err := accountStore.Close(); err != nil { @@ -1733,10 +1734,11 @@ func newPostgresRebindFixture(t *testing.T, databaseURL string) postgresRebindFi t.Fatal(err) } if err := accountStore.CreateAccount(ctx, phasea.Account{ - ID: "account-a", Platform: "mock", PlatformAccountKey: "account-a", AuthorizationKind: "owned", - CredentialReference: phasea.CredentialReference{ID: "credential-account", Provider: "os_keyring"}, - CredentialKey: "creatorhub/account-a", - }); err != nil { + ID: "account-a", Name: "账号 A", Platform: "douyin", PlatformAccountKey: "account-a", + Tags: []string{}, Cookies: "sessionid=value", + CredentialReference: phasea.CredentialReference{ID: "account-a-cookies", Provider: "os_keyring"}, + CredentialKey: "creatorhub/account-a/cookies", + }, &testCredentialBridge{values: map[string]string{}}); err != nil { t.Fatal(err) } if err := accountStore.Close(); err != nil { @@ -1840,7 +1842,7 @@ func TestPauseClosesClaimGateBeforeStoppingRuntime(t *testing.T) { } app := fiber.New() - registerPhaseA(app, accountStore, fixture.store) + registerPhaseA(app, accountStore, fixture.store, nil) response := do(app, http.MethodPost, "/api/phase-a/accounts/account-a/pause", "") if response.Code != test.wantStatus { t.Fatalf("pause returned %d, want %d: %s", response.Code, test.wantStatus, response.Body.String()) @@ -1882,7 +1884,7 @@ func TestPauseAndRevokeStopContainerWithoutRuntimeLease(t *testing.T) { BindingVersion: fixture.bound.BindingVersion, NetworkExitID: fixture.bound.Exit.ID, }} app := fiber.New() - registerPhaseA(app, accountStore, fixture.store) + registerPhaseA(app, accountStore, fixture.store, nil) if err := accountStore.ResumeAccount(ctx, fixture.bound.AccountID); err != nil { t.Fatal(err) diff --git a/cmd/control-plane/main.go b/cmd/control-plane/main.go index 8a0e9ba..af4da77 100644 --- a/cmd/control-plane/main.go +++ b/cmd/control-plane/main.go @@ -27,9 +27,10 @@ import ( ) type config struct { - listenAddr, webDir, databaseURL string - username, password string - logLevel logrus.Level + listenAddr, webDir, databaseURL, credentialStoreDir string + username, password string + credentialMasterKey []byte + logLevel logrus.Level } func main() { @@ -58,6 +59,10 @@ func newCommand() *cobra.Command { if err != nil { return err } + credentials, err := newPersistentCredentialBridge(cfg.credentialStoreDir, cfg.credentialMasterKey) + if err != nil { + return err + } logrus.SetLevel(cfg.logLevel) phaseAStore, err := phasea.Open(command.Context(), cfg.databaseURL) if err != nil { @@ -79,7 +84,7 @@ func newCommand() *cobra.Command { defer close(heartbeatDone) runtimeLeaseHeartbeat(heartbeatContext, hubStore) }() - listenErr := newHandlerWithStores(cfg.webDir, cfg.username, cfg.password, phaseAStore, hubStore).Listen(cfg.listenAddr, fiber.ListenConfig{ + listenErr := newHandlerWithCredentialBridge(cfg.webDir, cfg.username, cfg.password, phaseAStore, hubStore, credentials).Listen(cfg.listenAddr, fiber.ListenConfig{ GracefulContext: command.Context(), DisableStartupMessage: true, }) @@ -133,10 +138,13 @@ func loadConfig() (config, error) { v.SetDefault("listen_addr", ":8080") v.SetDefault("web_dir", "web/dist") v.SetDefault("database_url", "postgres://creatorhub@127.0.0.1:5432/creatorhub?sslmode=disable") + v.SetDefault("credential_store_dir", "/var/lib/creatorhub/credentials") v.SetDefault("log_level", "info") _ = v.BindEnv("listen_addr", "LISTEN_ADDR") _ = v.BindEnv("web_dir", "WEB_DIR") _ = v.BindEnv("database_url", "DATABASE_URL") + _ = v.BindEnv("credential_store_dir", "CREATORHUB_CREDENTIAL_STORE_DIR") + _ = v.BindEnv("credential_master_key", "CREATORHUB_CREDENTIAL_MASTER_KEY") _ = v.BindEnv("log_level", "LOG_LEVEL") _ = v.BindEnv("username", "CONTROL_PLANE_USERNAME") _ = v.BindEnv("password", "CONTROL_PLANE_PASSWORD") @@ -146,12 +154,13 @@ func loadConfig() (config, error) { return config{}, errors.New("LOG_LEVEL must be panic, fatal, error, warn, info, debug, or trace") } cfg := config{ - listenAddr: strings.TrimSpace(v.GetString("listen_addr")), - webDir: strings.TrimSpace(v.GetString("web_dir")), - databaseURL: strings.TrimSpace(v.GetString("database_url")), - username: strings.TrimSpace(v.GetString("username")), - password: v.GetString("password"), - logLevel: level, + listenAddr: strings.TrimSpace(v.GetString("listen_addr")), + webDir: strings.TrimSpace(v.GetString("web_dir")), + databaseURL: strings.TrimSpace(v.GetString("database_url")), + credentialStoreDir: strings.TrimSpace(v.GetString("credential_store_dir")), + username: strings.TrimSpace(v.GetString("username")), + password: v.GetString("password"), + logLevel: level, } if cfg.listenAddr == "" { return config{}, errors.New("LISTEN_ADDR must not be empty") @@ -168,6 +177,13 @@ func loadConfig() (config, error) { if len(cfg.password) < 6 { return config{}, errors.New("CONTROL_PLANE_PASSWORD must contain at least 6 bytes") } + if !filepath.IsAbs(cfg.credentialStoreDir) { + return config{}, errors.New("CREATORHUB_CREDENTIAL_STORE_DIR must be absolute") + } + cfg.credentialMasterKey, err = base64.StdEncoding.DecodeString(strings.TrimSpace(v.GetString("credential_master_key"))) + if err != nil || len(cfg.credentialMasterKey) != 32 { + return config{}, errors.New("CREATORHUB_CREDENTIAL_MASTER_KEY must be base64 for exactly 32 bytes") + } databaseURL, err := url.Parse(cfg.databaseURL) if err != nil || databaseURL.Host == "" || databaseURL.Path == "" || databaseURL.Path == "/" || (databaseURL.Scheme != "postgres" && databaseURL.Scheme != "postgresql") { @@ -193,6 +209,10 @@ func newHandler(webDirectory, username, password string) *fiber.App { } func newHandlerWithStores(webDirectory, username, password string, phaseAStore *phasea.Store, hubStore *hub.Store) *fiber.App { + return newHandlerWithCredentialBridge(webDirectory, username, password, phaseAStore, hubStore, nil) +} + +func newHandlerWithCredentialBridge(webDirectory, username, password string, phaseAStore *phasea.Store, hubStore *hub.Store, credentials phasea.CredentialBridge) *fiber.App { app := fiber.New(fiber.Config{ AppName: "CreatorHub control plane", BodyLimit: 1 << 20, @@ -209,7 +229,7 @@ func newHandlerWithStores(webDirectory, username, password string, phaseAStore * registerHub(app, hubStore) } if phaseAStore != nil { - registerPhaseA(app, phaseAStore, hubStore) + registerPhaseA(app, phaseAStore, hubStore, credentials) } app.Get("/*", spaHandler(webDirectory)) return app diff --git a/cmd/control-plane/main_test.go b/cmd/control-plane/main_test.go index f1fff45..654f305 100644 --- a/cmd/control-plane/main_test.go +++ b/cmd/control-plane/main_test.go @@ -86,6 +86,7 @@ func TestLoadConfigRequiresControlPlaneCredentials(t *testing.T) { 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 @@ -103,6 +104,17 @@ func TestLoadConfigPasswordLength(t *testing.T) { } } +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 TestControlPlaneAuthentication(t *testing.T) { logger := logrus.StandardLogger() previousOutput := logger.Out @@ -167,11 +179,31 @@ type controlPlaneRouteCase struct { 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) + registerPhaseA(app, nil, nil, nil) routes := controlPlaneRouteMatrix() assertControlPlaneRouteMatrix(t, app, routes) @@ -372,11 +404,11 @@ func TestOperatorNotificationFiltersAndRedacts(t *testing.T) { } } -func TestPhaseAAccountRequestRejectsSecretsAndUnknownFields(t *testing.T) { +func TestPhaseAAccountRequestRejectsUnknownFields(t *testing.T) { for name, body := range map[string]string{ - "raw password": `{"id":"account-a","platform":"mock","platform_account_key":"a","authorization_kind":"owned","credential_reference":{"id":"credential-a","provider":"os_keyring","key":"creatorhub/account-a"},"password":"secret"}`, - "nested secret": `{"id":"account-a","platform":"mock","platform_account_key":"a","authorization_kind":"owned","credential_reference":{"id":"credential-a","provider":"os_keyring","key":"creatorhub/account-a","secret":"secret"}}`, - "unknown field": `{"id":"account-a","platform":"mock","platform_account_key":"a","authorization_kind":"owned","credential_reference":{"id":"credential-a","provider":"os_keyring","key":"creatorhub/account-a"},"extra":true}`, + "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() @@ -441,52 +473,107 @@ func TestPhaseAAccountHTTPWorkflowRedactsSecrets(t *testing.T) { } app := fiber.New() app.Use(authenticate("operator", "unit-test-password")) - registerPhaseA(app, store, nil) - credentialKey := "creatorhub/phase-http-secret" - accountBody := `{"id":"account-http","platform":"mock","platform_account_key":"phase-http","authorization_kind":"owned","credential_reference":{"id":"credential-http","provider":"os_keyring","key":"` + credentialKey + `"}}` + 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(), credentialKey) { - t.Fatalf("%s %s leaked credential key: %s", method, path, 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-http" || account.RuntimeStatus != "paused" { + 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", strings.TrimSuffix(accountBody, "}")+`,"password":"`+credentialKey+`"}`, http.StatusBadRequest) + 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-http", "", 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'); - INSERT INTO environment_binding (id, account_id, browser_env_alias, network_exit_id) - VALUES ('binding-http', 'account-http', 'account-http', 'exit-http')`); err != nil { + VALUES ('exit-http', 'socks5', '127.0.0.1', 1080, 'healthy')`); err != nil { t.Fatal(err) } - request(http.MethodPost, "/api/phase-a/accounts/account-http/resume", "", http.StatusNoContent) - request(http.MethodPost, "/api/phase-a/accounts/account-http/pause", "", http.StatusNoContent) - request(http.MethodPost, "/api/phase-a/accounts/account-http/resume", "", http.StatusNoContent) - request(http.MethodPost, "/api/phase-a/accounts/account-http/revoke", "", http.StatusNoContent) - blocked := request(http.MethodPost, "/api/phase-a/accounts/account-http/resume", "", http.StatusConflict) + 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()) } diff --git a/cmd/control-plane/network.go b/cmd/control-plane/network.go index 7a8402c..fec5514 100644 --- a/cmd/control-plane/network.go +++ b/cmd/control-plane/network.go @@ -140,15 +140,18 @@ func resolveExitCredential(exit hub.NetworkExitAccess) (string, error) { if exit.CredentialReference == nil || exit.CredentialKey == "" { return "", errors.New("credential reference unavailable") } - digest := sha256.Sum256([]byte(exit.CredentialKey)) - name := "CREATORHUB_CREDENTIAL_" + strings.ToUpper(hex.EncodeToString(digest[:])) - value, ok := os.LookupEnv(name) + value, ok := os.LookupEnv(credentialEnvironmentName(exit.CredentialKey)) if !ok || value == "" { return "", errors.New("credential value unavailable") } return value, nil } +func credentialEnvironmentName(key string) string { + digest := sha256.Sum256([]byte(key)) + return "CREATORHUB_CREDENTIAL_" + strings.ToUpper(hex.EncodeToString(digest[:])) +} + type gatewayNetworkExit struct { Protocol string `json:"protocol"` Host string `json:"host"` diff --git a/cmd/control-plane/phasea.go b/cmd/control-plane/phasea.go index a94b279..74f2e6f 100644 --- a/cmd/control-plane/phasea.go +++ b/cmd/control-plane/phasea.go @@ -6,6 +6,7 @@ import ( "errors" "io" "strconv" + "strings" "time" "git.ipao.vip/rogee/creator-hub/internal/hub" @@ -14,15 +15,11 @@ import ( ) type accountRequest struct { - ID string `json:"id"` - Platform string `json:"platform"` - PlatformAccountKey string `json:"platform_account_key"` - AuthorizationKind string `json:"authorization_kind"` - CredentialReference struct { - ID string `json:"id"` - Provider string `json:"provider"` - Key string `json:"key"` - } `json:"credential_reference"` + Name string `json:"name"` + Platform string `json:"platform"` + PlatformAccountKey string `json:"platform_account_key"` + Tags []string `json:"tags"` + Cookies string `json:"cookies"` } type draftRequest struct { @@ -44,23 +41,33 @@ type taskVerificationRequest struct { Result string `json:"result"` } -func registerPhaseA(app *fiber.App, store *phasea.Store, runtimeStore runtimeStopStore) { +func registerPhaseA(app *fiber.App, store *phasea.Store, runtimeStore runtimeStopStore, credentials phasea.CredentialBridge) { app.Post("/api/phase-a/accounts", func(c fiber.Ctx) error { var input accountRequest if err := decodePhaseA(c, &input); err != nil { return phaseAError(c, err) } - accountID := input.ID - if accountID == "" { - accountID = phasea.NewAccountID() + tags := input.Tags + if tags == nil { + tags = []string{} } + for index := range tags { + tags[index] = strings.TrimSpace(tags[index]) + } + accountID := phasea.NewAccountID() account := phasea.Account{ - ID: accountID, Platform: input.Platform, PlatformAccountKey: input.PlatformAccountKey, - AuthorizationKind: input.AuthorizationKind, CredentialKey: input.CredentialReference.Key, - CredentialReference: phasea.CredentialReference{ID: input.CredentialReference.ID, Provider: input.CredentialReference.Provider}, + ID: accountID, Name: strings.TrimSpace(input.Name), Platform: strings.TrimSpace(input.Platform), + PlatformAccountKey: strings.TrimSpace(input.PlatformAccountKey), Tags: tags, Cookies: strings.TrimSpace(input.Cookies), + CredentialReference: phasea.CredentialReference{ID: accountID + "-cookies", Provider: "os_keyring"}, + CredentialKey: "creatorhub/" + accountID + "/cookies", AuthorizationStatus: "authorized", RuntimeStatus: "paused", Version: 1, } - if err := store.CreateAccount(c.Context(), account); err != nil { + if err := store.CreateAccount(c.Context(), account, credentials); err != nil { + if errors.Is(err, phasea.ErrAccountCreationUnknown) { + return c.Status(fiber.StatusServiceUnavailable).JSON(map[string]string{ + "error": "account creation result is unknown", "reason_code": "account_creation_result_unknown", "account_id": accountID, + }) + } return phaseAError(c, err) } return c.Status(fiber.StatusCreated).JSON(account) diff --git a/compose.yaml b/compose.yaml index e7827c7..b632e2d 100644 --- a/compose.yaml +++ b/compose.yaml @@ -5,9 +5,12 @@ services: DATABASE_URL: postgres://creatorhub@postgres/creatorhub?sslmode=disable CONTROL_PLANE_USERNAME: ${CONTROL_PLANE_USERNAME:?required} CONTROL_PLANE_PASSWORD: ${CONTROL_PLANE_PASSWORD:?required} + CREATORHUB_CREDENTIAL_MASTER_KEY: ${CREATORHUB_CREDENTIAL_MASTER_KEY:?required} ports: - "${CREATORHUB_PORT:-8080}:8080" read_only: true + volumes: + - creatorhub_credentials:/var/lib/creatorhub/credentials tmpfs: - /tmp:size=16m,noexec,nosuid,nodev cap_drop: [ALL] @@ -73,3 +76,4 @@ networks: name: creatorhub_control volumes: creatorhub_postgres: + creatorhub_credentials: diff --git a/docs/architecture/container-control.md b/docs/architecture/container-control.md index 41cd052..f10d46c 100644 --- a/docs/architecture/container-control.md +++ b/docs/architecture/container-control.md @@ -57,11 +57,11 @@ DOCKER_GID=$(stat -c %g /var/run/docker.sock) docker compose up --build ## 阶段 A 离线闭环 -`POST /api/phase-a/accounts` 接受 `{id, platform, platform_account_key, authorization_kind, credential_reference}`,只允许 OS Keyring/Secret Manager 引用,不接受秘密值;新账号默认 `paused`,`(platform, platform_account_key)` 全局唯一。`GET /api/phase-a/accounts[/:id]` 不返回引用 key;pause/revoke 会递增账号版本并将 queued 任务置为 `policy_hold`,只有具备 binding 和 healthy 出口的未撤销账号才能 resume。账号与浏览器环境通过一对一 `environment_binding` 关联,出口可复用;运行实例保留历史,并以 binding 和外部 runtime id 的部分唯一索引限制活动实例。 +`POST /api/phase-a/accounts` 只接受 `{name, platform, platform_account_key, tags, cookies}`;`platform` 限定为 `douyin`、`xiaohongshu`、`wechat-official`、`kuaishou`,`cookies` 必须是浏览器 Cookie Header 格式。控制面通过持久 provider bridge 安全写入凭据:部署侧 Secret Manager/OS Keyring 注入 32 字节主密钥,独立凭据卷只保存 AES-GCM 密文,数据库只记录凭据引用;外部 API 不返回 Cookies、provider 或 `reference_key`。数据库明确回滚时清理凭据,提交结果未知时保留凭据并返回 `account_creation_result_unknown`,不自动破坏可能已提交的账号。内部账号 ID 由服务端生成,新账号默认 `paused`,`(platform, platform_account_key)` 全局唯一。pause/revoke 会递增账号版本并将 queued 任务置为 `policy_hold`,只有具备 binding 和 healthy 出口的未撤销账号才能 resume。账号与浏览器环境通过一对一 `environment_binding` 关联,出口可复用;运行实例保留历史,并以 binding 和外部 runtime id 的部分唯一索引限制活动实例。 草稿经 `POST /api/phase-a/confirmations` 显式确认后才可投递到 `/api/phase-a/tasks`。任务由幂等键去重;`POST /api/phase-a/mock/execute` 使用 `FOR UPDATE SKIP LOCKED` 领取一分钟租约,执行前统一核对账号、草稿和确认版本。缺少确认或版本不一致会进入 `needs_confirmation`,暂停账号或 Mock 策略结果会进入 `policy_hold`,不确定结果与过期租约进入 `needs_confirmation`;这些状态都不会自动重试。`GET /api/phase-a/audit` 只导出账号、确认版本、尝试和结果等非秘密证据。 -启动时控制面先应用 Phase A v1,再由 Hub runner 顺序应用 v2 至 v12;每一步都在事务和 advisory lock 下前向执行。v3 保留旧表、列和历史记录,旧账号回填为 `platform=mock` 并暂停,仅账号 ID 与环境 alias 相同的记录自动建立 binding;v4 追加环境动作审计字段与索引,v5 清理持久 fingerprint 中的旧代理字段,v6 增加可重试的 runtime cleanup 状态,v7 为 runtime lease 增加 binding version 并回填可确定的既有记录,v8 至 v10 补齐 cleanup/runtime 的不可变 generation 与兼容约束,v11、v12 增加任务恢复状态并修复兼容约束。其余记录等待显式绑定。本阶段不提供破坏性自动回滚。 +启动时控制面先应用 Phase A v1,再由 Hub runner 顺序应用 v2 至 v14;每一步都在事务和 advisory lock 下前向执行。v3 保留旧表、列和历史记录,旧账号回填为 `platform=mock` 并暂停,仅账号 ID 与环境 alias 相同的记录自动建立 binding;v4 追加环境动作审计字段与索引,v5 清理持久 fingerprint 中的旧代理字段,v6 增加可重试的 runtime cleanup 状态,v7 为 runtime lease 增加 binding version 并回填可确定的既有记录,v8 至 v10 补齐 cleanup/runtime 的不可变 generation 与兼容约束,v11、v12 增加任务恢复状态并修复兼容约束,v13 增加账号名称和 TAGS;v14 仅前向修复旧 PR v13 的空数据 schema。若旧 v13 已产生空引用或明文 Cookies,v14 会在删除前阻断启动,必须先将凭据迁入 provider。其余记录等待显式绑定。本阶段不提供破坏性自动回滚。 `POST /api/network-exits` 只接受协议、主机、端口、已有 `credential_reference: {id}` 和预期出口身份;新出口为 `unchecked`,由 `POST /api/network-exits/:id/check` 经实际代理链路变为 `healthy` 或 `unhealthy`,`disable` 不可被检查重新启用。credential reference 的 `reference_key` 不出现在 API、日志或审计中;OS Keyring/Secret Manager bridge 在控制面进程启动前注入 `CREATORHUB_CREDENTIAL_`(大写十六进制),值为请求期解析的 `username:password`,控制面不持久化解析值。 diff --git a/docs/deployment.md b/docs/deployment.md index ac5f7bf..0cda0c6 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -32,6 +32,7 @@ docker info >/dev/null docker compose version : "${CONTROL_PLANE_USERNAME:?export CONTROL_PLANE_USERNAME in this shell}" : "${CONTROL_PLANE_PASSWORD:?export CONTROL_PLANE_PASSWORD in this shell}" +: "${CREATORHUB_CREDENTIAL_MASTER_KEY:?export CREATORHUB_CREDENTIAL_MASTER_KEY in this shell}" docker compose config --quiet ``` @@ -45,6 +46,7 @@ export CREATORHUB_PORT=8080 export GATEWAY_TOKEN="$(openssl rand -hex 24)" export CONTROL_PLANE_USERNAME=creatorhub export CONTROL_PLANE_PASSWORD="$(openssl rand -hex 24)" +export CREATORHUB_CREDENTIAL_MASTER_KEY="$(openssl rand -base64 32)" docker compose config --quiet docker compose up --detach --build @@ -74,13 +76,16 @@ curl --fail --silent --show-error \ docker compose exec -T postgres \ psql -U creatorhub -d creatorhub -tAc \ - 'SELECT 1 FROM schema_migration WHERE version = 12;' \ + 'SELECT 1 FROM schema_migration WHERE version = 14;' \ | grep -qx 1 docker compose ps ``` -健康检查应成功,浏览器列表接口应返回 JSON,迁移查询当前应输出 `1`,三个 Compose 服务应为运行状态。然后访问 ;修改过 `CREATORHUB_PORT` 时使用对应端口。 +健康检查应成功,浏览器列表接口应返回 JSON,迁移查询当前应输出 `1`,三个 Compose 服务应为运行状态。`CREATORHUB_CREDENTIAL_MASTER_KEY` 必须由部署侧 Secret Manager/OS Keyring 持久保存并在每次启动时注入同一值;账号凭据以 AES-GCM 密文写入独立 `creatorhub_credentials` 卷,轮换主密钥前必须先迁移已有凭据。然后访问 ;修改过 `CREATORHUB_PORT` 时使用对应端口。 + +若任何持久环境曾运行 PR 中间版本 `a37732c`,发布前先执行 +`SELECT to_regclass(current_schema() || '.account_credential_reconciliation');`。结果非空表示遗留了当前代码不再使用的非秘密对账表;不要手工改写 `schema_migration` 或直接删表。应在确认不再回滚到该中间版本并完成数据库备份后,以单独的前向迁移 v15 删除该表并先在备份副本演练;该清理不影响当前账号创建链路,但属于正式发布前检查项。 排障时读取结构化服务日志: @@ -97,7 +102,7 @@ docker compose logs --tail=200 creator-hub docker-gateway postgres 1. 登录后在「网关管理」注册 GATEWAY_TOKEN。 2. 在「镜像版本」添加并启用一个可拉取的 fingerprint-chromium 镜像。 3. 可选:在「网络出口」创建出口并点击「检测」,健康状态必须为「健康」;这里只填写凭据引用 ID,不填写密码、Cookie 或 token。留空则使用网关所在机器的网络出口直连。 -4. 在「社媒账号」创建平台为 mock 的账号;创建后默认暂停。 +4. 在「社媒账号」创建平台为抖音的账号;创建后默认暂停,后续仍可使用 Mock 执行器验证离线闭环。 5. 在「运行环境」选择该账号、镜像和可选的健康出口,使用正整数 Fingerprint Seed 创建环境;随后在账号详情点击「恢复账号」,再在「运行环境」点击「启动」。 6. 在账号详情创建文本草稿,点击「核对草稿」,勾选“我已核对当前账号、草稿内容、运行环境和固定出口”,依次执行「确认当前快照」→「保存确认」→「加入队列」。 7. Mock 执行器没有独立页面,使用下面的 POST /api/phase-a/mock/execute,再到「任务中心」和「审计」核对结果。 @@ -128,10 +133,7 @@ gateway_name="gw-${run_id}" image_version="${IMAGE_VERSION:-148.0.7778.215}" image_ref="${IMAGE_REF:-git.ipao.vip/rogee/fingerprint-chromium:${image_version}}" exit_protocol="${PROXY_PROTOCOL:-http}" -account_id="${run_id}-account" account_key="${run_id}-platform" -credential_id="${run_id}-credential" -credential_key="manual/${account_id}" env_alias="${run_id}-env" gateway_json="$(api -X POST "$BASE_URL/api/gateways" --data "$(jq -n \ @@ -152,9 +154,10 @@ checked_exit="$(api -X POST "$BASE_URL/api/network-exits/$exit_id/check")" jq -e '.health_status == "healthy"' <<<"$checked_exit" >/dev/null account_json="$(api -X POST "$BASE_URL/api/phase-a/accounts" --data "$(jq -n \ - --arg id "$account_id" --arg key "$account_key" --arg credential_id "$credential_id" --arg credential_key "$credential_key" \ - '{id:$id,platform:"mock",platform_account_key:$key,authorization_kind:"owned",credential_reference:{id:$credential_id,provider:"os_keyring",key:$credential_key}}')")" -jq -e --arg id "$account_id" '.id == $id and .authorization_status == "authorized" and .runtime_status == "paused"' <<<"$account_json" >/dev/null + --arg name "手工验证账号" --arg key "$account_key" --arg cookies "${ACCOUNT_COOKIES:-sessionid=manual-test}" \ + '{name:$name,platform:"douyin",platform_account_key:$key,tags:["manual"],cookies:$cookies}')")" +account_id="$(jq -er '.id' <<<"$account_json")" +jq -e '.authorization_status == "authorized" and .runtime_status == "paused"' <<<"$account_json" >/dev/null created_env="$(api -X POST "$BASE_URL/api/browsers" --data "$(jq -n \ --arg alias "$env_alias" --arg name "手工验证环境" --arg gateway "$gateway_name" \ @@ -227,8 +230,8 @@ docker compose stop | 现象 | 先检查 | 处理 | | --- | --- | --- | -| docker compose config 报 required | CONTROL_PLANE_USERNAME、CONTROL_PLANE_PASSWORD 是否在当前 shell 非空 | 重新 export 两个变量;用户名不能含冒号,密码至少 6 字节 | -| 手工验证脚本在 `:?` 处退出 | CREATORHUB_PORT、GATEWAY_TOKEN、CONTROL_PLANE_USERNAME、CONTROL_PLANE_PASSWORD 是否都已 export | 在启动 Compose 的同一个 shell 中 export 完整变量集;不要只依赖 `.env` 或 Compose 默认值 | +| docker compose config 报 required | CONTROL_PLANE_USERNAME、CONTROL_PLANE_PASSWORD、CREATORHUB_CREDENTIAL_MASTER_KEY 是否在当前 shell 非空 | 重新 export 三个变量;用户名不能含冒号,密码至少 6 字节,主密钥必须为 32 字节的 base64 | +| 手工验证脚本在 `:?` 处退出 | CREATORHUB_PORT、GATEWAY_TOKEN、CONTROL_PLANE_USERNAME、CONTROL_PLANE_PASSWORD、CREATORHUB_CREDENTIAL_MASTER_KEY 是否都已 export | 在启动 Compose 的同一个 shell 中 export 完整变量集;不要只依赖 `.env` 或 Compose 默认值 | | creator-hub 未启动 | docker compose ps、docker compose logs --tail=200 postgres docker-gateway creator-hub | 先确认 PostgreSQL 与网关 health 为 healthy;网关需能访问 /var/run/docker.sock,DOCKER_GID 使用 stat -c '%g' /var/run/docker.sock 的实际值 | | API 返回 401 | curl 是否带 --user CONTROL_PLANE_USERNAME:CONTROL_PLANE_PASSWORD | /healthz 不需要认证,其余 /api/* 需要控制面 Basic Auth | | /api/browsers 返回 503 或网关不可用 | 网关注册的 Endpoint、令牌与 Compose 的 GATEWAY_TOKEN | Endpoint 在 Compose 网络内应为 | @@ -251,6 +254,7 @@ Compose 部署时通常只需设置以下宿主机变量: | `GATEWAY_TOKEN` | `dev-creatorhub-gateway-token` | Compose 未提供变量时的默认值;本说明要求显式 export 随机值,并同步填入网关注册表单 | | `CONTROL_PLANE_USERNAME` | 无(必填) | 控制面唯一用户;不能包含冒号 | | `CONTROL_PLANE_PASSWORD` | 无(必填) | 控制面密码,至少 6 字节;使用随机值 | +| `CREATORHUB_CREDENTIAL_MASTER_KEY` | 无(必填) | 32 字节 base64;由部署 Secret Manager/OS Keyring 持久注入,重启后必须保持一致 | 服务本身支持并校验以下环境变量;`compose.yaml` 会在控制面凭据缺失或为空时拒绝渲染。下表中的 Compose 默认值不由手工验证脚本隐式读取;手工验证沿用上文的显式 export 要求: @@ -262,6 +266,8 @@ Compose 部署时通常只需设置以下宿主机变量: | `creator-hub` | `LOG_LEVEL` | 默认 `info` | | `creator-hub` | `CONTROL_PLANE_USERNAME` | 必填;HTTP Basic Auth 用户名 | | `creator-hub` | `CONTROL_PLANE_PASSWORD` | 必填且至少 6 字节;不会写入日志或响应 | +| `creator-hub` | `CREATORHUB_CREDENTIAL_MASTER_KEY` | 必填;解密独立凭据卷,不写入数据库、日志或响应 | +| `creator-hub` | `CREATORHUB_CREDENTIAL_STORE_DIR` | 默认 `/var/lib/creatorhub/credentials`;必须为绝对路径且持久可写 | | `docker-gateway` | `LISTEN_ADDR` | 默认 `:8081` | | `docker-gateway` | `DOCKER_SOCKET` | 默认值和 Compose 挂载均固定为 `/var/run/docker.sock`;不能只覆盖环境变量 | | `docker-gateway` | `BROWSER_NETWORK` | `creatorhub_browser` | diff --git a/internal/hub/migration_test.go b/internal/hub/migration_test.go index e61346c..5af85ee 100644 --- a/internal/hub/migration_test.go +++ b/internal/hub/migration_test.go @@ -8,6 +8,7 @@ import ( "net/url" "os" "slices" + "strings" "testing" "time" @@ -32,14 +33,54 @@ func TestUnifiedAccountMigration(t *testing.T) { t.Fatal(err) } defer db.Close() - assertDatabaseCount(t, db, `SELECT count(*) FROM schema_migration WHERE version BETWEEN 1 AND 12`, 12) + assertDatabaseCount(t, db, `SELECT count(*) FROM schema_migration WHERE version BETWEEN 1 AND 14`, 14) assertDatabaseCount(t, db, `SELECT count(*) FROM information_schema.tables WHERE table_schema = current_schema() AND table_name IN ('social_account', 'browser_env', 'network_exit', 'environment_binding')`, 4) + assertDatabaseCount(t, db, `SELECT count(*) FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = 'social_account' AND column_name IN ('name', 'tags')`, 2) + assertDatabaseCount(t, db, `SELECT count(*) FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = 'social_account' AND column_name = 'cookies'`, 0) assertDatabaseCount(t, db, `SELECT count(*) FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = 'environment_binding' AND column_name = 'runtime_cleanup_pending'`, 1) assertDatabaseCount(t, db, `SELECT count(*) FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = 'environment_binding' AND column_name LIKE 'runtime_cleanup_%'`, 5) store = openFullyMigratedHub(t, ctx, testURL) store.Close() - assertDatabaseCount(t, db, `SELECT count(*) FROM schema_migration WHERE version BETWEEN 1 AND 12`, 12) + assertDatabaseCount(t, db, `SELECT count(*) FROM schema_migration WHERE version BETWEEN 1 AND 14`, 14) + }) + + t.Run("legacy migration 013 without account secrets is repaired forward", func(t *testing.T) { + ctx := context.Background() + testURL := isolatedDatabaseURL(t, databaseURL) + db := openLegacyAccountCreationSchema(t, ctx, testURL) + defer db.Close() + + store, err := Open(ctx, testURL) + if err != nil { + t.Fatal(err) + } + store.Close() + assertDatabaseCount(t, db, `SELECT count(*) FROM schema_migration WHERE version = 14`, 1) + assertDatabaseCount(t, db, `SELECT count(*) FROM information_schema.columns WHERE table_schema = current_schema() + AND table_name = 'social_account' AND column_name = 'cookies'`, 0) + assertDatabaseCount(t, db, `SELECT count(*) FROM information_schema.columns WHERE table_schema = current_schema() + AND table_name = 'social_account' AND column_name = 'credential_reference_id' AND is_nullable = 'NO'`, 1) + }) + + t.Run("legacy migration 013 with account secrets blocks upgrade", func(t *testing.T) { + ctx := context.Background() + testURL := isolatedDatabaseURL(t, databaseURL) + db := openLegacyAccountCreationSchema(t, ctx, testURL) + defer db.Close() + if _, err := db.Exec(` + INSERT INTO social_account + (id, name, platform, platform_account_key, tags, cookies, authorization_kind, authorization_status, status) + VALUES ('legacy-secret', 'Legacy', 'douyin', 'legacy-secret', ARRAY[]::text[], + 'sessionid=migration-secret', 'owned', 'authorized', 'paused')`); err != nil { + t.Fatal(err) + } + + _, err := Open(ctx, testURL) + if err == nil || !strings.Contains(err.Error(), "apply hub schema migration 14") || strings.Contains(err.Error(), "migration-secret") { + t.Fatalf("unsafe legacy migration was not blocked safely: %v", err) + } + assertDatabaseCount(t, db, `SELECT count(*) FROM schema_migration WHERE version = 14`, 0) }) t.Run("previous migration 011 already applied", func(t *testing.T) { @@ -383,6 +424,52 @@ func isolatedDatabaseURL(t *testing.T, databaseURL string) string { return parsed.String() } +func openLegacyAccountCreationSchema(t *testing.T, ctx context.Context, databaseURL string) *sql.DB { + t.Helper() + phaseAStore, err := phasea.Open(ctx, databaseURL) + if err != nil { + t.Fatal(err) + } + phaseAStore.Close() + db, err := sql.Open("pgx", databaseURL) + if err != nil { + t.Fatal(err) + } + for _, migration := range []struct { + version int + sql string + }{{2, migration002}, {3, migration003}, {4, migration004}, {5, migration005}, {6, migration006}, {7, migration007}, + {8, migration008}, {9, migration009}, {10, migration010}, {11, migration011}, {12, migration012}} { + if _, err := db.Exec(migration.sql); err != nil { + t.Fatal(err) + } + if _, err := db.Exec(`INSERT INTO schema_migration (version) VALUES ($1)`, migration.version); err != nil { + t.Fatal(err) + } + } + if _, err := db.Exec(` + ALTER TABLE social_account + ADD COLUMN name text, + ADD COLUMN tags text[], + ADD COLUMN cookies text; + UPDATE social_account SET name = platform_account_key, tags = ARRAY[]::text[], cookies = ''; + ALTER TABLE social_account + ALTER COLUMN credential_reference_id DROP NOT NULL, + ALTER COLUMN name SET NOT NULL, + ALTER COLUMN name SET DEFAULT '未命名账号', + ALTER COLUMN tags SET NOT NULL, + ALTER COLUMN tags SET DEFAULT ARRAY[]::text[], + ALTER COLUMN cookies SET NOT NULL, + ALTER COLUMN cookies SET DEFAULT '', + ADD CONSTRAINT social_account_name_check CHECK (name = btrim(name) AND length(name) BETWEEN 1 AND 128), + ADD CONSTRAINT social_account_tags_check CHECK (cardinality(tags) <= 20), + ADD CONSTRAINT social_account_cookies_length_check CHECK (length(cookies) <= 8192); + INSERT INTO schema_migration (version) VALUES (13)`); err != nil { + t.Fatal(err) + } + return db +} + func assertDatabaseCount(t *testing.T, db *sql.DB, query string, want int, args ...any) { t.Helper() var got int diff --git a/internal/hub/migrations/013_account_creation.sql b/internal/hub/migrations/013_account_creation.sql new file mode 100644 index 0000000..524ee38 --- /dev/null +++ b/internal/hub/migrations/013_account_creation.sql @@ -0,0 +1,17 @@ +ALTER TABLE social_account + ADD COLUMN name text, + ADD COLUMN tags text[]; + +UPDATE social_account +SET name = platform_account_key, + tags = ARRAY[]::text[]; + +ALTER TABLE social_account + ALTER COLUMN name SET NOT NULL, + ALTER COLUMN name SET DEFAULT '未命名账号', + ALTER COLUMN tags SET NOT NULL, + ALTER COLUMN tags SET DEFAULT ARRAY[]::text[], + ADD CONSTRAINT social_account_name_check + CHECK (name = btrim(name) AND length(name) BETWEEN 1 AND 128), + ADD CONSTRAINT social_account_tags_check + CHECK (cardinality(tags) <= 20); diff --git a/internal/hub/migrations/014_account_creation_compatibility.sql b/internal/hub/migrations/014_account_creation_compatibility.sql new file mode 100644 index 0000000..3c73263 --- /dev/null +++ b/internal/hub/migrations/014_account_creation_compatibility.sql @@ -0,0 +1,20 @@ +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = current_schema() + AND table_name = 'social_account' + AND column_name = 'cookies' + ) THEN + IF EXISTS (SELECT 1 FROM social_account WHERE credential_reference_id IS NULL OR cookies <> '') THEN + RAISE EXCEPTION 'legacy migration 13 contains account credentials; migrate them to the credential provider before upgrade'; + END IF; + ALTER TABLE social_account DROP COLUMN cookies; + END IF; + + IF EXISTS (SELECT 1 FROM social_account WHERE credential_reference_id IS NULL) THEN + RAISE EXCEPTION 'social accounts without credential references block migration 14'; + END IF; + + ALTER TABLE social_account ALTER COLUMN credential_reference_id SET NOT NULL; +END $$; diff --git a/internal/hub/store.go b/internal/hub/store.go index 8296d68..1769b5a 100644 --- a/internal/hub/store.go +++ b/internal/hub/store.go @@ -54,6 +54,12 @@ var migration011 string //go:embed migrations/012_task_recovery_compatibility.sql var migration012 string +//go:embed migrations/013_account_creation.sql +var migration013 string + +//go:embed migrations/014_account_creation_compatibility.sql +var migration014 string + var ( ErrConflict = errors.New("resource conflicts with existing state") ErrInvalid = errors.New("invalid hub input") @@ -210,7 +216,7 @@ func (s *Store) migrate(ctx context.Context) error { for _, migration := range []struct { version int sql string - }{{2, migration002}, {3, migration003}, {4, migration004}, {5, migration005}, {6, migration006}, {7, migration007}, {8, migration008}, {9, migration009}, {10, migration010}, {11, migration011}, {12, migration012}} { + }{{2, migration002}, {3, migration003}, {4, migration004}, {5, migration005}, {6, migration006}, {7, migration007}, {8, migration008}, {9, migration009}, {10, migration010}, {11, migration011}, {12, migration012}, {13, migration013}, {14, migration014}} { var applied bool if err := tx.QueryRowContext(ctx, `SELECT EXISTS (SELECT 1 FROM schema_migration WHERE version = $1)`, migration.version).Scan(&applied); err != nil { return errors.New("read hub schema migration state") diff --git a/internal/phasea/store.go b/internal/phasea/store.go index 32a1f46..989129e 100644 --- a/internal/phasea/store.go +++ b/internal/phasea/store.go @@ -10,12 +10,16 @@ import ( "encoding/json" "errors" "fmt" + "net/http" "regexp" "strings" "time" + "unicode/utf8" "git.ipao.vip/rogee/creator-hub/internal/taskstate" + "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgtype" _ "github.com/jackc/pgx/v5/stdlib" ) @@ -23,36 +27,46 @@ import ( var migration001 string var ( - ErrConflict = errors.New("resource conflicts with existing state") - ErrInvalid = errors.New("invalid phase A input") - ErrNotFound = errors.New("resource not found") - idPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,31}$`) - refPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$`) - platformKeyPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,127}$`) - credentialKeyPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}/[A-Za-z0-9][A-Za-z0-9._/-]{0,126}$`) - eventPattern = regexp.MustCompile(`^[a-z0-9_]{1,64}$`) + ErrConflict = errors.New("resource conflicts with existing state") + ErrInvalid = errors.New("invalid phase A input") + ErrNotFound = errors.New("resource not found") + ErrAccountCreationUnknown = errors.New("account creation result is unknown") + idPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,31}$`) + refPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$`) + platformKeyPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,127}$`) + credentialKeyPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}/[A-Za-z0-9][A-Za-z0-9._/-]{0,126}$`) + eventPattern = regexp.MustCompile(`^[a-z0-9_]{1,64}$`) ) type Store struct { - db *sql.DB - notify taskstate.Notifier + db *sql.DB + notify taskstate.Notifier + accountCommit func(*sql.Tx) error } type Account struct { ID string `json:"id"` + Name string `json:"name"` Platform string `json:"platform"` PlatformAccountKey string `json:"platform_account_key"` - AuthorizationKind string `json:"authorization_kind"` + Tags []string `json:"tags"` + Cookies string `json:"-"` + CredentialReference CredentialReference `json:"-"` + CredentialKey string `json:"-"` AuthorizationStatus string `json:"authorization_status"` RuntimeStatus string `json:"runtime_status"` - CredentialReference CredentialReference `json:"credential_reference"` - CredentialKey string `json:"-"` Version int64 `json:"version"` } type CredentialReference struct { - ID string `json:"id"` - Provider string `json:"provider"` + ID string + Provider string +} + +type CredentialBridge interface { + // Store may fail after a partial write; Delete must be idempotent for compensation. + Store(context.Context, CredentialReference, string, string) error + Delete(context.Context, CredentialReference, string) error } type Draft struct { @@ -252,14 +266,27 @@ func (s *Store) migrate(ctx context.Context) error { return nil } -func (s *Store) CreateAccount(ctx context.Context, account Account) error { - if !idPattern.MatchString(account.ID) || !idPattern.MatchString(account.Platform) || - !platformKeyPattern.MatchString(account.PlatformAccountKey) || - (account.AuthorizationKind != "owned" && account.AuthorizationKind != "authorized") || - !refPattern.MatchString(account.CredentialReference.ID) || !credentialKeyPattern.MatchString(account.CredentialKey) || - (account.CredentialReference.Provider != "os_keyring" && account.CredentialReference.Provider != "secret_manager") { +func (s *Store) CreateAccount(ctx context.Context, account Account, credentials CredentialBridge) (err error) { + if account.Tags == nil { + account.Tags = []string{} + } + if !validAccount(account) || credentials == nil { return ErrInvalid } + if err := credentials.Store(ctx, account.CredentialReference, account.CredentialKey, account.Cookies); err != nil { + storeErr := errors.New("store account credential") + if cleanupErr := credentials.Delete(context.WithoutCancel(ctx), account.CredentialReference, account.CredentialKey); cleanupErr != nil { + storeErr = errors.Join(storeErr, errors.New("delete incomplete account credential")) + } + return storeErr + } + defer func() { + if err != nil && !errors.Is(err, ErrAccountCreationUnknown) { + if cleanupErr := credentials.Delete(context.WithoutCancel(ctx), account.CredentialReference, account.CredentialKey); cleanupErr != nil { + err = errors.Join(err, errors.New("delete orphaned account credential")) + } + } + }() tx, err := s.db.BeginTx(ctx, nil) if err != nil { return errors.New("begin account transaction") @@ -271,23 +298,42 @@ func (s *Store) CreateAccount(ctx context.Context, account Account) error { } if _, err := tx.ExecContext(ctx, ` INSERT INTO social_account - (id, credential_reference_id, platform, platform_account_key, authorization_kind, authorization_status, status) - VALUES ($1, $2, $3, $4, $5, 'authorized', 'paused')`, account.ID, account.CredentialReference.ID, - account.Platform, account.PlatformAccountKey, account.AuthorizationKind); err != nil { + (id, credential_reference_id, name, platform, platform_account_key, tags, authorization_kind, authorization_status, status) + VALUES ($1, $2, $3, $4, $5, $6, 'owned', 'authorized', 'paused')`, account.ID, account.CredentialReference.ID, + account.Name, account.Platform, account.PlatformAccountKey, account.Tags); err != nil { return publicDatabaseError(err) } if err := appendAudit(ctx, tx, "account_created", "account_created", account.ID, "", 0, "", "", map[string]string{"platform": account.Platform}); err != nil { return err } - return commit(tx) + var commitErr error + if s.accountCommit != nil { + commitErr = s.accountCommit(tx) + } else { + commitErr = tx.Commit() + } + if commitErr == nil { + return nil + } + if !commitKnownRolledBack(commitErr) { + return ErrAccountCreationUnknown + } + return errors.New("commit account transaction") +} + +func commitKnownRolledBack(err error) bool { + if errors.Is(err, pgx.ErrTxCommitRollback) { + return true + } + var postgresError *pgconn.PgError + return errors.As(err, &postgresError) } func (s *Store) ListAccounts(ctx context.Context) ([]Account, error) { rows, err := s.db.QueryContext(ctx, ` - SELECT account.id, account.platform, account.platform_account_key, account.authorization_kind, - account.authorization_status, account.status, reference.id, reference.provider, account.version + SELECT account.id, account.name, account.platform, account.platform_account_key, account.tags, + account.authorization_status, account.status, account.version FROM social_account account - JOIN credential_reference reference ON reference.id = account.credential_reference_id ORDER BY account.created_at, account.id`) if err != nil { return nil, errors.New("read accounts") @@ -309,10 +355,9 @@ func (s *Store) GetAccount(ctx context.Context, id string) (Account, error) { return Account{}, ErrInvalid } return scanAccount(s.db.QueryRowContext(ctx, ` - SELECT account.id, account.platform, account.platform_account_key, account.authorization_kind, - account.authorization_status, account.status, reference.id, reference.provider, account.version + SELECT account.id, account.name, account.platform, account.platform_account_key, account.tags, + account.authorization_status, account.status, account.version FROM social_account account - JOIN credential_reference reference ON reference.id = account.credential_reference_id WHERE account.id = $1`, id)) } @@ -320,14 +365,38 @@ type accountScanner interface{ Scan(...any) error } func scanAccount(row accountScanner) (Account, error) { var account Account - if err := row.Scan(&account.ID, &account.Platform, &account.PlatformAccountKey, &account.AuthorizationKind, - &account.AuthorizationStatus, &account.RuntimeStatus, &account.CredentialReference.ID, - &account.CredentialReference.Provider, &account.Version); err != nil { + var tags pgtype.FlatArray[string] + if err := row.Scan(&account.ID, &account.Name, &account.Platform, &account.PlatformAccountKey, pgtype.NewMap().SQLScanner(&tags), + &account.AuthorizationStatus, &account.RuntimeStatus, &account.Version); err != nil { return Account{}, rowError(err) } + account.Tags = []string(tags) return account, nil } +func validAccount(account Account) bool { + if !idPattern.MatchString(account.ID) || strings.TrimSpace(account.Name) != account.Name || account.Name == "" || + !utf8.ValidString(account.Name) || utf8.RuneCountInString(account.Name) > 128 || + !platformKeyPattern.MatchString(account.PlatformAccountKey) || len(account.Tags) > 20 || + len(account.Cookies) > 8192 || !refPattern.MatchString(account.CredentialReference.ID) || + !credentialKeyPattern.MatchString(account.CredentialKey) || + (account.CredentialReference.Provider != "os_keyring" && account.CredentialReference.Provider != "secret_manager") { + return false + } + switch account.Platform { + case "douyin", "xiaohongshu", "wechat-official", "kuaishou": + default: + return false + } + for _, tag := range account.Tags { + if tag == "" || strings.TrimSpace(tag) != tag || !utf8.ValidString(tag) || utf8.RuneCountInString(tag) > 32 { + return false + } + } + _, err := http.ParseCookie(account.Cookies) + return err == nil +} + func (s *Store) CreateDraft(ctx context.Context, draft Draft) error { if !refPattern.MatchString(draft.ID) || !idPattern.MatchString(draft.AccountID) || draft.Version < 1 || strings.TrimSpace(draft.Content) == "" { return ErrInvalid diff --git a/internal/phasea/store_test.go b/internal/phasea/store_test.go index 6c5e130..e60ed5b 100644 --- a/internal/phasea/store_test.go +++ b/internal/phasea/store_test.go @@ -2,9 +2,11 @@ package phasea import ( "context" + "database/sql" "encoding/json" "errors" "fmt" + "io" "os" "path/filepath" "strings" @@ -14,8 +16,46 @@ import ( "time" "git.ipao.vip/rogee/creator-hub/internal/taskstate" + "github.com/jackc/pgx/v5" ) +type testCredentialBridge struct { + values map[string]string + storeErr error +} + +func (bridge *testCredentialBridge) Store(ctx context.Context, _ CredentialReference, key, value string) error { + if err := ctx.Err(); err != nil { + return err + } + bridge.values[key] = value + return bridge.storeErr +} + +func TestCreateAccountCompensatesPartialCredentialStore(t *testing.T) { + credentials := &testCredentialBridge{values: map[string]string{}, storeErr: errors.New("provider failed after write")} + account := Account{ + ID: "account-partial-store", Name: "Partial Store", Platform: "douyin", PlatformAccountKey: "partial-store", + Tags: []string{}, Cookies: "sessionid=value", + CredentialReference: CredentialReference{ID: "account-partial-store-cookies", Provider: "os_keyring"}, + CredentialKey: "creatorhub/account-partial-store/cookies", + } + if err := (&Store{}).CreateAccount(context.Background(), account, credentials); err == nil { + t.Fatal("partial credential store returned success") + } + if _, ok := credentials.values[account.CredentialKey]; ok { + t.Fatal("partial credential store left an orphan") + } +} + +func (bridge *testCredentialBridge) Delete(ctx context.Context, _ CredentialReference, key string) error { + if err := ctx.Err(); err != nil { + return err + } + delete(bridge.values, key) + return nil +} + func TestNewAccountIDIsStoreValidAndUnique(t *testing.T) { first, second := NewAccountID(), NewAccountID() if first == second || !idPattern.MatchString(first) || !idPattern.MatchString(second) { @@ -26,30 +66,35 @@ func TestNewAccountIDIsStoreValidAndUnique(t *testing.T) { func TestValidationRejectsInvalidInputsBeforePersistence(t *testing.T) { store := &Store{} valid := Account{ - ID: "account-a", Platform: "mock", PlatformAccountKey: "platform-a", AuthorizationKind: "owned", - CredentialReference: CredentialReference{ID: "credential-a", Provider: "os_keyring"}, CredentialKey: "creatorhub/account-a", + ID: "account-a", Name: "账号 A", Platform: "douyin", PlatformAccountKey: "platform-a", + Tags: []string{"主账号"}, Cookies: "sessionid=value; token=second", + CredentialReference: CredentialReference{ID: "account-a-cookies", Provider: "os_keyring"}, + CredentialKey: "creatorhub/account-a/cookies", + } + credentials := &testCredentialBridge{values: map[string]string{}} + for _, platform := range []string{"douyin", "xiaohongshu", "wechat-official", "kuaishou"} { + account := valid + account.Platform = platform + if !validAccount(account) { + t.Fatalf("supported platform rejected: %s", platform) + } } for name, mutate := range map[string]func(*Account){ "id": func(account *Account) { account.ID = "INVALID" }, - "platform": func(account *Account) { account.Platform = "bad platform" }, + "name": func(account *Account) { account.Name = " " }, + "platform": func(account *Account) { account.Platform = "mock" }, "platform account key": func(account *Account) { account.PlatformAccountKey = "secret value" }, - "authorization kind": func(account *Account) { account.AuthorizationKind = "borrowed" }, - "credential provider": func(account *Account) { account.CredentialReference.Provider = "plain_text" }, + "tag": func(account *Account) { account.Tags = []string{""} }, + "cookie header": func(account *Account) { account.Cookies = "sessionid" }, } { t.Run(name, func(t *testing.T) { account := valid mutate(&account) - if err := store.CreateAccount(context.Background(), account); !errors.Is(err, ErrInvalid) { + if err := store.CreateAccount(context.Background(), account, credentials); !errors.Is(err, ErrInvalid) { t.Fatalf("expected invalid account, got %v", err) } }) } - if err := store.CreateAccount(context.Background(), Account{ - ID: "account-a", Platform: "mock", PlatformAccountKey: "platform-a", AuthorizationKind: "owned", - CredentialReference: CredentialReference{ID: "credential-a", Provider: "os_keyring"}, CredentialKey: "raw-value", - }); !errors.Is(err, ErrInvalid) { - t.Fatalf("expected non-reference credential value to be rejected, got %v", err) - } if _, _, err := store.Enqueue(context.Background(), Task{ID: "task-a"}); !errors.Is(err, ErrInvalid) { t.Fatalf("expected invalid task, got %v", err) } @@ -125,27 +170,41 @@ func TestPhaseAOfflineWorkflow(t *testing.T) { } accounts := []Account{ - {ID: "account-a", Platform: "mock", PlatformAccountKey: "platform-a", AuthorizationKind: "owned", CredentialReference: CredentialReference{ID: "credential-a", Provider: "os_keyring"}, CredentialKey: "creatorhub/account-a"}, - {ID: "account-b", Platform: "mock", PlatformAccountKey: "platform-b", AuthorizationKind: "authorized", CredentialReference: CredentialReference{ID: "credential-b", Provider: "os_keyring"}, CredentialKey: "creatorhub/account-b"}, + {ID: "account-a", Name: "账号 A", Platform: "douyin", PlatformAccountKey: "platform-a", Tags: []string{"主账号"}, Cookies: "sessionid=a", + CredentialReference: CredentialReference{ID: "account-a-cookies", Provider: "os_keyring"}, CredentialKey: "creatorhub/account-a/cookies"}, + {ID: "account-b", Name: "账号 B", Platform: "douyin", PlatformAccountKey: "platform-b", Tags: []string{}, Cookies: "sessionid=b", + CredentialReference: CredentialReference{ID: "account-b-cookies", Provider: "os_keyring"}, CredentialKey: "creatorhub/account-b/cookies"}, } + credentials := &testCredentialBridge{values: map[string]string{}} for _, account := range accounts { - if err := store.CreateAccount(ctx, account); err != nil { + if err := store.CreateAccount(ctx, account, credentials); err != nil { t.Fatal(err) } } createdAccount, err := store.GetAccount(ctx, "account-a") - if err != nil || createdAccount.RuntimeStatus != "paused" || createdAccount.Version != 1 { + if err != nil || createdAccount.RuntimeStatus != "paused" || createdAccount.Version != 1 || + len(createdAccount.Tags) != 1 || createdAccount.Tags[0] != "主账号" { t.Fatalf("new account must start paused: %#v %v", createdAccount, err) } + if credentials.values["creatorhub/account-a/cookies"] != "sessionid=a" || + credentials.values["creatorhub/account-b/cookies"] != "sessionid=b" { + t.Fatalf("cookies were not stored in the credential bridge: %#v", credentials.values) + } + assertCount(t, store, `SELECT count(*) FROM credential_reference WHERE id IN ('account-a-cookies', 'account-b-cookies')`, 2) + assertCount(t, store, `SELECT count(*) FROM information_schema.columns WHERE table_schema = current_schema() + AND table_name = 'social_account' AND column_name = 'cookies'`, 0) if err := store.ResumeAccount(ctx, "account-a"); !errors.Is(err, ErrConflict) { t.Fatalf("account without a healthy binding must not resume: %v", err) } if err := store.CreateAccount(ctx, Account{ - ID: "account-c", Platform: "mock", PlatformAccountKey: "platform-a", AuthorizationKind: "owned", - CredentialReference: CredentialReference{ID: "credential-c", Provider: "os_keyring"}, CredentialKey: "creatorhub/account-c", - }); !errors.Is(err, ErrConflict) { + ID: "account-c", Name: "账号 C", Platform: "douyin", PlatformAccountKey: "platform-a", Tags: []string{}, Cookies: "sessionid=c", + CredentialReference: CredentialReference{ID: "account-c-cookies", Provider: "os_keyring"}, CredentialKey: "creatorhub/account-c/cookies", + }, credentials); !errors.Is(err, ErrConflict) { t.Fatalf("expected platform accounts to be unique, got %v", err) } + if _, ok := credentials.values["creatorhub/account-c/cookies"]; ok { + t.Fatal("credential bridge retained a secret after database rejection") + } if _, err := store.db.ExecContext(ctx, ` INSERT INTO gateway (name, endpoint, token) VALUES ('test-gateway', 'http://127.0.0.1:8081', 'phase-a-test-gateway-token')`); err != nil { t.Fatal(err) @@ -175,16 +234,16 @@ func TestPhaseAOfflineWorkflow(t *testing.T) { t.Fatalf("unexpected resumed account: %#v %v", accountA, err) } exportedAccount, _ := json.Marshal(accountA) - if strings.Contains(string(exportedAccount), "creatorhub/account-a") || strings.Contains(string(exportedAccount), "reference_key") { - t.Fatalf("account response leaked credential key: %s", exportedAccount) + if strings.Contains(string(exportedAccount), "sessionid=a") || strings.Contains(string(exportedAccount), "cookies") { + t.Fatalf("account response leaked cookies: %s", exportedAccount) } listedAccounts, err := store.ListAccounts(ctx) - if err != nil || len(listedAccounts) != 2 { + if err != nil || len(listedAccounts) != 2 || len(listedAccounts[0].Tags) != 1 || len(listedAccounts[1].Tags) != 0 { t.Fatalf("unexpected account list: %#v %v", listedAccounts, err) } accountB, err := store.GetAccount(ctx, "account-b") - if err != nil { - t.Fatal(err) + if err != nil || len(accountB.Tags) != 0 { + t.Fatalf("empty tags did not round-trip: %#v err=%v", accountB, err) } if _, err := store.db.ExecContext(ctx, ` @@ -666,6 +725,69 @@ func TestPhaseAOfflineWorkflow(t *testing.T) { } } +func TestAccountCredentialCommitResult(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() + store, err := Open(ctx, databaseURL) + if err != nil { + t.Fatal(err) + } + applyHubMigrationsForPhaseATest(t, store) + if _, err := store.db.ExecContext(ctx, ` + TRUNCATE audit_event, execution_attempt, operation_task, confirmation, content_draft, + runtime_instance, environment_binding, network_exit, social_account, credential_reference, + browser_env, browser_image, gateway RESTART IDENTITY CASCADE`); err != nil { + t.Fatal(err) + } + credentials := &testCredentialBridge{values: map[string]string{}} + account := func(id, platformKey string) Account { + return Account{ID: id, Name: id, Platform: "douyin", PlatformAccountKey: platformKey, Tags: []string{}, Cookies: "sessionid=" + id, + CredentialReference: CredentialReference{ID: id + "-cookies", Provider: "os_keyring"}, CredentialKey: "creatorhub/" + id + "/cookies"} + } + + store.accountCommit = func(tx *sql.Tx) error { + if err := tx.Commit(); err != nil { + return err + } + return io.ErrUnexpectedEOF + } + committed := account("account-committed", "platform-committed") + if err := store.CreateAccount(ctx, committed, credentials); !errors.Is(err, ErrAccountCreationUnknown) { + t.Fatalf("ambiguous commit did not return unknown: %v", err) + } + if credentials.values[committed.CredentialKey] == "" { + t.Fatal("committed unknown result deleted its credential") + } + assertCount(t, store, `SELECT count(*) FROM social_account WHERE id = $1`, 1, committed.ID) + + store.accountCommit = func(tx *sql.Tx) error { + _ = tx.Rollback() + return pgx.ErrTxCommitRollback + } + rolledBack := account("account-rolled-back", "platform-rolled-back") + if err := store.CreateAccount(ctx, rolledBack, credentials); err == nil || errors.Is(err, ErrAccountCreationUnknown) { + t.Fatalf("known rollback did not return a known failure: %v", err) + } + if _, ok := credentials.values[rolledBack.CredentialKey]; ok { + t.Fatal("known rollback retained its credential") + } + + store.accountCommit = func(tx *sql.Tx) error { + _ = tx.Rollback() + return io.ErrUnexpectedEOF + } + ambiguousRollback := account("account-ambiguous", "platform-ambiguous") + if err := store.CreateAccount(ctx, ambiguousRollback, credentials); !errors.Is(err, ErrAccountCreationUnknown) { + t.Fatalf("transport error did not preserve an unknown result: %v", err) + } + if credentials.values[ambiguousRollback.CredentialKey] == "" { + t.Fatal("commit-unknown deleted the credential") + } +} + func createApprovedDraft(t *testing.T, store *Store, accountID string, accountVersion int64, draftID, confirmationID string) { t.Helper() ctx := context.Background() @@ -790,7 +912,7 @@ func applyHubMigrationsForPhaseATest(t *testing.T, store *Store) { }{{2, "002_hub.sql"}, {3, "003_unified_accounts.sql"}, {4, "004_environment_actions.sql"}, {5, "005_sanitize_legacy_proxy.sql"}, {6, "006_runtime_cleanup.sql"}, {7, "007_runtime_binding_version.sql"}, {8, "008_runtime_cleanup_generation.sql"}, {9, "009_runtime_cleanup_compatibility.sql"}, {10, "010_runtime_network_generation.sql"}, {11, "011_task_recovery.sql"}, - {12, "012_task_recovery_compatibility.sql"}} { + {12, "012_task_recovery_compatibility.sql"}, {13, "013_account_creation.sql"}, {14, "014_account_creation_compatibility.sql"}} { var applied bool if err := store.db.QueryRow(`SELECT EXISTS (SELECT 1 FROM schema_migration WHERE version = $1)`, migrationFile.version).Scan(&applied); err != nil { t.Fatal(err) diff --git a/web/src/AccountList.jsx b/web/src/AccountList.jsx index 40d04f7..1f4d5ca 100644 --- a/web/src/AccountList.jsx +++ b/web/src/AccountList.jsx @@ -24,14 +24,20 @@ import PauseCircleOutlined from '@mui/icons-material/PauseCircleOutlined' import ReportProblemOutlined from '@mui/icons-material/ReportProblemOutlined' const emptyAccount = { + name: '', platform: '', platform_account_key: '', - authorization_kind: 'owned', - credential_id: '', - credential_provider: 'os_keyring', - credential_key: '', + tags: '', + cookies: '', } +const platformOptions = [ + ['douyin', '抖音'], + ['xiaohongshu', '小红书'], + ['wechat-official', '公众号'], + ['kuaishou', '快手'], +] + const wrapAnywhere = { overflowWrap: 'anywhere', minWidth: 0 } const reasonLabels = { @@ -85,36 +91,30 @@ function AccountState({ account, binding, bindingError }) { function AccountForm({ busy, onSubmit }) { const [form, setForm] = useState(emptyAccount) const update = (key, value) => setForm(current => ({ ...current, [key]: value })) - const valid = form.platform.trim() && form.platform_account_key.trim() && form.credential_id.trim() && form.credential_key.trim() + const valid = form.name.trim() && form.platform && form.platform_account_key.trim() && form.cookies.trim() async function submit(event) { event.preventDefault() if (!valid) return const created = await onSubmit({ + name: form.name.trim(), platform: form.platform.trim(), platform_account_key: form.platform_account_key.trim(), - authorization_kind: form.authorization_kind, - credential_reference: { - id: form.credential_id.trim(), - provider: form.credential_provider, - key: form.credential_key.trim(), - }, + tags: form.tags.split(/[,,]/).map(tag => tag.trim()).filter(Boolean), + cookies: form.cookies.trim(), }) if (created) setForm(emptyAccount) } return ( - update('platform', event.target.value)} slotProps={{ htmlInput: { pattern: '[a-z0-9][a-z0-9-]{0,31}', 'aria-label': '平台' } }} helperText="阶段 A 使用虚拟平台标识,如 mock" /> - update('platform_account_key', event.target.value)} slotProps={{ htmlInput: { 'aria-label': '平台账号标识', maxLength: 128 } }} /> - update('authorization_kind', event.target.value)} slotProps={{ htmlInput: { 'aria-label': '授权类型' } }}> - 自有账号已获授权 + update('name', event.target.value)} slotProps={{ htmlInput: { 'aria-label': '账号名称', maxLength: 128 } }} /> + update('platform', event.target.value)} slotProps={{ htmlInput: { 'aria-label': '平台类型' } }}> + {platformOptions.map(([value, label]) => {label})} - update('credential_id', event.target.value)} slotProps={{ htmlInput: { 'aria-label': '凭据引用 ID', maxLength: 128 } }} /> - update('credential_provider', event.target.value)} slotProps={{ htmlInput: { 'aria-label': '凭据引用来源' } }}> - 系统密钥环Secret Manager - - update('credential_key', event.target.value)} slotProps={{ htmlInput: { 'aria-label': '凭据引用键', maxLength: 192 } }} helperText="仅填写引用路径,不填写密码、Cookie 或 token" /> + update('platform_account_key', event.target.value)} slotProps={{ htmlInput: { 'aria-label': '账号 ID', maxLength: 128 } }} /> + update('tags', event.target.value)} slotProps={{ htmlInput: { 'aria-label': 'TAGS' } }} helperText="多个标签用逗号分隔" /> + update('cookies', event.target.value)} slotProps={{ htmlInput: { 'aria-label': 'Cookies', maxLength: 8192 } }} helperText="仅支持浏览器 Cookie Header 格式,如 name=value; token=value" /> ) @@ -127,7 +127,7 @@ function AccountTable({ accounts, bindings, bindingsError }) { 账号授权资源就绪度固定资源操作 {accounts.map(account => { const binding = bindings.get(account.id) - return {account.platform_account_key}{account.id} · {account.platform}{account.authorization_status === 'authorized' ? '已授权' : '已撤销'} · {account.runtime_status === 'active' ? '启用' : '暂停'}{bindingsError ? '状态未知' : (binding?.name || '—')}{bindingsError ? '环境数据不可用' : (binding?.network_exit_id || '未绑定出口')} + return {account.name}{account.platform_account_key} · {account.platform}{account.authorization_status === 'authorized' ? '已授权' : '已撤销'} · {account.runtime_status === 'active' ? '启用' : '暂停'}{bindingsError ? '状态未知' : (binding?.name || '—')}{bindingsError ? '环境数据不可用' : (binding?.network_exit_id || '未绑定出口')} })} @@ -137,7 +137,7 @@ function AccountTable({ accounts, bindings, bindingsError }) { function AccountCards({ accounts, bindings, bindingsError }) { return {accounts.map(account => { const binding = bindings.get(account.id) - return {account.platform_account_key}{account.id} · {account.platform}授权:{account.authorization_status === 'authorized' ? '已授权' : '已撤销'} · 账号:{account.runtime_status === 'active' ? '启用' : '暂停'}{bindingsError ? '运行环境与固定出口:状态未知' : `运行环境:${binding?.name || '未绑定'} · 固定出口:${binding?.network_exit_id || '未绑定'}`} + return {account.name}{account.platform_account_key} · {account.platform}授权:{account.authorization_status === 'authorized' ? '已授权' : '已撤销'} · 账号:{account.runtime_status === 'active' ? '启用' : '暂停'}{bindingsError ? '运行环境与固定出口:状态未知' : `运行环境:${binding?.name || '未绑定'} · 固定出口:${binding?.network_exit_id || '未绑定'}`} })} } @@ -150,7 +150,7 @@ export function AccountList() { const { data: browsers = [], error: browsersError, refetch: refetchBrowsers } = useGetList('browsers', undefined, { retry: false }) const bindings = useMemo(() => new Map(browsers.filter(browser => browser.account_id).map(browser => [browser.account_id, browser])), [browsers]) const needle = filter.trim().toLowerCase() - const visible = needle ? accounts.filter(account => `${account.platform} ${account.platform_account_key} ${account.id}`.toLowerCase().includes(needle)) : accounts + const visible = needle ? accounts.filter(account => `${account.name} ${account.platform} ${account.platform_account_key} ${(account.tags || []).join(' ')}`.toLowerCase().includes(needle)) : accounts useEffect(() => { document.title = 'CreatorHub · 社媒账号' }, []) @@ -162,7 +162,7 @@ export function AccountList() { setMessage({ severity: 'success', text: '账号已创建;绑定健康出口和运行环境后方可恢复。' }) return true } catch (reason) { - setMessage({ severity: 'error', text: actionError(reason, '平台账号标识或凭据引用已存在;表单内容已保留。') }) + setMessage({ severity: 'error', text: actionError(reason, '该平台的账号 ID 已存在;表单内容已保留。') }) return false } finally { setBusy(false) } } @@ -199,7 +199,7 @@ export function AccountDetail() { useEffect(() => { document.title = 'CreatorHub · 账号详情' }, []) async function act(action) { - if (action === 'pause' && !window.confirm(`暂停账号 ${account.platform_account_key}?待领取任务将进入 hold,恢复账号不会自动恢复既有 hold。`)) return + if (action === 'pause' && !window.confirm(`暂停账号 ${account.name}?待领取任务将进入 hold,恢复账号不会自动恢复既有 hold。`)) return setBusy(true); setMessage(null) try { await dataProvider.accountAction(account.id, action) @@ -228,12 +228,12 @@ export function AccountDetail() { if (error || !account) return {error?.message || '账号不存在'} return ( <> - {account.platform_account_key}{account.id} · {account.platform} + {account.name}{account.platform_account_key} · {account.platform} {message ? {message.text} : null} {browsersError ? refetchBrowsers()}>重试环境状态} sx={{ mb: 2.5 }}>{environmentError(browsersError)} : null} } sx={{ mb: 2.5 }}>暂停会把待领取任务置为 hold;恢复账号只恢复账号可用性,不会自动恢复既有 hold。 - 账号状态授权:{account.authorization_status === 'authorized' ? '已授权' : '已撤销'}({account.authorization_kind})运行:{account.runtime_status === 'active' ? '启用' : '暂停'} · 版本 {account.version}凭据引用:{account.credential_reference?.id} · {account.credential_reference?.provider} + 账号状态授权:{account.authorization_status === 'authorized' ? '已授权' : '已撤销'}运行:{account.runtime_status === 'active' ? '启用' : '暂停'} · 版本 {account.version}TAGS:{account.tags?.join('、') || '无'} 固定资源{browsersError ? 运行环境与网络出口状态未知;重试成功后再执行依赖资源状态的操作。 : binding ? <>运行环境:{binding.name}({binding.alias})网络出口:{binding.network_exit_id ? `${binding.network_exit_id} · ${binding.network_exit_health || '未知状态'}` : '当前机器直连'}绑定版本:{binding.binding_version}不可调度原因:{binding.schedule_block_reason ? (reasonLabels[binding.schedule_block_reason] || binding.schedule_block_reason) : '无'} : <>尚未绑定运行环境,因此不能恢复或排队。} diff --git a/web/src/AccountList.test.jsx b/web/src/AccountList.test.jsx index eb39c91..01352a7 100644 --- a/web/src/AccountList.test.jsx +++ b/web/src/AccountList.test.jsx @@ -6,7 +6,7 @@ import { AccountDetail, AccountList, accountReadiness } from './AccountList' afterEach(() => { cleanup(); vi.restoreAllMocks() }) -const account = { id: 'account-a', platform: 'mock', platform_account_key: 'shop-a', authorization_kind: 'owned', authorization_status: 'authorized', runtime_status: 'paused', version: 1, credential_reference: { id: 'credential-a', provider: 'os_keyring' } } +const account = { id: 'account-a', name: '店铺一号', platform: 'douyin', platform_account_key: 'shop-a', tags: ['主账号'], authorization_status: 'authorized', runtime_status: 'paused', version: 1 } const binding = { id: 'env-a', alias: 'env-a', name: '店铺环境', account_id: 'account-a', network_exit_id: 'exit-a', network_exit_health: 'healthy', binding_version: 1, schedule_status: 'blocked', schedule_block_reason: 'account_paused' } function provider(overrides = {}) { @@ -25,45 +25,26 @@ function renderList(dataProvider) { } describe('AccountList', () => { - it('keeps account input after a 409 and never submits a secret or client id', async () => { + it('offers four platforms and submits only the account creation fields', async () => { const dataProvider = provider({ create: vi.fn().mockRejectedValue(new HttpError('conflict', 409, { reason_code: 'duplicate_platform_account' })) }) renderList(dataProvider) - await screen.findAllByText('shop-a') + await screen.findAllByText('店铺一号') - fireEvent.change(screen.getByRole('textbox', { name: '平台' }), { target: { value: 'mock' } }) - fireEvent.change(screen.getByRole('textbox', { name: '平台账号标识' }), { target: { value: 'shop-new' } }) - fireEvent.change(screen.getByRole('textbox', { name: '凭据引用 ID' }), { target: { value: 'credential-new' } }) - fireEvent.change(screen.getByRole('textbox', { name: '凭据引用键' }), { target: { value: 'creatorhub/shop-new' } }) + fireEvent.change(screen.getByRole('textbox', { name: '账号名称' }), { target: { value: '新店铺' } }) + fireEvent.mouseDown(screen.getByRole('combobox', { name: '平台类型' })) + for (const platform of ['抖音', '小红书', '公众号', '快手']) expect(await screen.findByRole('option', { name: platform })).toBeTruthy() + fireEvent.click(screen.getByRole('option', { name: '抖音' })) + fireEvent.change(screen.getByRole('textbox', { name: '账号 ID' }), { target: { value: 'shop-new' } }) + fireEvent.change(screen.getByRole('textbox', { name: 'TAGS' }), { target: { value: '主账号,直播' } }) + fireEvent.change(screen.getByRole('textbox', { name: 'Cookies' }), { target: { value: 'sessionid=value; token=second' } }) fireEvent.click(screen.getByRole('button', { name: '创建账号' })) await waitFor(() => expect(dataProvider.create).toHaveBeenCalledWith('accounts', { data: { - platform: 'mock', platform_account_key: 'shop-new', authorization_kind: 'owned', - credential_reference: { id: 'credential-new', provider: 'os_keyring', key: 'creatorhub/shop-new' }, + name: '新店铺', platform: 'douyin', platform_account_key: 'shop-new', tags: ['主账号', '直播'], cookies: 'sessionid=value; token=second', } })) expect((await screen.findByRole('alert')).textContent).toContain('冲突(409)') - expect(screen.getByRole('textbox', { name: '平台账号标识' }).value).toBe('shop-new') - expect(screen.queryByLabelText(/密码|Cookie|token/i)).toBeNull() - }) - - it('accepts and submits a 192-character credential reference key', async () => { - const dataProvider = provider() - const credentialKey = `${'a'.repeat(64)}/${'b'.repeat(127)}` - renderList(dataProvider) - await screen.findAllByText('shop-a') - - fireEvent.change(screen.getByRole('textbox', { name: '平台' }), { target: { value: 'mock' } }) - fireEvent.change(screen.getByRole('textbox', { name: '平台账号标识' }), { target: { value: 'shop-new' } }) - fireEvent.change(screen.getByRole('textbox', { name: '凭据引用 ID' }), { target: { value: 'credential-new' } }) - const input = screen.getByRole('textbox', { name: '凭据引用键' }) - fireEvent.change(input, { target: { value: credentialKey } }) - fireEvent.click(screen.getByRole('button', { name: '创建账号' })) - - expect(credentialKey).toHaveLength(192) - expect(input.maxLength).toBe(192) - await waitFor(() => expect(dataProvider.create).toHaveBeenCalledWith('accounts', { data: { - platform: 'mock', platform_account_key: 'shop-new', authorization_kind: 'owned', - credential_reference: { id: 'credential-new', provider: 'os_keyring', key: credentialKey }, - } })) + expect(screen.getByRole('textbox', { name: '账号 ID' }).value).toBe('shop-new') + expect(screen.queryByLabelText(/授权类型|凭据引用/)).toBeNull() }) it('distinguishes resumable bindings from missing resources', () => { diff --git a/web/src/dataProvider.test.js b/web/src/dataProvider.test.js index aed9a53..7a62d4f 100644 --- a/web/src/dataProvider.test.js +++ b/web/src/dataProvider.test.js @@ -45,10 +45,11 @@ describe('dataProvider', () => { it('creates an account without a client-generated technical id', async () => { const fetch = vi.fn().mockResolvedValue(new Response('{"id":"account-generated"}', { status: 201 })) vi.stubGlobal('fetch', fetch) - const data = { platform: 'mock', platform_account_key: 'shop-a' } + const data = { name: '店铺一号', platform: 'douyin', platform_account_key: 'shop-a', tags: ['主账号'], cookies: 'sessionid=value' } await expect(dataProvider.create('accounts', { data })).resolves.toEqual({ data: { ...data, id: 'account-generated' } }) expect(fetch).toHaveBeenCalledWith('/api/phase-a/accounts', expect.objectContaining({ method: 'POST' })) + expect(JSON.parse(fetch.mock.calls[0][1].body)).toEqual(data) }) it('filters drafts by account through the server list contract', async () => { diff --git a/web/tests/responsive.e2e.js b/web/tests/responsive.e2e.js index 5f0049b..1d2d37c 100644 --- a/web/tests/responsive.e2e.js +++ b/web/tests/responsive.e2e.js @@ -97,8 +97,7 @@ test('enforces browser readiness before starting at 900px', async ({ page }) => } }) -test('submits a complete 192-character credential reference at 900px', async ({ page }) => { - const credentialKey = `${'a'.repeat(64)}/${'b'.repeat(127)}` +test('submits the minimal social-account form at 900px', async ({ page }) => { await page.route('**/api/phase-a/accounts', async route => { if (route.request().method() === 'POST') return route.fulfill({ json: { id: 'account-new' } }) return route.fulfill({ json: [] }) @@ -107,24 +106,23 @@ test('submits a complete 192-character credential reference at 900px', async ({ await page.setViewportSize({ width: 900, height: 900 }) await page.goto('/#/accounts') - await page.getByRole('textbox', { name: '平台', exact: true }).fill('mock') - await page.getByRole('textbox', { name: '平台账号标识', exact: true }).fill('shop-new') - await page.getByRole('textbox', { name: '凭据引用 ID', exact: true }).fill('credential-new') - const input = page.getByRole('textbox', { name: '凭据引用键', exact: true }) - await input.fill(credentialKey) - await expect(input).toHaveAttribute('maxlength', '192') - await expect(input).toHaveValue(credentialKey) + await page.getByRole('textbox', { name: '账号名称', exact: true }).fill('店铺一号') + await page.getByRole('combobox', { name: '平台类型', exact: true }).click() + await page.getByRole('option', { name: '抖音', exact: true }).click() + await page.getByRole('textbox', { name: '账号 ID', exact: true }).fill('shop-new') + await page.getByRole('textbox', { name: 'TAGS', exact: true }).fill('主账号,直播') + await page.getByRole('textbox', { name: 'Cookies', exact: true }).fill('sessionid=value; token=second') const request = page.waitForRequest(request => request.url().endsWith('/api/phase-a/accounts') && request.method() === 'POST') await page.getByRole('button', { name: '创建账号' }).click() - await expect((await request).postDataJSON()).toMatchObject({ credential_reference: { key: credentialKey } }) + expect((await request).postDataJSON()).toEqual({ name: '店铺一号', platform: 'douyin', platform_account_key: 'shop-new', tags: ['主账号', '直播'], cookies: 'sessionid=value; token=second' }) }) test('keeps account and network-exit pages inside 599px, 900px and 1280px', async ({ page }) => { const accountKey = 'a'.repeat(128) const credentialID = 'c'.repeat(128) const hostname = [63, 63, 63, 61].map(length => 'h'.repeat(length)).join('.') - const account = { id: 'account-a', platform: 'mock', platform_account_key: accountKey, authorization_kind: 'owned', authorization_status: 'authorized', runtime_status: 'paused', version: 1, credential_reference: { id: credentialID, provider: 'os_keyring' } } + const account = { id: 'account-a', name: '店铺一号', platform: 'douyin', platform_account_key: accountKey, tags: ['主账号'], authorization_status: 'authorized', runtime_status: 'paused', version: 1 } const binding = { alias: 'env-a', name: '店铺环境', account_id: 'account-a', network_exit_id: 'exit-a', network_exit_health: 'healthy', binding_version: 1, schedule_status: 'blocked', schedule_block_reason: 'account_paused' } const networkExit = { id: 'exit-a', protocol: 'socks5', host: hostname, port: 1080, health_status: 'healthy', credential_reference: { id: credentialID, provider: 'os_keyring' } } await page.route('**/api/phase-a/accounts', route => route.fulfill({ json: [account] })) @@ -144,7 +142,7 @@ test('keeps account and network-exit pages inside 599px, 900px and 1280px', asyn }) test('opens account detail at the phase A route', async ({ page }) => { - const account = { id: 'account-a', platform: 'mock', platform_account_key: 'shop-a', authorization_kind: 'owned', authorization_status: 'authorized', runtime_status: 'paused', version: 1, credential_reference: { id: 'credential-a', provider: 'os_keyring' } } + const account = { id: 'account-a', name: '店铺一号', platform: 'douyin', platform_account_key: 'shop-a', tags: [], authorization_status: 'authorized', runtime_status: 'paused', version: 1 } await page.route('**/api/phase-a/accounts', route => route.fulfill({ json: [account] })) await page.route('**/api/phase-a/accounts/account-a', route => route.fulfill({ json: account })) await page.route('**/api/browsers', route => route.fulfill({ json: [] })) @@ -154,12 +152,12 @@ test('opens account detail at the phase A route', async ({ page }) => { await page.getByRole('link', { name: '查看账号' }).click() await expect(page).toHaveURL(/#\/accounts\/account-a$/) - await expect(page.getByRole('heading', { level: 1, name: 'shop-a' })).toBeVisible() + await expect(page.getByRole('heading', { level: 1, name: '店铺一号' })).toBeVisible() expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(599) }) test('keeps draft review responsive and restores focus after dialog close and successful save', async ({ page }) => { - const account = { id: 'account-a', platform: 'mock', platform_account_key: 'shop-a', authorization_status: 'authorized', runtime_status: 'active', version: 2 } + const account = { id: 'account-a', name: '店铺一号', platform: 'douyin', platform_account_key: 'shop-a', tags: [], authorization_status: 'authorized', runtime_status: 'active', version: 2 } const draft = { id: 'draft-a', account_id: 'account-a', version: 1, content: 'x'.repeat(1000), account, versions: [{ id: 'draft-a', account_id: 'account-a', version: 1, content: 'x'.repeat(1000) }],