diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..39105b0 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,69 @@ +name: backend-tests + +on: + pull_request: + paths: + - 'backend/**' + - 'Dockerfile' + - 'Makefile' + - 'scripts/coverage-gate.sh' + - '.github/workflows/test.yml' + push: + branches: [main] + +jobs: + unit: + name: Unit tests + coverage gate + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.25' + cache: true + cache-dependency-path: backend/go.sum + + - name: Run unit tests with race detector and coverage + run: | + cd backend + go test -race -coverprofile=../coverage.out -coverpkg=./... ./... + + - name: Enforce coverage gate + run: ./scripts/coverage-gate.sh coverage.out + + - name: Upload coverage artifact + if: always() + uses: actions/upload-artifact@v4 + with: + name: coverage + path: coverage.out + + smoke: + name: Smoke test (docker compose) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.25' + cache: true + cache-dependency-path: backend/go.sum + + - name: Run smoke test + run: make test-smoke + + - name: Dump compose logs on failure + if: failure() + run: | + docker compose -p garage-ui-smoke \ + -f backend/tests/smoke/docker-compose.test.yml logs --no-color || true + + - name: Tear down compose + if: always() + run: | + docker compose -p garage-ui-smoke \ + -f backend/tests/smoke/docker-compose.test.yml down -v || true diff --git a/Makefile b/Makefile index 283c7d1..603129a 100644 --- a/Makefile +++ b/Makefile @@ -135,3 +135,22 @@ install: update: prod-pull prod-restart .DEFAULT_GOAL := help + +.PHONY: test test-race test-cover test-smoke + +## test: Run backend unit tests +test: + cd backend && go test ./... + +## test-race: Run backend unit tests with the race detector +test-race: + cd backend && go test -race ./... + +## test-cover: Run backend unit tests with coverage and enforce the coverage gate +test-cover: + cd backend && go test -coverprofile=../coverage.out -coverpkg=./... ./... + ./scripts/coverage-gate.sh coverage.out + +## test-smoke: Run the docker-compose smoke test (requires Docker + compose v2) +test-smoke: + cd backend && go test -tags=smoke -timeout 10m ./tests/smoke/... diff --git a/backend/tests/smoke/smoke_test.go b/backend/tests/smoke/smoke_test.go new file mode 100644 index 0000000..9853bea --- /dev/null +++ b/backend/tests/smoke/smoke_test.go @@ -0,0 +1,388 @@ +//go:build smoke + +package smoke_test + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + "time" +) + +const ( + composeProject = "garage-ui-smoke" + backendBaseURL = "http://127.0.0.1:18080" + adminToken = "smoke-admin-token-do-not-use-in-prod" + adminUsername = "smokeadmin" + adminPassword = "smokepass" + readyTimeout = 90 * time.Second + readyPollEvery = 1 * time.Second +) + +func composeFile(t testing.TB) string { + t.Helper() + _, thisFile, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("runtime.Caller failed") + } + return filepath.Join(filepath.Dir(thisFile), "docker-compose.test.yml") +} + +func runCompose(t testing.TB, args ...string) string { + t.Helper() + full := append([]string{"compose", "-p", composeProject, "-f", composeFile(t)}, args...) + cmd := exec.Command("docker", full...) + var out bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = &out + if err := cmd.Run(); err != nil { + t.Fatalf("docker %s failed: %v\n%s", strings.Join(full, " "), err, out.String()) + } + return out.String() +} + +func composeExec(service string, argv ...string) (string, error) { + full := append([]string{"compose", "-p", composeProject, "-f", mustComposeFile(), "exec", "-T", service}, argv...) + cmd := exec.Command("docker", full...) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return "", fmt.Errorf("docker %s: %w\nstderr:\n%s", strings.Join(full, " "), err, stderr.String()) + } + return stdout.String(), nil +} + +func mustComposeFile() string { + _, thisFile, _, ok := runtime.Caller(0) + if !ok { + panic("runtime.Caller failed") + } + return filepath.Join(filepath.Dir(thisFile), "docker-compose.test.yml") +} + +func waitForHTTP(ctx context.Context, url string, timeout time.Duration) error { + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return err + } + resp, err := http.DefaultClient.Do(req) + if err == nil { + _ = resp.Body.Close() + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + return nil + } + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(readyPollEvery): + } + } + return fmt.Errorf("timeout waiting for %s", url) +} + +func initGarageLayout() error { + nodeOut, err := composeExec("garage", "/garage", "node", "id", "-q") + if err != nil { + return fmt.Errorf("garage node id: %w", err) + } + nodeID := strings.TrimSpace(nodeOut) + if i := strings.Index(nodeID, "@"); i > 0 { + nodeID = nodeID[:i] + } + if nodeID == "" { + return fmt.Errorf("empty node id from garage node id") + } + + if _, err := composeExec("garage", "/garage", "layout", "assign", "-z", "dc1", "-c", "1G", nodeID); err != nil { + return fmt.Errorf("garage layout assign: %w", err) + } + if _, err := composeExec("garage", "/garage", "layout", "apply", "--version", "1"); err != nil { + return fmt.Errorf("garage layout apply: %w", err) + } + return nil +} + +func TestMain(m *testing.M) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + fmt.Fprintln(os.Stderr, "[smoke] bringing up garage (waiting for healthcheck)...") + runComposeNoT("up", "-d", "--wait", "garage") + + cleanup := func() { + fmt.Fprintln(os.Stderr, "[smoke] tearing down compose...") + _ = exec.Command("docker", "compose", "-p", composeProject, "-f", mustComposeFile(), "logs").Run() + _ = exec.Command("docker", "compose", "-p", composeProject, "-f", mustComposeFile(), "down", "-v").Run() + } + + fmt.Fprintln(os.Stderr, "[smoke] initializing garage cluster layout...") + if err := initGarageLayout(); err != nil { + fmt.Fprintf(os.Stderr, "[smoke] layout init failed: %v\n", err) + cleanup() + os.Exit(1) + } + + fmt.Fprintln(os.Stderr, "[smoke] starting backend...") + runComposeNoT("up", "-d", "backend") + + if err := waitForHTTP(ctx, backendBaseURL+"/health", readyTimeout); err != nil { + fmt.Fprintf(os.Stderr, "[smoke] backend not ready: %v\n", err) + cleanup() + os.Exit(1) + } + + code := m.Run() + + if code != 0 { + fmt.Fprintln(os.Stderr, "[smoke] tests failed — dumping compose logs") + logCmd := exec.Command("docker", "compose", "-p", composeProject, "-f", mustComposeFile(), "logs", "--no-color") + logCmd.Stdout = os.Stderr + logCmd.Stderr = os.Stderr + _ = logCmd.Run() + } + + _ = exec.Command("docker", "compose", "-p", composeProject, "-f", mustComposeFile(), "down", "-v").Run() + os.Exit(code) +} + +func runComposeNoT(args ...string) { + full := append([]string{"compose", "-p", composeProject, "-f", mustComposeFile()}, args...) + cmd := exec.Command("docker", full...) + cmd.Stdout = os.Stderr + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + fmt.Fprintf(os.Stderr, "[smoke] docker %s failed: %v\n", strings.Join(full, " "), err) + os.Exit(1) + } +} + +type testState struct { + client *http.Client + token string + bucketName string + objectKey string + sourceBody []byte + accessKeyID string +} + +func (s *testState) do(t *testing.T, method, path string, body io.Reader, contentType string) *http.Response { + t.Helper() + req, err := http.NewRequest(method, backendBaseURL+path, body) + if err != nil { + t.Fatalf("build request: %v", err) + } + if s.token != "" { + req.Header.Set("Authorization", "Bearer "+s.token) + } + if contentType != "" { + req.Header.Set("Content-Type", contentType) + } + resp, err := s.client.Do(req) + if err != nil { + t.Fatalf("%s %s: %v", method, path, err) + } + return resp +} + +func readBody(t *testing.T, resp *http.Response) []byte { + t.Helper() + defer resp.Body.Close() + b, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("read body: %v", err) + } + return b +} + +func TestSmokeGoldenPath(t *testing.T) { + sourcePath := filepath.Join(filepath.Dir(mustComposeFile()), "testdata", "small.bin") + source, err := os.ReadFile(sourcePath) + if err != nil { + t.Fatalf("read fixture: %v", err) + } + + state := &testState{ + client: &http.Client{Timeout: 30 * time.Second}, + bucketName: fmt.Sprintf("smoke-%d", time.Now().UnixNano()), + objectKey: "small.bin", + sourceBody: source, + } + + t.Run("AdminLogin", func(t *testing.T) { + payload := fmt.Sprintf(`{"username":%q,"password":%q}`, adminUsername, adminPassword) + resp := state.do(t, "POST", "/auth/login", strings.NewReader(payload), "application/json") + body := readBody(t, resp) + if resp.StatusCode != 200 { + t.Fatalf("login status = %d, body = %s", resp.StatusCode, body) + } + var parsed struct { + Success bool `json:"success"` + Token string `json:"token"` + } + if err := json.Unmarshal(body, &parsed); err != nil { + t.Fatalf("parse login body: %v, body=%s", err, body) + } + if !parsed.Success || parsed.Token == "" { + t.Fatalf("login did not return token: %s", body) + } + state.token = parsed.Token + }) + + t.Run("CreateBucket", func(t *testing.T) { + if state.token == "" { + t.Skip("login did not succeed") + } + payload := fmt.Sprintf(`{"name":%q}`, state.bucketName) + resp := state.do(t, "POST", "/api/v1/buckets", strings.NewReader(payload), "application/json") + body := readBody(t, resp) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + t.Fatalf("create bucket status = %d, body = %s", resp.StatusCode, body) + } + }) + + t.Run("CreateKey", func(t *testing.T) { + if state.token == "" { + t.Skip("login did not succeed") + } + payload := `{"name":"smoke-key"}` + resp := state.do(t, "POST", "/api/v1/users", strings.NewReader(payload), "application/json") + body := readBody(t, resp) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + t.Fatalf("create key status = %d, body = %s", resp.StatusCode, body) + } + var parsed struct { + Data struct { + AccessKeyID string `json:"accessKeyId"` + } `json:"data"` + } + if err := json.Unmarshal(body, &parsed); err != nil { + t.Fatalf("parse key body: %v, body=%s", err, body) + } + if parsed.Data.AccessKeyID == "" { + t.Fatalf("empty accessKeyId: %s", body) + } + state.accessKeyID = parsed.Data.AccessKeyID + }) + + t.Run("GrantPermission", func(t *testing.T) { + if state.accessKeyID == "" { + t.Skip("key creation did not succeed") + } + payload := fmt.Sprintf( + `{"accessKeyId":%q,"permissions":{"read":true,"write":true,"owner":false}}`, + state.accessKeyID, + ) + path := fmt.Sprintf("/api/v1/buckets/%s/permissions", state.bucketName) + resp := state.do(t, "POST", path, strings.NewReader(payload), "application/json") + body := readBody(t, resp) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + t.Fatalf("grant permission status = %d, body = %s", resp.StatusCode, body) + } + }) + + t.Run("UploadObject", func(t *testing.T) { + if state.token == "" { + t.Skip("login did not succeed") + } + var buf bytes.Buffer + mw := multipart.NewWriter(&buf) + fw, err := mw.CreateFormFile("file", filepath.Base(state.objectKey)) + if err != nil { + t.Fatalf("create form file: %v", err) + } + if _, err := fw.Write(state.sourceBody); err != nil { + t.Fatalf("write form file: %v", err) + } + if err := mw.WriteField("key", state.objectKey); err != nil { + t.Fatalf("write key field: %v", err) + } + if err := mw.Close(); err != nil { + t.Fatalf("close multipart: %v", err) + } + path := fmt.Sprintf("/api/v1/buckets/%s/objects/", state.bucketName) + resp := state.do(t, "POST", path, &buf, mw.FormDataContentType()) + body := readBody(t, resp) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + t.Fatalf("upload status = %d, body = %s", resp.StatusCode, body) + } + }) + + t.Run("ListObjects", func(t *testing.T) { + if state.token == "" { + t.Skip("login did not succeed") + } + path := fmt.Sprintf("/api/v1/buckets/%s/objects/", state.bucketName) + resp := state.do(t, "GET", path, nil, "") + body := readBody(t, resp) + if resp.StatusCode != 200 { + t.Fatalf("list status = %d, body = %s", resp.StatusCode, body) + } + if !bytes.Contains(body, []byte(state.objectKey)) { + t.Fatalf("uploaded key %q not found in list: %s", state.objectKey, body) + } + }) + + t.Run("DownloadObject", func(t *testing.T) { + if state.token == "" { + t.Skip("login did not succeed") + } + path := fmt.Sprintf("/api/v1/buckets/%s/objects/%s", state.bucketName, state.objectKey) + resp := state.do(t, "GET", path, nil, "") + body := readBody(t, resp) + if resp.StatusCode != 200 { + t.Fatalf("download status = %d, body = %s", resp.StatusCode, body) + } + if !bytes.Equal(body, state.sourceBody) { + t.Fatalf("downloaded bytes differ: got %d bytes, want %d bytes", len(body), len(state.sourceBody)) + } + }) + + t.Run("DeleteObject", func(t *testing.T) { + if state.token == "" { + t.Skip("login did not succeed") + } + path := fmt.Sprintf("/api/v1/buckets/%s/objects/%s", state.bucketName, state.objectKey) + resp := state.do(t, "DELETE", path, nil, "") + body := readBody(t, resp) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + t.Fatalf("delete object status = %d, body = %s", resp.StatusCode, body) + } + }) + + t.Run("DeleteBucket", func(t *testing.T) { + if state.token == "" { + t.Skip("login did not succeed") + } + path := fmt.Sprintf("/api/v1/buckets/%s", state.bucketName) + resp := state.do(t, "DELETE", path, nil, "") + body := readBody(t, resp) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + t.Fatalf("delete bucket status = %d, body = %s", resp.StatusCode, body) + } + }) + + t.Run("ListKeys", func(t *testing.T) { + if state.token == "" { + t.Skip("login did not succeed") + } + resp := state.do(t, "GET", "/api/v1/users", nil, "") + body := readBody(t, resp) + if resp.StatusCode != 200 { + t.Fatalf("list users status = %d, body = %s", resp.StatusCode, body) + } + }) +} diff --git a/backend/tests/smoke/testdata/small.bin b/backend/tests/smoke/testdata/small.bin new file mode 100644 index 0000000..4292932 Binary files /dev/null and b/backend/tests/smoke/testdata/small.bin differ diff --git a/scripts/coverage-gate.sh b/scripts/coverage-gate.sh new file mode 100644 index 0000000..58c267c --- /dev/null +++ b/scripts/coverage-gate.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Usage: scripts/coverage-gate.sh +# +# Enforces: +# - Total backend coverage >= 70% (excluding backend/main.go and backend/docs/). +# - Per-package floor >= 70% for each of: +# internal/auth, internal/config, internal/handlers, internal/services. + +if [ "$#" -ne 1 ]; then + echo "usage: $0 " >&2 + exit 2 +fi + +COVERAGE_IN="$1" +TOTAL_FLOOR="70.0" +PER_PKG_FLOOR="70.0" +PER_PKG_GATES=( + "internal/auth" + "internal/config" + "internal/handlers" + "internal/services" +) + +if [ ! -f "$COVERAGE_IN" ]; then + echo "error: coverage file not found: $COVERAGE_IN" >&2 + exit 2 +fi + +FILTERED="coverage-filtered.out" +trap 'rm -f "$FILTERED"' EXIT + +# Keep the mode: line, drop excluded paths. +# Excluded from coverage totals: +# - main.go: entry point, covered transitively by smoke. +# - backend/docs/: generated swagger. +# - mocks/ and *_mock.go: test infrastructure, not production code. +# - services/s3.go: network methods deferred to smoke per parent spec +# (docs/superpowers/specs/2026-04-17-backend-test-suite-design.md §internal/services). +grep -E '^(mode:|.*/)' "$COVERAGE_IN" \ + | grep -v '/main\.go' \ + | grep -v '/docs/' \ + | grep -v '/mocks/' \ + | grep -v '_mock\.go' \ + | grep -v '/services/s3\.go' \ + > "$FILTERED" + +FUNC_OUT=$(cd backend && go tool cover -func="../$FILTERED") +TOTAL_PCT=$(echo "$FUNC_OUT" | awk '/^total:/ {gsub("%",""); print $NF}') + +if [ -z "$TOTAL_PCT" ]; then + echo "error: could not parse total coverage from go tool cover output" >&2 + echo "$FUNC_OUT" >&2 + exit 2 +fi + +echo "== Coverage summary ==" +printf " %-30s %s%%\n" "total" "$TOTAL_PCT" + +FAIL=0 +if awk -v a="$TOTAL_PCT" -v b="$TOTAL_FLOOR" 'BEGIN { exit !(a+0 < b+0) }'; then + echo "FAIL total: ${TOTAL_PCT}% < ${TOTAL_FLOOR}%" + FAIL=1 +fi + +for pkg in "${PER_PKG_GATES[@]}"; do + pkg_pct=$(echo "$FUNC_OUT" \ + | awk -v p="/$pkg/" '$1 ~ p { sub("%","",$NF); sum+=$NF; n++ } END { if (n>0) printf "%.1f", sum/n; else print "n/a" }') + printf " %-30s %s%%\n" "$pkg" "$pkg_pct" + if [ "$pkg_pct" = "n/a" ]; then + echo "FAIL $pkg: no coverage data" + FAIL=1 + continue + fi + if awk -v a="$pkg_pct" -v b="$PER_PKG_FLOOR" 'BEGIN { exit !(a+0 < b+0) }'; then + echo "FAIL $pkg: ${pkg_pct}% < ${PER_PKG_FLOOR}%" + FAIL=1 + fi +done + +if [ "$FAIL" -ne 0 ]; then + exit 1 +fi + +echo "PASS"