Files
creator-hub/internal/controlplane/app/app_test.go
T
rogee eab193007c
douyin-release-gate / verify (push) Failing after 3m43s
refactor(accounts): 移除「授权状态」概念——账号收敛为启用/暂停单一状态机
产品已收敛为自有账号管理,撤销授权在 UI 无入口、状态恒为 authorized,属废弃语义:
- migration 1045:social_account DROP authorization_status/revoked_at/authorization_kind
- 删除 revoke API 路由与 RevokeAccount/disableAccount 状态机分支(PauseAccount 独立)
- accountRunnable/就绪判定/采集过滤/登录校验删除 authorization_status 检查
- EnvironmentContext/AccountProfile 契约删字段;前端删「已授权/已撤销」展示与 readiness 分支
- 测试同步:revoke 流程/409 用例删除,seed 语句去列;dev 库测试遗留 revoked 账号待 UI 删除
2026-09-30 14:18:34 +08:00

582 lines
21 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package app
import (
"bufio"
"bytes"
"context"
"database/sql"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"os"
"os/exec"
"path/filepath"
"strings"
"syscall"
"testing"
"time"
"git.ipao.vip/rogee/creator-hub/internal/account"
accountsapi "git.ipao.vip/rogee/creator-hub/internal/controlplane/api/accounts"
"git.ipao.vip/rogee/creator-hub/internal/creator"
hub "git.ipao.vip/rogee/creator-hub/internal/environment"
"github.com/gofiber/fiber/v3"
"github.com/gofiber/fiber/v3/middleware/adaptor"
"github.com/sirupsen/logrus"
"github.com/spf13/cobra"
)
func TestExecuteContextStopsOnSIGTERM(t *testing.T) {
if os.Getenv("CREATORHUB_SIGTERM_HELPER") == "1" {
command := &cobra.Command{Use: "shutdown-test", RunE: func(command *cobra.Command, _ []string) error {
_, _ = os.Stdout.WriteString("ready\n")
<-command.Context().Done()
_, _ = os.Stdout.WriteString("stopped\n")
return nil
}}
if err := execute(command); err != nil {
t.Fatal(err)
}
return
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
process := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestExecuteContextStopsOnSIGTERM$")
process.Env = append(os.Environ(), "CREATORHUB_SIGTERM_HELPER=1")
stdout, err := process.StdoutPipe()
if err != nil {
t.Fatal(err)
}
if err := process.Start(); err != nil {
t.Fatal(err)
}
scanner := bufio.NewScanner(stdout)
if !scanner.Scan() || scanner.Text() != "ready" {
t.Fatalf("helper did not become ready: %q err=%v", scanner.Text(), scanner.Err())
}
if err := process.Process.Signal(syscall.SIGTERM); err != nil {
t.Fatal(err)
}
if !scanner.Scan() || scanner.Text() != "stopped" {
t.Fatalf("helper did not finish cleanup: %q err=%v", scanner.Text(), scanner.Err())
}
if err := process.Wait(); err != nil || ctx.Err() != nil {
t.Fatalf("process did not exit gracefully after SIGTERM: wait=%v context=%v", err, ctx.Err())
}
}
func TestLoadConfigRejectsInvalidDatabase(t *testing.T) {
t.Setenv("CONTROL_PLANE_USERNAME", "operator")
t.Setenv("CONTROL_PLANE_PASSWORD", "unit-test-password")
t.Setenv("DATABASE_URL", "file:///tmp/creatorhub.db")
if _, err := loadConfig(); err == nil {
t.Fatal("expected non-Postgres database URL to be rejected")
}
}
func TestLoadConfigRequiresControlPlaneCredentials(t *testing.T) {
t.Setenv("CONTROL_PLANE_USERNAME", "")
t.Setenv("CONTROL_PLANE_PASSWORD", "")
if _, err := loadConfig(); err == nil {
t.Fatal("expected missing control-plane credentials to be rejected")
}
}
func TestLoadConfigPasswordLength(t *testing.T) {
t.Setenv("CONTROL_PLANE_USERNAME", "operator")
t.Setenv("CREATORHUB_CREDENTIAL_MASTER_KEY", "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")
for _, test := range []struct {
password string
wantErr bool
}{
{"12345", true},
{"123456", false},
} {
t.Run(test.password, func(t *testing.T) {
t.Setenv("CONTROL_PLANE_PASSWORD", test.password)
_, err := loadConfig()
if (err != nil) != test.wantErr {
t.Fatalf("loadConfig() error = %v, wantErr %v", err, test.wantErr)
}
})
}
}
func TestLoadConfigRequiresCredentialMasterKey(t *testing.T) {
t.Setenv("CONTROL_PLANE_USERNAME", "operator")
t.Setenv("CONTROL_PLANE_PASSWORD", "unit-test-password")
for _, key := range []string{"", "not-base64", "c2hvcnQ="} {
t.Setenv("CREATORHUB_CREDENTIAL_MASTER_KEY", key)
if _, err := loadConfig(); err == nil {
t.Fatalf("accepted invalid credential master key %q", key)
}
}
}
func TestControlPlaneAuthentication(t *testing.T) {
logger := logrus.StandardLogger()
previousOutput := logger.Out
var logs bytes.Buffer
logrus.SetOutput(&logs)
t.Cleanup(func() { logrus.SetOutput(previousOutput) })
directory := t.TempDir()
if err := os.WriteFile(filepath.Join(directory, "index.html"), []byte("index"), 0o600); err != nil {
t.Fatal(err)
}
app := newHandler(directory, "operator", "unit-test-password", nil, nil, nil, nil)
health, err := app.Test(httptest.NewRequest(http.MethodGet, "/healthz", nil))
if err != nil || health.StatusCode != http.StatusNoContent {
t.Fatalf("health check must remain public: status=%d err=%v", health.StatusCode, err)
}
health.Body.Close()
ready, err := app.Test(httptest.NewRequest(http.MethodGet, "/readyz", nil))
if err != nil || ready.StatusCode != http.StatusServiceUnavailable {
t.Fatalf("readiness must fail without stores: status=%d err=%v", ready.StatusCode, err)
}
ready.Body.Close()
unknown := httptest.NewRequest(http.MethodGet, "/api/not-registered", nil)
unknown.SetBasicAuth("operator", "unit-test-password")
unknownResponse, err := app.Test(unknown)
if err != nil || unknownResponse.StatusCode != http.StatusNotFound {
t.Fatalf("unknown API must be JSON 404: status=%d err=%v", unknownResponse.StatusCode, err)
}
if got := unknownResponse.Header.Get("Content-Type"); !strings.HasPrefix(got, "application/json") {
t.Fatalf("unknown API content type=%q", got)
}
unknownResponse.Body.Close()
for _, path := range []string{
"/", "/api/phase-a/accounts", "/api/browsers", "/api/network-exits", "/api/phase-a/audit",
} {
request := httptest.NewRequest(http.MethodGet, path, nil)
response, err := app.Test(request)
if err != nil {
t.Fatal(err)
}
response.Body.Close()
if response.StatusCode != http.StatusUnauthorized || response.Header.Get("WWW-Authenticate") == "" {
t.Fatalf("GET %s was not protected: status=%d", path, response.StatusCode)
}
}
for name, test := range map[string]struct {
user, password string
want int
}{
"valid": {"operator", "unit-test-password", http.StatusOK},
"wrong user": {"other", "unit-test-password", http.StatusUnauthorized},
"wrong password": {"operator", "credential-must-not-be-logged", http.StatusUnauthorized},
} {
t.Run(name, func(t *testing.T) {
request := httptest.NewRequest(http.MethodGet, "/", nil)
request.SetBasicAuth(test.user, test.password)
response, err := app.Test(request)
if err != nil {
t.Fatal(err)
}
response.Body.Close()
if response.StatusCode != test.want {
t.Fatalf("status=%d want=%d", response.StatusCode, test.want)
}
})
}
if strings.Contains(logs.String(), "credential-must-not-be-logged") {
t.Fatalf("authentication credential reached logs: %s", logs.String())
}
}
type controlPlaneRouteCase struct {
method, pattern, path, body string
wantAuthenticatedStatus int
}
type testCredentialBridge struct {
values map[string]string
}
func (bridge *testCredentialBridge) Store(ctx context.Context, _ account.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, _ account.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)
accountsapi.Register(app, nil, nil, nil)
routes := controlPlaneRouteMatrix()
assertControlPlaneRouteMatrix(t, app, routes)
for _, route := range routes {
if response := do(app, route.method, route.path, route.body); response.Code != http.StatusUnauthorized {
t.Fatalf("registered route %s %s returned %d without credentials, want %d", route.method, route.path,
response.Code, http.StatusUnauthorized)
}
}
if response := do(app, http.MethodPost, "/api/not-registered", ""); response.Code != http.StatusUnauthorized {
t.Fatalf("unregistered route returned %d without credentials, want %d", response.Code, http.StatusUnauthorized)
}
}
func TestControlPlaneRegisteredRouteMatrix(t *testing.T) {
databaseURL := os.Getenv("CREATORHUB_POSTGRES_TEST_URL")
if databaseURL == "" {
t.Skip("set CREATORHUB_POSTGRES_TEST_URL to run control-plane route coverage")
}
databaseURL = isolatedControlPlaneDatabaseURL(t, databaseURL)
ctx := context.Background()
phaseAStore, err := account.Open(ctx, databaseURL)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = phaseAStore.Close() })
hubStore, err := hub.Open(ctx, databaseURL)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = hubStore.Close() })
webDirectory := t.TempDir()
if err := os.WriteFile(filepath.Join(webDirectory, "index.html"), []byte("index"), 0o600); err != nil {
t.Fatal(err)
}
app := newHandlerWithStores(webDirectory, "operator", "unit-test-password", phaseAStore, hubStore)
routes := controlPlaneRouteMatrix()
assertControlPlaneRouteMatrix(t, app, routes)
for _, route := range routes {
route := route
t.Run(route.method+" "+route.pattern, func(t *testing.T) {
if response := do(app, route.method, route.path, route.body); response.Code != http.StatusUnauthorized {
t.Fatalf("unauthenticated %s %s returned %d, want %d", route.method, route.path, response.Code, http.StatusUnauthorized)
}
response := do(app, route.method, route.path, route.body, "operator", "unit-test-password")
if response.Code != route.wantAuthenticatedStatus {
t.Fatalf("authenticated %s %s returned %d, want %d: %s", route.method, route.path, response.Code,
route.wantAuthenticatedStatus, response.Body.String())
}
})
}
for _, route := range []struct {
method, path string
want int
}{
{http.MethodPost, "/api/not-registered", http.StatusNotFound},
{http.MethodDelete, "/api/not-registered", http.StatusNotFound},
} {
t.Run("unregistered "+route.method, func(t *testing.T) {
if response := do(app, route.method, route.path, ""); response.Code != http.StatusUnauthorized {
t.Fatalf("unauthenticated unregistered %s %s returned %d, want %d", route.method, route.path, response.Code, http.StatusUnauthorized)
}
if response := do(app, route.method, route.path, "", "operator", "unit-test-password"); response.Code != route.want {
t.Fatalf("authenticated unregistered %s %s returned %d, want %d", route.method, route.path, response.Code, route.want)
}
})
}
}
func assertControlPlaneRouteMatrix(t *testing.T, app *fiber.App, routes []controlPlaneRouteCase) {
t.Helper()
expected := make(map[string]struct{}, len(routes))
for _, route := range routes {
key := controlPlaneRouteKey(route.method, route.pattern)
if _, exists := expected[key]; exists {
t.Fatalf("duplicate route matrix entry: %s", key)
}
expected[key] = struct{}{}
}
actual := registeredControlPlaneRoutes(app)
for key := range expected {
if _, exists := actual[key]; !exists {
t.Errorf("route matrix is missing registered route: %s", key)
}
}
for key := range actual {
if _, exists := expected[key]; !exists {
t.Errorf("registered API route is missing from route matrix: %s", key)
}
}
}
func controlPlaneRouteKey(method, path string) string { return method + " " + path }
func registeredControlPlaneRoutes(app *fiber.App) map[string]struct{} {
routes := map[string]struct{}{}
for _, methodRoutes := range app.Stack() {
for _, route := range methodRoutes {
if !strings.HasPrefix(route.Path, "/api/") {
continue
}
switch route.Method {
case http.MethodGet, http.MethodPost, http.MethodPut, http.MethodDelete:
routes[controlPlaneRouteKey(route.Method, route.Path)] = struct{}{}
}
}
}
return routes
}
func controlPlaneRouteMatrix() []controlPlaneRouteCase {
return []controlPlaneRouteCase{
{http.MethodGet, "/api/browsers", "/api/browsers", "", http.StatusOK},
{http.MethodGet, "/api/browsers/:alias", "/api/browsers/missing", "", http.StatusNotFound},
{http.MethodPost, "/api/browsers", "/api/browsers", "", http.StatusBadRequest},
{http.MethodPost, "/api/browsers/:alias/:action", "/api/browsers/missing/start", "", http.StatusNotFound},
{http.MethodDelete, "/api/browsers/:alias", "/api/browsers/missing", "", http.StatusNotFound},
{http.MethodGet, "/api/network-exits", "/api/network-exits", "", http.StatusOK},
{http.MethodGet, "/api/network-exits/:id", "/api/network-exits/missing", "", http.StatusNotFound},
{http.MethodPost, "/api/network-exits", "/api/network-exits", "", http.StatusBadRequest},
{http.MethodPost, "/api/network-exits/:id/check", "/api/network-exits/missing/check", "", http.StatusNotFound},
{http.MethodPost, "/api/network-exits/:id/disable", "/api/network-exits/missing/disable", "", http.StatusNotFound},
{http.MethodPut, "/api/network-exits/:id", "/api/network-exits/missing", "", http.StatusBadRequest},
{http.MethodPost, "/api/network-exits/:id/enable", "/api/network-exits/missing/enable", "", http.StatusNotFound},
{http.MethodDelete, "/api/network-exits/:id", "/api/network-exits/missing", "", http.StatusNotFound},
{http.MethodGet, "/api/gateways", "/api/gateways", "", http.StatusOK},
{http.MethodPost, "/api/gateways", "/api/gateways", "", http.StatusBadRequest},
{http.MethodPut, "/api/gateways/:name", "/api/gateways/missing", `{"name":"gw-missing","endpoint":"http://gw-missing:8081","token":""}`, http.StatusNotFound},
{http.MethodDelete, "/api/gateways/:name", "/api/gateways/missing", "", http.StatusNotFound},
{http.MethodPost, "/api/phase-a/accounts", "/api/phase-a/accounts", "", http.StatusBadRequest},
{http.MethodGet, "/api/phase-a/accounts", "/api/phase-a/accounts", "", http.StatusOK},
{http.MethodGet, "/api/phase-a/accounts/:id", "/api/phase-a/accounts/missing", "", http.StatusNotFound},
{http.MethodPost, "/api/phase-a/accounts/:id/pause", "/api/phase-a/accounts/missing/pause", "", http.StatusNotFound},
{http.MethodPost, "/api/phase-a/accounts/:id/environment", "/api/phase-a/accounts/missing/environment", "", http.StatusNotFound},
{http.MethodPost, "/api/phase-a/accounts/:id/start", "/api/phase-a/accounts/missing/start", "", http.StatusNotFound},
{http.MethodPost, "/api/phase-a/accounts/:id/resume", "/api/phase-a/accounts/missing/resume", "", http.StatusNotFound},
{http.MethodGet, "/api/phase-a/audit", "/api/phase-a/audit", "", http.StatusOK},
}
}
func TestSPAServesFileAndIndexFallback(t *testing.T) {
directory := t.TempDir()
files := map[string]string{
"index.html": "index",
"app.js": "asset",
"hello world.js": "space",
"应用.js": "unicode",
}
for name, body := range files {
if err := os.WriteFile(filepath.Join(directory, name), []byte(body), 0o600); err != nil {
t.Fatal(err)
}
}
if err := os.WriteFile(filepath.Join(directory, "..", "outside-secret.txt"), []byte("secret"), 0o600); err != nil {
t.Fatal(err)
}
app := newHandler(directory, "operator", "unit-test-password", nil, nil, nil, nil)
for path, want := range map[string]string{
"/app.js": "asset",
"/hello%20world.js": "space",
"/%E5%BA%94%E7%94%A8.js": "unicode",
"/%2e%2e%2foutside-secret.txt": "index",
"/dashboard": "index",
} {
request := httptest.NewRequest(http.MethodGet, path, nil)
request.SetBasicAuth("operator", "unit-test-password")
response, err := app.Test(request)
if err != nil {
t.Fatal(err)
}
body, readErr := io.ReadAll(response.Body)
response.Body.Close()
if readErr != nil || response.StatusCode != http.StatusOK || string(body) != want {
t.Fatalf("GET %s: status=%d body=%q err=%v", path, response.StatusCode, body, readErr)
}
}
}
func TestStartupLogIncludesListenAddress(t *testing.T) {
logger := logrus.StandardLogger()
previousOutput, previousFormatter, previousLevel := logger.Out, logger.Formatter, logger.Level
t.Cleanup(func() {
logrus.SetOutput(previousOutput)
logrus.SetFormatter(previousFormatter)
logrus.SetLevel(previousLevel)
})
var output bytes.Buffer
logrus.SetOutput(&output)
logrus.SetFormatter(&logrus.JSONFormatter{})
logrus.SetLevel(logrus.InfoLevel)
logStartup(config{listenAddr: ":8080"})
var entry map[string]any
if err := json.Unmarshal(output.Bytes(), &entry); err != nil {
t.Fatal(err)
}
if entry["listen_addr"] != ":8080" || entry["service"] != "control-plane" {
t.Fatalf("unexpected startup log: %#v", entry)
}
}
func do(appInstance *fiber.App, method, path, body string, credentials ...string) *httptest.ResponseRecorder {
response := httptest.NewRecorder()
var reader io.Reader
if body != "" {
reader = strings.NewReader(body)
}
request := httptest.NewRequest(method, path, reader)
if len(credentials) == 2 {
request.SetBasicAuth(credentials[0], credentials[1])
}
adaptor.FiberApp(appInstance).ServeHTTP(response, request)
return response
}
func isolatedControlPlaneDatabaseURL(t *testing.T, databaseURL string) string {
t.Helper()
admin, err := sql.Open("pgx", databaseURL)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = admin.Close() })
schema := fmt.Sprintf("creatorhub_hh803_%d", time.Now().UnixNano())
if _, err := admin.Exec("CREATE SCHEMA " + schema); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if _, err := admin.Exec("DROP SCHEMA " + schema + " CASCADE"); err != nil {
t.Errorf("drop test schema: %v", err)
}
})
parsed, err := url.Parse(databaseURL)
if err != nil {
t.Fatal(err)
}
query := parsed.Query()
query.Set("search_path", schema)
parsed.RawQuery = query.Encode()
return parsed.String()
}
// TestControlPlaneSystemEndpointsWithStores 覆盖 system 包健康/就绪真实链路:
// 三个 store 就绪时 readyz 204;creator schema 缺失时 503。
func TestControlPlaneSystemEndpointsWithStores(t *testing.T) {
databaseURL := os.Getenv("CREATORHUB_POSTGRES_TEST_URL")
if databaseURL == "" {
t.Skip("set CREATORHUB_POSTGRES_TEST_URL to run control-plane readiness coverage")
}
databaseURL = isolatedControlPlaneDatabaseURL(t, databaseURL)
ctx := context.Background()
phaseAStore, err := account.Open(ctx, databaseURL)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = phaseAStore.Close() })
hubStore, err := hub.Open(ctx, databaseURL)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = hubStore.Close() })
creatorStore, err := creator.Open(ctx, databaseURL)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = creatorStore.Close() })
directory := t.TempDir()
if err := os.WriteFile(filepath.Join(directory, "index.html"), []byte("index"), 0o600); err != nil {
t.Fatal(err)
}
app := newHandler(directory, "operator", "unit-test-password", phaseAStore, hubStore, nil, creatorStore)
health, err := app.Test(httptest.NewRequest(http.MethodGet, "/healthz", nil))
if err != nil || health.StatusCode != http.StatusNoContent {
t.Fatalf("healthz: status=%d err=%v", health.StatusCode, err)
}
health.Body.Close()
ready, err := app.Test(httptest.NewRequest(http.MethodGet, "/readyz", nil))
if err != nil || ready.StatusCode != http.StatusNoContent {
t.Fatalf("readyz with all stores: status=%d err=%v", ready.StatusCode, err)
}
ready.Body.Close()
creatorStore.Close()
ready, err = app.Test(httptest.NewRequest(http.MethodGet, "/readyz", nil))
if err != nil || ready.StatusCode != http.StatusServiceUnavailable {
t.Fatalf("readyz with closed store: status=%d err=%v", ready.StatusCode, err)
}
ready.Body.Close()
}
// TestControlPlaneLegacyAPIPathFallbacks 驱动 isControlPlaneAPIPath 与 SPA 回退包装的纯逻辑。
func TestControlPlaneLegacyAPIPathFallbacks(t *testing.T) {
for path, want := range map[string]bool{
"/api/browsers": true,
"/api/phase-a/accounts": true,
"/gateways/gw-1": true,
"/browsers/account-a": true,
"/network-exits": true,
"/phase-a": true,
"/dashboard/assets.js": false,
"/apifake": false,
"/": false,
} {
if got := isControlPlaneAPIPath(path); got != want {
t.Fatalf("isControlPlaneAPIPath(%q)=%v want %v", path, got, want)
}
}
}
// TestCreatorSecretBridgeDelegatesAndRejectsNil 覆盖凭据桥转发与空桥报错。
func TestCreatorSecretBridgeDelegatesAndRejectsNil(t *testing.T) {
var nilBridge creatorSecretBridge
ctx := context.Background()
if err := nilBridge.Store(ctx, creator.SecretReference{ID: "ref-1", Provider: "os_keyring"}, "key-1", "value-1"); err == nil {
t.Fatal("nil bridge must reject store")
}
if err := nilBridge.Delete(ctx, creator.SecretReference{ID: "ref-1", Provider: "os_keyring"}, "key-1"); err == nil {
t.Fatal("nil bridge must reject delete")
}
values := map[string]string{}
bridge := creatorSecretBridge{bridge: &accountCredentialBridgeStub{values: values}}
if err := bridge.Store(ctx, creator.SecretReference{ID: "ref-1", Provider: "os_keyring"}, "creatorhub/key", "value-1"); err != nil {
t.Fatalf("store: %v", err)
}
if values["creatorhub/key"] != "value-1" {
t.Fatalf("store did not delegate: %#v", values)
}
if err := bridge.Delete(ctx, creator.SecretReference{ID: "ref-1", Provider: "os_keyring"}, "creatorhub/key"); err != nil {
t.Fatalf("delete: %v", err)
}
if _, ok := values["creatorhub/key"]; ok {
t.Fatal("delete did not delegate")
}
}
type accountCredentialBridgeStub struct{ values map[string]string }
func (stub *accountCredentialBridgeStub) Store(_ context.Context, _ account.CredentialReference, key, value string) error {
stub.values[key] = value
return nil
}
func (stub *accountCredentialBridgeStub) Delete(_ context.Context, _ account.CredentialReference, key string) error {
delete(stub.values, key)
return nil
}