583 lines
21 KiB
Go
583 lines
21 KiB
Go
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
|
||
}
|