tests: integration suite

This commit is contained in:
IliaDenisov
2026-04-09 15:27:14 +02:00
parent e04fc663f0
commit 1c8e0ca48e
20 changed files with 2748 additions and 10 deletions
+71
View File
@@ -0,0 +1,71 @@
// Package harness provides reusable black-box integration helpers shared by
// inter-service suites.
package harness
import (
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync"
"testing"
)
var binaryCache struct {
mu sync.Mutex
paths map[string]string
}
// BuildBinary builds packagePath once per test process and returns the
// resulting executable path.
func BuildBinary(t testing.TB, name string, packagePath string) string {
t.Helper()
root := repositoryRoot(t)
key := name + ":" + packagePath
binaryCache.mu.Lock()
if binaryCache.paths == nil {
binaryCache.paths = make(map[string]string)
}
if path, ok := binaryCache.paths[key]; ok {
binaryCache.mu.Unlock()
return path
}
outputDir := filepath.Join(os.TempDir(), "galaxy-integration-binaries")
if err := os.MkdirAll(outputDir, 0o755); err != nil {
binaryCache.mu.Unlock()
t.Fatalf("create integration binary directory: %v", err)
}
outputPath := filepath.Join(outputDir, sanitizeBinaryName(key))
cmd := exec.Command("go", "build", "-o", outputPath, packagePath)
cmd.Dir = root
output, err := cmd.CombinedOutput()
if err != nil {
binaryCache.mu.Unlock()
t.Fatalf("build %s: %v\n%s", packagePath, err, output)
}
binaryCache.paths[key] = outputPath
binaryCache.mu.Unlock()
return outputPath
}
func repositoryRoot(t testing.TB) string {
t.Helper()
_, file, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("resolve harness repository root: runtime caller is unavailable")
}
return filepath.Clean(filepath.Join(filepath.Dir(file), "..", "..", ".."))
}
func sanitizeBinaryName(value string) string {
replacer := strings.NewReplacer("/", "_", "\\", "_", ":", "_", ".", "_")
return replacer.Replace(value)
}
+54
View File
@@ -0,0 +1,54 @@
package harness
import (
"crypto/ed25519"
"crypto/sha256"
"crypto/x509"
"encoding/pem"
"os"
"path/filepath"
"testing"
"github.com/alicebob/miniredis/v2"
)
// StartMiniredis starts one isolated Redis-compatible in-memory server and
// registers automatic cleanup.
func StartMiniredis(t testing.TB) *miniredis.Miniredis {
t.Helper()
server, err := miniredis.Run()
if err != nil {
t.Fatalf("start miniredis: %v", err)
}
t.Cleanup(server.Close)
return server
}
// WriteResponseSignerPEM writes one deterministic PKCS#8 PEM-encoded Ed25519
// private key for gateway response signing and returns the file path plus the
// matching public key.
func WriteResponseSignerPEM(t testing.TB, label string) (string, ed25519.PublicKey) {
t.Helper()
seed := sha256.Sum256([]byte("galaxy-integration-response-signer-" + label))
privateKey := ed25519.NewKeyFromSeed(seed[:])
encoded, err := x509.MarshalPKCS8PrivateKey(privateKey)
if err != nil {
t.Fatalf("marshal response signer private key: %v", err)
}
pemBytes := pem.EncodeToMemory(&pem.Block{
Type: "PRIVATE KEY",
Bytes: encoded,
})
path := filepath.Join(t.TempDir(), "response-signer.pem")
if err := os.WriteFile(path, pemBytes, 0o600); err != nil {
t.Fatalf("write response signer private key: %v", err)
}
return path, privateKey.Public().(ed25519.PublicKey)
}
+182
View File
@@ -0,0 +1,182 @@
package harness
import (
"bytes"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
)
const mailStubPath = "/api/v1/internal/login-code-deliveries"
// LoginCodeDelivery stores one mail-delivery request received by the external
// mail stub.
type LoginCodeDelivery struct {
// Email identifies the target e-mail address requested by authsession.
Email string
// Code stores the cleartext login code requested by authsession.
Code string
}
// MailBehavior overrides one external mail-stub response.
type MailBehavior struct {
// Delay waits before the stub writes its response.
Delay time.Duration
// StatusCode overrides the HTTP status returned by the stub. Zero keeps the
// default `200 OK`.
StatusCode int
// RawBody overrides the exact response body returned by the stub. Empty
// value keeps the default JSON payload for the chosen status.
RawBody string
}
// MailStub provides one stateful external HTTP mail-service stub.
type MailStub struct {
server *httptest.Server
mu sync.Mutex
deliveries []LoginCodeDelivery
behavior MailBehavior
}
// NewMailStub starts one stateful external HTTP mail-service stub.
func NewMailStub(t testing.TB) *MailStub {
t.Helper()
stub := &MailStub{}
stub.server = httptest.NewServer(http.HandlerFunc(stub.handle))
t.Cleanup(stub.server.Close)
return stub
}
// BaseURL returns the stub base URL suitable for service runtime wiring.
func (s *MailStub) BaseURL() string {
if s == nil || s.server == nil {
return ""
}
return s.server.URL
}
// SetBehavior replaces the current response behavior used by subsequent
// requests.
func (s *MailStub) SetBehavior(behavior MailBehavior) {
s.mu.Lock()
defer s.mu.Unlock()
s.behavior = behavior
}
// RecordedDeliveries returns a snapshot of all delivery requests received by
// the stub so far.
func (s *MailStub) RecordedDeliveries() []LoginCodeDelivery {
s.mu.Lock()
defer s.mu.Unlock()
cloned := make([]LoginCodeDelivery, len(s.deliveries))
copy(cloned, s.deliveries)
return cloned
}
// Reset clears the recorded deliveries and restores default behavior.
func (s *MailStub) Reset() {
s.mu.Lock()
defer s.mu.Unlock()
s.deliveries = nil
s.behavior = MailBehavior{}
}
func (s *MailStub) handle(writer http.ResponseWriter, request *http.Request) {
if request.Method != http.MethodPost || request.URL.Path != mailStubPath {
http.NotFound(writer, request)
return
}
var payload struct {
Email string `json:"email"`
Code string `json:"code"`
}
if err := decodeStrictJSONRequest(request, &payload); err != nil {
http.Error(writer, err.Error(), http.StatusBadRequest)
return
}
s.mu.Lock()
s.deliveries = append(s.deliveries, LoginCodeDelivery{
Email: payload.Email,
Code: payload.Code,
})
behavior := s.behavior
s.mu.Unlock()
if behavior.Delay > 0 {
timer := time.NewTimer(behavior.Delay)
defer timer.Stop()
select {
case <-request.Context().Done():
return
case <-timer.C:
}
}
statusCode := behavior.StatusCode
if statusCode == 0 {
statusCode = http.StatusOK
}
body := behavior.RawBody
if body == "" {
switch statusCode {
case http.StatusOK:
body = `{"outcome":"sent"}`
default:
body = `{"error":"stubbed mail failure"}`
}
}
writer.Header().Set("Content-Type", "application/json")
writer.WriteHeader(statusCode)
_, _ = io.WriteString(writer, body)
}
func decodeStrictJSONRequest(request *http.Request, target any) error {
decoder := json.NewDecoder(request.Body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil {
return err
}
if err := decoder.Decode(&struct{}{}); err != io.EOF {
if err == nil {
return errors.New("unexpected trailing JSON input")
}
return err
}
return nil
}
func decodeStrictJSONPayload(payload []byte, target any) error {
decoder := json.NewDecoder(bytes.NewReader(payload))
decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil {
return err
}
if err := decoder.Decode(&struct{}{}); err != io.EOF {
if err == nil {
return errors.New("unexpected trailing JSON input")
}
return err
}
return nil
}
+276
View File
@@ -0,0 +1,276 @@
package harness
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"net"
"net/http"
"os"
"os/exec"
"strings"
"sync"
"syscall"
"testing"
"time"
)
const (
defaultStartupWait = 10 * time.Second
defaultPollInterval = 25 * time.Millisecond
defaultStopWait = 5 * time.Second
)
// Process represents one long-lived external service process started by an
// integration suite.
type Process struct {
name string
cmd *exec.Cmd
logsMu sync.Mutex
logs bytes.Buffer
doneCh chan struct{}
waitErr error
}
// StartProcess starts binaryPath with envOverrides and registers cleanup that
// stops the process and prints captured logs on failed tests.
func StartProcess(t testing.TB, name string, binaryPath string, envOverrides map[string]string) *Process {
t.Helper()
cmd := exec.Command(binaryPath)
cmd.Env = mergeEnvironment(os.Environ(), envOverrides)
process := &Process{
name: name,
cmd: cmd,
doneCh: make(chan struct{}),
}
cmd.Stdout = process.logWriter()
cmd.Stderr = process.logWriter()
if err := cmd.Start(); err != nil {
t.Fatalf("start %s: %v", name, err)
}
go func() {
process.waitErr = cmd.Wait()
close(process.doneCh)
}()
t.Cleanup(func() {
process.Stop(t)
if t.Failed() {
t.Logf("%s logs:\n%s", name, process.Logs())
}
})
return process
}
// Stop asks the process to terminate gracefully and waits for completion.
func (p *Process) Stop(t testing.TB) {
t.Helper()
if p == nil {
return
}
select {
case <-p.doneCh:
err := p.waitErr
if err != nil && !isExpectedProcessExit(err) {
t.Errorf("%s exited unexpectedly: %v", p.name, err)
}
return
default:
}
if p.cmd.Process != nil {
_ = p.cmd.Process.Signal(syscall.SIGTERM)
}
select {
case <-p.doneCh:
err := p.waitErr
if err != nil && !isExpectedProcessExit(err) {
t.Errorf("%s exited unexpectedly: %v", p.name, err)
}
case <-time.After(defaultStopWait):
if p.cmd.Process != nil {
_ = p.cmd.Process.Kill()
}
<-p.doneCh
err := p.waitErr
if err != nil && !isExpectedProcessExit(err) {
t.Errorf("%s exited unexpectedly: %v", p.name, err)
}
}
}
// Logs returns the captured combined stdout/stderr output of the process.
func (p *Process) Logs() string {
if p == nil {
return ""
}
p.logsMu.Lock()
defer p.logsMu.Unlock()
return p.logs.String()
}
// FreeTCPAddress reserves one ephemeral loopback TCP address and releases it
// immediately so a service process can bind to it.
func FreeTCPAddress(t testing.TB) string {
t.Helper()
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("reserve free TCP address: %v", err)
}
addr := listener.Addr().String()
if err := listener.Close(); err != nil {
t.Fatalf("release reserved TCP address: %v", err)
}
return addr
}
// WaitForHTTPStatus waits until url responds with wantStatus or fails when the
// backing process exits early.
func WaitForHTTPStatus(t testing.TB, process *Process, url string, wantStatus int) {
t.Helper()
client := &http.Client{
Timeout: 250 * time.Millisecond,
Transport: &http.Transport{
DisableKeepAlives: true,
},
}
defer client.CloseIdleConnections()
ctx, cancel := context.WithTimeout(context.Background(), defaultStartupWait)
defer cancel()
ticker := time.NewTicker(defaultPollInterval)
defer ticker.Stop()
for {
if err := processErr(process); err != nil {
t.Fatalf("%s exited before %s became ready: %v\n%s", process.name, url, err, process.Logs())
}
request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
t.Fatalf("build readiness request for %s: %v", url, err)
}
response, err := client.Do(request)
if err == nil {
_, _ = io.Copy(io.Discard, response.Body)
response.Body.Close()
if response.StatusCode == wantStatus {
return
}
}
select {
case <-ctx.Done():
t.Fatalf("wait for %s status %d: %v\n%s", url, wantStatus, ctx.Err(), process.Logs())
case <-ticker.C:
}
}
}
// WaitForTCP waits until address accepts TCP connections or fails when the
// backing process exits early.
func WaitForTCP(t testing.TB, process *Process, address string) {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), defaultStartupWait)
defer cancel()
ticker := time.NewTicker(defaultPollInterval)
defer ticker.Stop()
for {
if err := processErr(process); err != nil {
t.Fatalf("%s exited before %s became reachable: %v\n%s", process.name, address, err, process.Logs())
}
conn, err := net.DialTimeout("tcp", address, 100*time.Millisecond)
if err == nil {
_ = conn.Close()
return
}
select {
case <-ctx.Done():
t.Fatalf("wait for %s TCP readiness: %v\n%s", address, ctx.Err(), process.Logs())
case <-ticker.C:
}
}
}
func (p *Process) logWriter() io.Writer {
return writerFunc(func(data []byte) (int, error) {
p.logsMu.Lock()
defer p.logsMu.Unlock()
return p.logs.Write(data)
})
}
func mergeEnvironment(base []string, overrides map[string]string) []string {
values := make(map[string]string, len(base)+len(overrides))
for _, entry := range base {
name, value, ok := strings.Cut(entry, "=")
if ok {
values[name] = value
}
}
for name, value := range overrides {
values[name] = value
}
merged := make([]string, 0, len(values))
for name, value := range values {
merged = append(merged, fmt.Sprintf("%s=%s", name, value))
}
return merged
}
func processErr(process *Process) error {
if process == nil {
return errors.New("nil process")
}
select {
case <-process.doneCh:
return process.waitErr
default:
return nil
}
}
func isExpectedProcessExit(err error) bool {
if err == nil {
return true
}
var exitErr *exec.ExitError
if !errors.As(err, &exitErr) {
return false
}
return exitErr.ExitCode() == -1
}
type writerFunc func([]byte) (int, error)
func (f writerFunc) Write(data []byte) (int, error) {
return f(data)
}
+323
View File
@@ -0,0 +1,323 @@
package harness
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync"
"testing"
)
const (
resolveByEmailPath = "/api/v1/internal/user-resolutions/by-email"
ensureByEmailPath = "/api/v1/internal/users/ensure-by-email"
blockByEmailPath = "/api/v1/internal/user-blocks/by-email"
)
// EnsureUserCall stores one ensure-by-email request received by the external
// user-service stub.
type EnsureUserCall struct {
// Email identifies the requested login or registration e-mail.
Email string
// PreferredLanguage stores the forwarded registration-context language.
PreferredLanguage string
// TimeZone stores the forwarded registration-context time zone.
TimeZone string
}
// UserStub provides one stateful external HTTP user-service stub.
type UserStub struct {
server *httptest.Server
mu sync.Mutex
emailToUserID map[string]string
userIDToEmail map[string]string
blockedEmails map[string]string
blockedUsers map[string]string
ensureCalls []EnsureUserCall
nextUserID int
}
// NewUserStub starts one stateful external HTTP user-service stub.
func NewUserStub(t testing.TB) *UserStub {
t.Helper()
stub := &UserStub{
emailToUserID: make(map[string]string),
userIDToEmail: make(map[string]string),
blockedEmails: make(map[string]string),
blockedUsers: make(map[string]string),
nextUserID: 1,
}
stub.server = httptest.NewServer(http.HandlerFunc(stub.handle))
t.Cleanup(stub.server.Close)
return stub
}
// BaseURL returns the stub base URL suitable for authsession runtime wiring.
func (s *UserStub) BaseURL() string {
if s == nil || s.server == nil {
return ""
}
return s.server.URL
}
// SeedExisting adds one existing unblocked user record into the stub state.
func (s *UserStub) SeedExisting(email string, userID string) {
s.mu.Lock()
defer s.mu.Unlock()
s.emailToUserID[email] = userID
s.userIDToEmail[userID] = email
}
// SeedBlockedEmail adds one blocked e-mail into the stub state.
func (s *UserStub) SeedBlockedEmail(email string, reasonCode string) {
s.mu.Lock()
defer s.mu.Unlock()
s.blockedEmails[email] = reasonCode
if userID, ok := s.emailToUserID[email]; ok {
s.blockedUsers[userID] = reasonCode
}
}
// EnsureCalls returns a snapshot of ensure-by-email requests observed by the
// stub so far.
func (s *UserStub) EnsureCalls() []EnsureUserCall {
s.mu.Lock()
defer s.mu.Unlock()
cloned := make([]EnsureUserCall, len(s.ensureCalls))
copy(cloned, s.ensureCalls)
return cloned
}
// Reset clears all stub state and recorded calls.
func (s *UserStub) Reset() {
s.mu.Lock()
defer s.mu.Unlock()
s.emailToUserID = make(map[string]string)
s.userIDToEmail = make(map[string]string)
s.blockedEmails = make(map[string]string)
s.blockedUsers = make(map[string]string)
s.ensureCalls = nil
s.nextUserID = 1
}
func (s *UserStub) handle(writer http.ResponseWriter, request *http.Request) {
switch {
case request.Method == http.MethodPost && request.URL.Path == resolveByEmailPath:
s.handleResolveByEmail(writer, request)
case request.Method == http.MethodGet && strings.HasPrefix(request.URL.Path, "/api/v1/internal/users/") && strings.HasSuffix(request.URL.Path, "/exists"):
s.handleExistsByUserID(writer, request)
case request.Method == http.MethodPost && request.URL.Path == ensureByEmailPath:
s.handleEnsureByEmail(writer, request)
case request.Method == http.MethodPost && strings.HasPrefix(request.URL.Path, "/api/v1/internal/users/") && strings.HasSuffix(request.URL.Path, "/block"):
s.handleBlockByUserID(writer, request)
case request.Method == http.MethodPost && request.URL.Path == blockByEmailPath:
s.handleBlockByEmail(writer, request)
default:
http.NotFound(writer, request)
}
}
func (s *UserStub) handleResolveByEmail(writer http.ResponseWriter, request *http.Request) {
var payload struct {
Email string `json:"email"`
}
if err := decodeStrictJSONRequest(request, &payload); err != nil {
http.Error(writer, err.Error(), http.StatusBadRequest)
return
}
s.mu.Lock()
defer s.mu.Unlock()
if reason, ok := s.blockedEmails[payload.Email]; ok {
writeJSON(writer, http.StatusOK, map[string]any{
"kind": "blocked",
"block_reason_code": reason,
})
return
}
if userID, ok := s.emailToUserID[payload.Email]; ok {
if reason, blocked := s.blockedUsers[userID]; blocked {
writeJSON(writer, http.StatusOK, map[string]any{
"kind": "blocked",
"block_reason_code": reason,
})
return
}
writeJSON(writer, http.StatusOK, map[string]any{
"kind": "existing",
"user_id": userID,
})
return
}
writeJSON(writer, http.StatusOK, map[string]any{"kind": "creatable"})
}
func (s *UserStub) handleExistsByUserID(writer http.ResponseWriter, request *http.Request) {
userIDValue := strings.TrimSuffix(strings.TrimPrefix(request.URL.Path, "/api/v1/internal/users/"), "/exists")
userIDValue, err := url.PathUnescape(userIDValue)
if err != nil {
http.Error(writer, err.Error(), http.StatusBadRequest)
return
}
s.mu.Lock()
defer s.mu.Unlock()
_, exists := s.userIDToEmail[userIDValue]
writeJSON(writer, http.StatusOK, map[string]bool{"exists": exists})
}
func (s *UserStub) handleEnsureByEmail(writer http.ResponseWriter, request *http.Request) {
var payload struct {
Email string `json:"email"`
RegistrationContext *struct {
PreferredLanguage string `json:"preferred_language"`
TimeZone string `json:"time_zone"`
} `json:"registration_context"`
}
if err := decodeStrictJSONRequest(request, &payload); err != nil {
http.Error(writer, err.Error(), http.StatusBadRequest)
return
}
if payload.RegistrationContext == nil {
http.Error(writer, "registration_context must be present", http.StatusBadRequest)
return
}
s.mu.Lock()
defer s.mu.Unlock()
s.ensureCalls = append(s.ensureCalls, EnsureUserCall{
Email: payload.Email,
PreferredLanguage: payload.RegistrationContext.PreferredLanguage,
TimeZone: payload.RegistrationContext.TimeZone,
})
if reason, ok := s.blockedEmails[payload.Email]; ok {
writeJSON(writer, http.StatusOK, map[string]any{
"outcome": "blocked",
"block_reason_code": reason,
})
return
}
if userID, ok := s.emailToUserID[payload.Email]; ok {
if reason, blocked := s.blockedUsers[userID]; blocked {
writeJSON(writer, http.StatusOK, map[string]any{
"outcome": "blocked",
"block_reason_code": reason,
})
return
}
writeJSON(writer, http.StatusOK, map[string]any{
"outcome": "existing",
"user_id": userID,
})
return
}
userID := fmt.Sprintf("user-%d", s.nextUserID)
s.nextUserID++
s.emailToUserID[payload.Email] = userID
s.userIDToEmail[userID] = payload.Email
writeJSON(writer, http.StatusOK, map[string]any{
"outcome": "created",
"user_id": userID,
})
}
func (s *UserStub) handleBlockByUserID(writer http.ResponseWriter, request *http.Request) {
userIDValue := strings.TrimSuffix(strings.TrimPrefix(request.URL.Path, "/api/v1/internal/users/"), "/block")
userIDValue, err := url.PathUnescape(userIDValue)
if err != nil {
http.Error(writer, err.Error(), http.StatusBadRequest)
return
}
var payload struct {
ReasonCode string `json:"reason_code"`
}
if err := decodeStrictJSONRequest(request, &payload); err != nil {
http.Error(writer, err.Error(), http.StatusBadRequest)
return
}
s.mu.Lock()
defer s.mu.Unlock()
email, exists := s.userIDToEmail[userIDValue]
if !exists {
writeJSON(writer, http.StatusNotFound, map[string]string{"error": "not found"})
return
}
outcome := "blocked"
if _, already := s.blockedUsers[userIDValue]; already {
outcome = "already_blocked"
}
s.blockedUsers[userIDValue] = payload.ReasonCode
s.blockedEmails[email] = payload.ReasonCode
writeJSON(writer, http.StatusOK, map[string]any{
"outcome": outcome,
"user_id": userIDValue,
})
}
func (s *UserStub) handleBlockByEmail(writer http.ResponseWriter, request *http.Request) {
var payload struct {
Email string `json:"email"`
ReasonCode string `json:"reason_code"`
}
if err := decodeStrictJSONRequest(request, &payload); err != nil {
http.Error(writer, err.Error(), http.StatusBadRequest)
return
}
s.mu.Lock()
defer s.mu.Unlock()
outcome := "blocked"
if _, already := s.blockedEmails[payload.Email]; already {
outcome = "already_blocked"
}
s.blockedEmails[payload.Email] = payload.ReasonCode
response := map[string]any{"outcome": outcome}
if userID, ok := s.emailToUserID[payload.Email]; ok {
s.blockedUsers[userID] = payload.ReasonCode
response["user_id"] = userID
}
writeJSON(writer, http.StatusOK, response)
}
func writeJSON(writer http.ResponseWriter, statusCode int, value any) {
payload, err := json.Marshal(value)
if err != nil {
http.Error(writer, err.Error(), http.StatusInternalServerError)
return
}
writer.Header().Set("Content-Type", "application/json")
writer.WriteHeader(statusCode)
_, _ = writer.Write(payload)
}