Files
creator-hub/internal/controlplane/app/app_test.go
T

583 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.MethodPost, "/api/phase-a/accounts/:id/revoke", "/api/phase-a/accounts/missing/revoke", "", 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
}