Files
garage-ui/backend/tests/smoke/smoke_test.go
T

389 lines
11 KiB
Go

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