mirror of
https://github.com/Noooste/garage-ui.git
synced 2026-07-26 07:48:13 +00:00
361756859a
Signed-off-by: Noooste <83548733+Noooste@users.noreply.github.com>
389 lines
11 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|