diff --git a/.gitignore b/.gitignore index ed79409..ed218a5 100644 --- a/.gitignore +++ b/.gitignore @@ -42,6 +42,7 @@ logs/ # ==================== 测试 ==================== /test/ *_test.go +!tests/acceptance/*_test.go *_test_config.go **/logtest/ *_test.yaml diff --git a/tests/acceptance/README.md b/tests/acceptance/README.md new file mode 100644 index 0000000..65a4eba --- /dev/null +++ b/tests/acceptance/README.md @@ -0,0 +1,41 @@ +# Acceptance Tests + +This package contains black-box Go tests for the staging API surface. + +## Local smoke run + +```bash +go test ./tests/acceptance/... -staging-url=https://tapi.hifast.biz +``` + +Without credentials, tests that require admin/user login or NodeSecret are skipped and unauthenticated error-path checks still run. + +## Required secrets + +The staging workflow should provide these values through GitHub Actions secrets or environment variables: + +- `ACCEPTANCE_ADMIN_EMAIL` +- `ACCEPTANCE_ADMIN_PASSWORD` +- `ACCEPTANCE_USER_EMAIL` +- `ACCEPTANCE_USER_PASSWORD` +- `STAGING_DB_HOST` +- `STAGING_DB_USER` +- `STAGING_DB_PASSWORD` +- `STAGING_DB_NAME` +- `STAGING_REDIS_ADDR` +- `STAGING_REDIS_PASSWORD` +- `STAGING_BASE_URL` + +`ACCEPTANCE_NODE_SECRET` is optional. When DB credentials are present, the loader reads NodeSecret from the staging `system` table instead of storing it as a long-lived secret. + +## Useful flags + +- `-staging-url`: API base URL. +- `-run-id`: run identifier used for QA-owned transient data. +- `-report-path`: write a JSON summary artifact. +- `-seed-sql`: seed SQL path, default `fixtures/seed.sql`. +- `-node-server-id` and `-node-protocol`: node smoke target. + +## Fixture rules + +Seed data must use a `qa_acceptance_` prefix or the provided `RUN_ID`. Cleanup must only delete QA-prefixed rows and Redis keys. Do not update or delete production-like staging data. diff --git a/tests/acceptance/acceptance_test.go b/tests/acceptance/acceptance_test.go new file mode 100644 index 0000000..f8590a6 --- /dev/null +++ b/tests/acceptance/acceptance_test.go @@ -0,0 +1,111 @@ +package acceptance + +import ( + "context" + "net/http" + "net/url" + "os" + "testing" +) + +func TestMain(m *testing.M) { + code := m.Run() + if err := writeReport(testConfig); err != nil && code == 0 { + code = 1 + } + os.Exit(code) +} + +func TestFixtureSeedLoader(t *testing.T) { + c := cfg(t) + ctx := context.Background() + newFixtureLoader(c).SeedIfConfigured(t, ctx) +} + +func TestPublicSmoke(t *testing.T) { + c := cfg(t) + ctx := context.Background() + client := newClient(c) + + t.Run("happy path user info", func(t *testing.T) { + token := loginUser(t, ctx, client, c) + envelope, raw, status, err := client.WithToken(token).Get(ctx, "/v1/public/user/info", nil) + if err != nil { + t.Fatalf("request failed: %v", err) + } + assertOK(t, envelope, raw, status) + recordCase(t, "public", http.MethodGet, "/v1/public/user/info", "200 envelope") + }) + + t.Run("error path missing auth", func(t *testing.T) { + envelope, raw, status, err := client.Get(ctx, "/v1/public/user/info", nil) + if err != nil { + t.Fatalf("request failed: %v", err) + } + assertAPIError(t, envelope, raw, status) + recordCase(t, "public", http.MethodGet, "/v1/public/user/info", "non-200 envelope without auth") + }) +} + +func TestAdminSmoke(t *testing.T) { + c := cfg(t) + ctx := context.Background() + client := newClient(c) + + t.Run("happy path current admin", func(t *testing.T) { + token := loginAdmin(t, ctx, client, c) + envelope, raw, status, err := client.WithToken(token).Get(ctx, "/v1/admin/user/current", nil) + if err != nil { + t.Fatalf("request failed: %v", err) + } + assertOK(t, envelope, raw, status) + recordCase(t, "admin", http.MethodGet, "/v1/admin/user/current", "200 envelope") + }) + + t.Run("error path missing auth", func(t *testing.T) { + envelope, raw, status, err := client.Get(ctx, "/v1/admin/user/current", nil) + if err != nil { + t.Fatalf("request failed: %v", err) + } + assertAPIError(t, envelope, raw, status) + recordCase(t, "admin", http.MethodGet, "/v1/admin/user/current", "non-200 envelope without auth") + }) +} + +func TestNodeSmoke(t *testing.T) { + c := cfg(t) + ctx := context.Background() + client := newClient(c) + loader := newFixtureLoader(c) + + t.Run("happy path server config", func(t *testing.T) { + secret, err := loader.NodeSecret(ctx) + if err != nil { + t.Fatalf("load node secret: %v", err) + } + secret = requireSecret(t, "node secret", secret) + query := url.Values{} + query.Set("secret_key", secret) + query.Set("protocols", c.NodeProtocol) + envelope, raw, status, err := client.Get(ctx, "/v2/server/"+c.NodeServerID, query) + if err != nil { + t.Fatalf("request failed: %v", err) + } + assertOK(t, envelope, raw, status) + recordCase(t, "node", http.MethodGet, "/v2/server/{server_id}", "200 envelope") + }) + + t.Run("error path invalid secret", func(t *testing.T) { + query := url.Values{} + query.Set("secret_key", "invalid-"+c.RunID) + query.Set("protocols", c.NodeProtocol) + _, raw, status, err := client.Get(ctx, "/v1/server/config", query) + if err != nil { + t.Fatalf("request failed: %v", err) + } + if status != http.StatusForbidden { + t.Fatalf("http status = %d, want 403; body=%s", status, sanitizeBody(raw)) + } + recordCase(t, "node", http.MethodGet, "/v1/server/config", "403 with invalid secret") + }) +} diff --git a/tests/acceptance/auth.go b/tests/acceptance/auth.go new file mode 100644 index 0000000..50d06fe --- /dev/null +++ b/tests/acceptance/auth.go @@ -0,0 +1,45 @@ +package acceptance + +import ( + "context" + "encoding/json" + "testing" +) + +type loginResponse struct { + Token string `json:"token"` +} + +func loginAdmin(t *testing.T, ctx context.Context, client *Client, c Config) string { + t.Helper() + return login(t, ctx, client, "/v1/auth/admin/login", c.AdminEmail, c.AdminPassword) +} + +func loginUser(t *testing.T, ctx context.Context, client *Client, c Config) string { + t.Helper() + return login(t, ctx, client, "/v1/auth/login", c.UserEmail, c.UserPassword) +} + +func login(t *testing.T, ctx context.Context, client *Client, path string, email string, password string) string { + t.Helper() + requireSecret(t, "login email", email) + requireSecret(t, "login password", password) + + envelope, raw, status, err := client.PostJSON(ctx, path, map[string]string{ + "email": email, + "password": password, + }) + if err != nil { + t.Fatalf("login request failed: %v", err) + } + assertOK(t, envelope, raw, status) + + var data loginResponse + if err := json.Unmarshal(envelope.Data, &data); err != nil { + t.Fatalf("decode login response: %v", err) + } + if data.Token == "" { + t.Fatal("login response token is empty") + } + return data.Token +} diff --git a/tests/acceptance/client.go b/tests/acceptance/client.go new file mode 100644 index 0000000..fd8a6c4 --- /dev/null +++ b/tests/acceptance/client.go @@ -0,0 +1,130 @@ +package acceptance + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "testing" +) + +type Client struct { + baseURL string + httpClient *http.Client + token string +} + +type Envelope struct { + Code uint32 `json:"code"` + Msg string `json:"msg"` + Data json.RawMessage `json:"data"` +} + +func newClient(c Config) *Client { + return &Client{ + baseURL: strings.TrimRight(c.StagingURL, "/"), + httpClient: &http.Client{ + Timeout: c.HTTPTimeout, + }, + } +} + +func (c *Client) WithToken(token string) *Client { + next := *c + next.token = token + return &next +} + +func (c *Client) Get(ctx context.Context, path string, query url.Values) (*Envelope, []byte, int, error) { + return c.Do(ctx, http.MethodGet, path, query, nil) +} + +func (c *Client) PostJSON(ctx context.Context, path string, payload any) (*Envelope, []byte, int, error) { + return c.Do(ctx, http.MethodPost, path, nil, payload) +} + +func (c *Client) Do(ctx context.Context, method string, path string, query url.Values, payload any) (*Envelope, []byte, int, error) { + target, err := url.Parse(c.baseURL + "/" + strings.TrimLeft(path, "/")) + if err != nil { + return nil, nil, 0, fmt.Errorf("build request URL: %w", err) + } + if query != nil { + target.RawQuery = query.Encode() + } + + var body io.Reader + if payload != nil { + encoded, err := json.Marshal(payload) + if err != nil { + return nil, nil, 0, fmt.Errorf("marshal request body: %w", err) + } + body = bytes.NewReader(encoded) + } + + req, err := http.NewRequestWithContext(ctx, method, target.String(), body) + if err != nil { + return nil, nil, 0, fmt.Errorf("create request: %w", err) + } + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", "hifast-acceptance/1.0") + req.Header.Set("Login-Type", "email") + if payload != nil { + req.Header.Set("Content-Type", "application/json") + } + if c.token != "" { + req.Header.Set("Authorization", c.token) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, nil, 0, fmt.Errorf("%s %s: %w", method, target.Redacted(), err) + } + defer func() { + _ = resp.Body.Close() + }() + + raw, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20)) + if err != nil { + return nil, nil, resp.StatusCode, fmt.Errorf("read response: %w", err) + } + + envelope := &Envelope{} + if strings.Contains(resp.Header.Get("Content-Type"), "application/json") && len(raw) > 0 { + if err := json.Unmarshal(raw, envelope); err != nil { + return nil, raw, resp.StatusCode, fmt.Errorf("decode response envelope: %w", err) + } + } + return envelope, raw, resp.StatusCode, nil +} + +func assertOK(t *testing.T, envelope *Envelope, raw []byte, status int) { + t.Helper() + if status != http.StatusOK { + t.Fatalf("http status = %d, want 200; body=%s", status, sanitizeBody(raw)) + } + if envelope == nil || envelope.Code != 200 { + t.Fatalf("api code = %d, want 200; msg=%q body=%s", envelope.Code, envelope.Msg, sanitizeBody(raw)) + } +} + +func assertAPIError(t *testing.T, envelope *Envelope, raw []byte, status int) { + t.Helper() + if status != http.StatusOK { + t.Fatalf("http status = %d, want 200 API envelope; body=%s", status, sanitizeBody(raw)) + } + if envelope == nil || envelope.Code == 200 { + t.Fatalf("api code = %d, want non-200; body=%s", envelope.Code, sanitizeBody(raw)) + } +} + +func sanitizeBody(raw []byte) string { + value := string(raw) + if len(value) > 512 { + value = value[:512] + "..." + } + return value +} diff --git a/tests/acceptance/config.go b/tests/acceptance/config.go new file mode 100644 index 0000000..2dbd267 --- /dev/null +++ b/tests/acceptance/config.go @@ -0,0 +1,112 @@ +package acceptance + +import ( + "flag" + "fmt" + "os" + "strings" + "testing" + "time" +) + +type Config struct { + StagingURL string + AdminEmail string + AdminPassword string + UserEmail string + UserPassword string + NodeSecret string + NodeServerID string + NodeProtocol string + RunID string + ReportPath string + SeedSQLPath string + DB DBConfig + Redis RedisConfig + HTTPTimeout time.Duration +} + +type DBConfig struct { + Host string + User string + Password string + Name string +} + +type RedisConfig struct { + Addr string + Password string + DB int +} + +var testConfig = Config{ + HTTPTimeout: 15 * time.Second, + NodeServerID: "31", + NodeProtocol: "trojan", + SeedSQLPath: "fixtures/seed.sql", +} + +func init() { + flag.StringVar(&testConfig.StagingURL, "staging-url", getenv("STAGING_BASE_URL", "https://tapi.hifast.biz"), "staging base URL") + flag.StringVar(&testConfig.AdminEmail, "admin-email", os.Getenv("ACCEPTANCE_ADMIN_EMAIL"), "admin login email") + flag.StringVar(&testConfig.AdminPassword, "admin-password", os.Getenv("ACCEPTANCE_ADMIN_PASSWORD"), "admin login password") + flag.StringVar(&testConfig.UserEmail, "user-email", os.Getenv("ACCEPTANCE_USER_EMAIL"), "user login email") + flag.StringVar(&testConfig.UserPassword, "user-password", os.Getenv("ACCEPTANCE_USER_PASSWORD"), "user login password") + flag.StringVar(&testConfig.NodeSecret, "node-secret", os.Getenv("ACCEPTANCE_NODE_SECRET"), "node secret; read from staging DB when omitted") + flag.StringVar(&testConfig.NodeServerID, "node-server-id", getenv("ACCEPTANCE_NODE_SERVER_ID", testConfig.NodeServerID), "server id for node smoke tests") + flag.StringVar(&testConfig.NodeProtocol, "node-protocol", getenv("ACCEPTANCE_NODE_PROTOCOL", testConfig.NodeProtocol), "server protocol for node smoke tests") + flag.StringVar(&testConfig.RunID, "run-id", getenv("ACCEPTANCE_RUN_ID", defaultRunID()), "acceptance run id") + flag.StringVar(&testConfig.ReportPath, "report-path", os.Getenv("ACCEPTANCE_REPORT_PATH"), "optional JSON report path") + flag.StringVar(&testConfig.SeedSQLPath, "seed-sql", getenv("ACCEPTANCE_SEED_SQL", testConfig.SeedSQLPath), "seed SQL file path") + flag.StringVar(&testConfig.DB.Host, "db-host", os.Getenv("STAGING_DB_HOST"), "staging DB host") + flag.StringVar(&testConfig.DB.User, "db-user", os.Getenv("STAGING_DB_USER"), "staging DB user") + flag.StringVar(&testConfig.DB.Password, "db-password", os.Getenv("STAGING_DB_PASSWORD"), "staging DB password") + flag.StringVar(&testConfig.DB.Name, "db-name", os.Getenv("STAGING_DB_NAME"), "staging DB name") + flag.StringVar(&testConfig.Redis.Addr, "redis-addr", os.Getenv("STAGING_REDIS_ADDR"), "staging Redis addr") + flag.StringVar(&testConfig.Redis.Password, "redis-password", os.Getenv("STAGING_REDIS_PASSWORD"), "staging Redis password") + flag.IntVar(&testConfig.Redis.DB, "redis-db", getenvInt("STAGING_REDIS_DB", 0), "staging Redis DB") +} + +func cfg(t *testing.T) Config { + t.Helper() + if strings.TrimSpace(testConfig.StagingURL) == "" { + t.Fatal("staging-url is required") + } + return testConfig +} + +func requireSecret(t *testing.T, name string, value string) string { + t.Helper() + if strings.TrimSpace(value) == "" { + t.Skipf("%s is required for this acceptance test", name) + } + return value +} + +func getenv(key string, fallback string) string { + if value := os.Getenv(key); value != "" { + return value + } + return fallback +} + +func getenvInt(key string, fallback int) int { + value := os.Getenv(key) + if value == "" { + return fallback + } + var parsed int + if _, err := fmt.Sscanf(value, "%d", &parsed); err != nil { + return fallback + } + return parsed +} + +func defaultRunID() string { + runID := os.Getenv("GITHUB_RUN_ID") + attempt := getenv("GITHUB_RUN_ATTEMPT", "1") + if runID == "" { + return "qa_local" + } + return "qa_" + runID + "_" + attempt +} diff --git a/tests/acceptance/fixtures.go b/tests/acceptance/fixtures.go new file mode 100644 index 0000000..4b07d47 --- /dev/null +++ b/tests/acceptance/fixtures.go @@ -0,0 +1,132 @@ +package acceptance + +import ( + "context" + "database/sql" + "fmt" + "os" + "strings" + "testing" + "time" + + _ "github.com/go-sql-driver/mysql" + "github.com/redis/go-redis/v9" +) + +type FixtureLoader struct { + cfg Config +} + +func newFixtureLoader(c Config) *FixtureLoader { + return &FixtureLoader{cfg: c} +} + +func (l *FixtureLoader) SeedIfConfigured(t *testing.T, ctx context.Context) { + t.Helper() + if !l.hasDBConfig() { + t.Log("staging DB config not provided; skipping seed loader") + return + } + sqlBytes, err := os.ReadFile(l.cfg.SeedSQLPath) + if err != nil { + t.Fatalf("read seed SQL %q: %v", l.cfg.SeedSQLPath, err) + } + + db, err := sql.Open("mysql", l.dsn(true)) + if err != nil { + t.Fatalf("open staging DB: %v", err) + } + defer func() { + _ = db.Close() + }() + + if err := db.PingContext(ctx); err != nil { + t.Fatalf("ping staging DB: %v", err) + } + + statements := splitSQL(string(sqlBytes)) + for _, statement := range statements { + statement = strings.ReplaceAll(statement, "{{RUN_ID}}", l.cfg.RunID) + if _, err := db.ExecContext(ctx, statement); err != nil { + t.Fatalf("execute seed SQL: %v; statement=%s", err, statement) + } + } +} + +func (l *FixtureLoader) NodeSecret(ctx context.Context) (string, error) { + if l.cfg.NodeSecret != "" { + return l.cfg.NodeSecret, nil + } + if !l.hasDBConfig() { + return "", nil + } + + db, err := sql.Open("mysql", l.dsn(false)) + if err != nil { + return "", fmt.Errorf("open staging DB: %w", err) + } + defer func() { + _ = db.Close() + }() + + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + var secret string + err = db.QueryRowContext(ctx, "SELECT value FROM system WHERE category = 'server' AND `key` = 'NodeSecret' LIMIT 1").Scan(&secret) + if err != nil { + return "", fmt.Errorf("read NodeSecret from staging DB: %w", err) + } + return secret, nil +} + +func (l *FixtureLoader) RedisClient() *redis.Client { + if l.cfg.Redis.Addr == "" { + return nil + } + return redis.NewClient(&redis.Options{ + Addr: l.cfg.Redis.Addr, + Password: l.cfg.Redis.Password, + DB: l.cfg.Redis.DB, + }) +} + +func (l *FixtureLoader) hasDBConfig() bool { + return l.cfg.DB.Host != "" && l.cfg.DB.User != "" && l.cfg.DB.Name != "" +} + +func (l *FixtureLoader) dsn(multiStatements bool) string { + params := "parseTime=true&timeout=5s&readTimeout=10s&writeTimeout=10s" + if multiStatements { + params += "&multiStatements=true" + } + return fmt.Sprintf("%s:%s@tcp(%s)/%s?%s", + l.cfg.DB.User, + l.cfg.DB.Password, + l.cfg.DB.Host, + l.cfg.DB.Name, + params, + ) +} + +func splitSQL(script string) []string { + parts := strings.Split(script, ";") + statements := make([]string, 0, len(parts)) + for _, part := range parts { + lines := strings.Split(part, "\n") + kept := make([]string, 0, len(lines)) + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "--") { + continue + } + kept = append(kept, line) + } + statement := strings.Join(kept, "\n") + if statement == "" || strings.HasPrefix(statement, "--") { + continue + } + statements = append(statements, statement) + } + return statements +} diff --git a/tests/acceptance/fixtures/seed.sql b/tests/acceptance/fixtures/seed.sql new file mode 100644 index 0000000..40bacbe --- /dev/null +++ b/tests/acceptance/fixtures/seed.sql @@ -0,0 +1,4 @@ +-- Idempotent staging acceptance fixture seed. +-- HIF-5 lands the loader and smoke skeleton; HIF-6/HIF-7 can extend this file +-- with concrete qa_acceptance_ rows after the MVP surface is expanded. +SELECT 1; diff --git a/tests/acceptance/helpers_test.go b/tests/acceptance/helpers_test.go new file mode 100644 index 0000000..8ab17c2 --- /dev/null +++ b/tests/acceptance/helpers_test.go @@ -0,0 +1,77 @@ +package acceptance + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestSplitSQLDropsCommentsAndKeepsStatements(t *testing.T) { + script := ` +-- comment before first statement +SELECT 1; + +-- comment before second statement +SELECT '{{RUN_ID}}'; +` + + statements := splitSQL(script) + if len(statements) != 2 { + t.Fatalf("len(statements) = %d, want 2: %#v", len(statements), statements) + } + if statements[0] != "SELECT 1" { + t.Fatalf("first statement = %q", statements[0]) + } + if statements[1] != "SELECT '{{RUN_ID}}'" { + t.Fatalf("second statement = %q", statements[1]) + } +} + +func TestSanitizeBodyTruncatesLongBodies(t *testing.T) { + raw := []byte(strings.Repeat("a", 600)) + got := sanitizeBody(raw) + if len(got) <= 512 { + t.Fatalf("sanitized body length = %d, want truncation marker", len(got)) + } + if !strings.Contains(got, "") { + t.Fatalf("sanitized body missing truncation marker: %q", got) + } +} + +func TestWriteReportCreatesJSONArtifact(t *testing.T) { + dir := t.TempDir() + reportPath := filepath.Join(dir, "report.json") + + original := acceptanceReporter + acceptanceReporter = &Reporter{started: time.Now()} + t.Cleanup(func() { + acceptanceReporter = original + }) + + recordCase(t, "public", "GET", "/v1/public/user/info", "200 envelope") + if err := writeReport(Config{ + RunID: "qa_test", + StagingURL: "https://tapi.hifast.biz", + ReportPath: reportPath, + }); err != nil { + t.Fatalf("write report: %v", err) + } + + raw, err := os.ReadFile(reportPath) + if err != nil { + t.Fatalf("read report: %v", err) + } + var report Report + if err := json.Unmarshal(raw, &report); err != nil { + t.Fatalf("decode report: %v", err) + } + if report.RunID != "qa_test" { + t.Fatalf("run id = %q", report.RunID) + } + if len(report.Cases) != 1 { + t.Fatalf("cases len = %d, want 1", len(report.Cases)) + } +} diff --git a/tests/acceptance/reporter.go b/tests/acceptance/reporter.go new file mode 100644 index 0000000..7d92788 --- /dev/null +++ b/tests/acceptance/reporter.go @@ -0,0 +1,64 @@ +package acceptance + +import ( + "encoding/json" + "os" + "sync" + "testing" + "time" +) + +type Reporter struct { + mu sync.Mutex + started time.Time + cases []ReportCase +} + +type ReportCase struct { + Name string `json:"name"` + Domain string `json:"domain"` + Method string `json:"method"` + Path string `json:"path"` + Expected string `json:"expected"` +} + +type Report struct { + RunID string `json:"run_id"` + BaseURL string `json:"base_url"` + Duration string `json:"duration"` + Cases []ReportCase `json:"cases"` +} + +var acceptanceReporter = &Reporter{started: time.Now()} + +func recordCase(t *testing.T, domain string, method string, path string, expected string) { + t.Helper() + acceptanceReporter.mu.Lock() + defer acceptanceReporter.mu.Unlock() + acceptanceReporter.cases = append(acceptanceReporter.cases, ReportCase{ + Name: t.Name(), + Domain: domain, + Method: method, + Path: path, + Expected: expected, + }) +} + +func writeReport(c Config) error { + if c.ReportPath == "" { + return nil + } + acceptanceReporter.mu.Lock() + defer acceptanceReporter.mu.Unlock() + report := Report{ + RunID: c.RunID, + BaseURL: c.StagingURL, + Duration: time.Since(acceptanceReporter.started).String(), + Cases: append([]ReportCase(nil), acceptanceReporter.cases...), + } + out, err := json.MarshalIndent(report, "", " ") + if err != nil { + return err + } + return os.WriteFile(c.ReportPath, out, 0o600) +}