//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) } }) }