mirror of
https://github.com/Noooste/garage-ui.git
synced 2026-07-26 15:58:13 +00:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b8061e5109 | |||
| cfa13be75b | |||
| adb430c2b2 | |||
| 849840b808 | |||
| 23d63be980 | |||
| 5b12c8121f | |||
| 785d21d38c | |||
| c2e99afb8b | |||
| 691ed8db4d | |||
| caf8d9fd2e | |||
| f2cc660c8f | |||
| 5c2e5dbf6f | |||
| adfd044798 | |||
| f48d6f0812 | |||
| 1cd9e734bc | |||
| eb1e7fe96a | |||
| af7af32d4e |
@@ -14,9 +14,11 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
platform:
|
||||
- linux/amd64
|
||||
- linux/arm64
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
arch: amd64
|
||||
- platform: linux/arm64
|
||||
arch: arm64
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@@ -52,8 +54,12 @@ jobs:
|
||||
platforms: ${{ matrix.platform }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: VERSION=${{ steps.meta.outputs.version }}
|
||||
cache-from: type=gha,scope=build-${{ matrix.platform }}
|
||||
cache-to: type=gha,mode=max,scope=build-${{ matrix.platform }}
|
||||
cache-from: |
|
||||
type=gha,scope=build-${{ matrix.arch }}
|
||||
type=registry,ref=${{ secrets.DOCKER_REGISTRY }}:buildcache-${{ matrix.arch }}
|
||||
cache-to: |
|
||||
type=gha,mode=max,scope=build-${{ matrix.arch }}
|
||||
type=registry,ref=${{ secrets.DOCKER_REGISTRY }}:buildcache-${{ matrix.arch }},mode=max
|
||||
outputs: type=image,name=${{ secrets.DOCKER_REGISTRY }},push-by-digest=true,name-canonical=true,push=true
|
||||
|
||||
- name: Export digest
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
|
||||
- name: Generate swagger docs
|
||||
run: |
|
||||
go install github.com/swaggo/swag/cmd/swag@latest
|
||||
go install github.com/swaggo/swag/cmd/swag@v1.16.4
|
||||
cd backend && swag init
|
||||
|
||||
- name: Run unit tests with race detector and coverage
|
||||
@@ -66,6 +66,19 @@ jobs:
|
||||
cache: true
|
||||
cache-dependency-path: backend/go.sum
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Pre-build backend image with layer cache
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
load: true
|
||||
tags: garage-ui-smoke-backend:ci
|
||||
cache-from: type=gha,scope=smoke-backend
|
||||
cache-to: type=gha,mode=max,scope=smoke-backend
|
||||
|
||||
- name: Run smoke test
|
||||
run: make test-smoke
|
||||
|
||||
|
||||
+1
-1
@@ -20,7 +20,7 @@ WORKDIR /app
|
||||
|
||||
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||
--mount=type=cache,target=/root/.cache/go-build \
|
||||
go install github.com/swaggo/swag/cmd/swag@latest
|
||||
go install github.com/swaggo/swag/cmd/swag@v1.16.4
|
||||
|
||||
COPY backend/go.mod backend/go.sum ./
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
// Package apierr translates upstream (Garage Admin API, S3/MinIO) errors into
|
||||
// backend API error responses with correct HTTP status codes and stable codes.
|
||||
package apierr
|
||||
|
||||
import "fmt"
|
||||
|
||||
// UpstreamError is a typed error describing a failure reported by an upstream
|
||||
// service. Returned from the services layer and consumed by handlers via Map
|
||||
// or Respond.
|
||||
type UpstreamError struct {
|
||||
HTTPStatus int
|
||||
Code string // upstream code, e.g. "BucketNotEmpty", "NoSuchKey"
|
||||
Message string // human-readable upstream message
|
||||
Source string // "garage" or "s3"
|
||||
Details map[string]string // optional: region, path, bucket, key
|
||||
}
|
||||
|
||||
func (e *UpstreamError) Error() string {
|
||||
if e.Code == "" {
|
||||
return fmt.Sprintf("%s: %s", e.Source, e.Message)
|
||||
}
|
||||
return fmt.Sprintf("%s %s: %s", e.Source, e.Code, e.Message)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package apierr
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestUpstreamError_Error(t *testing.T) {
|
||||
e := &UpstreamError{
|
||||
HTTPStatus: 409,
|
||||
Code: "BucketNotEmpty",
|
||||
Message: "Tried to delete a non-empty bucket",
|
||||
Source: "garage",
|
||||
}
|
||||
got := e.Error()
|
||||
want := "garage BucketNotEmpty: Tried to delete a non-empty bucket"
|
||||
if got != want {
|
||||
t.Fatalf("Error() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpstreamError_ErrorWithoutCode(t *testing.T) {
|
||||
e := &UpstreamError{HTTPStatus: 500, Source: "garage", Message: "server went boom"}
|
||||
got := e.Error()
|
||||
want := "garage: server went boom"
|
||||
if got != want {
|
||||
t.Fatalf("Error() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpstreamError_ErrorsAs(t *testing.T) {
|
||||
var base error = &UpstreamError{HTTPStatus: 404, Code: "NoSuchBucket", Source: "garage"}
|
||||
var target *UpstreamError
|
||||
if !errors.As(base, &target) {
|
||||
t.Fatal("errors.As should have matched *UpstreamError")
|
||||
}
|
||||
if target.Code != "NoSuchBucket" {
|
||||
t.Fatalf("Code = %q, want NoSuchBucket", target.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package apierr
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"Noooste/garage-ui/internal/models"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
// codeEntry defines how an upstream code translates to the API surface.
|
||||
type codeEntry struct {
|
||||
HTTPStatus int
|
||||
APICode string
|
||||
}
|
||||
|
||||
// upstreamCodeTable maps upstream codes (Garage + S3 share the same naming for
|
||||
// most codes) to (httpStatus, apiCode). Extend here as new upstream codes are
|
||||
// discovered.
|
||||
var upstreamCodeTable = map[string]codeEntry{
|
||||
"BucketNotEmpty": {409, models.ErrCodeBucketNotEmpty},
|
||||
"NoSuchBucket": {404, models.ErrCodeBucketNotFound},
|
||||
"BucketAlreadyExists": {409, models.ErrCodeBucketExists},
|
||||
"BucketAlreadyOwnedByYou": {409, models.ErrCodeBucketExists},
|
||||
"NoSuchKey": {404, models.ErrCodeObjectNotFound},
|
||||
"InvalidBucketName": {400, models.ErrCodeInvalidBucketName},
|
||||
"AccessDenied": {403, models.ErrCodeForbidden},
|
||||
}
|
||||
|
||||
// Map translates an error into (httpStatus, apiCode, message) for the API
|
||||
// response. Falls back to 500 / INTERNAL_ERROR for non-UpstreamError values.
|
||||
func Map(err error) (int, string, string) {
|
||||
var ue *UpstreamError
|
||||
if !errors.As(err, &ue) {
|
||||
return 500, models.ErrCodeInternalError, err.Error()
|
||||
}
|
||||
|
||||
if entry, ok := upstreamCodeTable[ue.Code]; ok {
|
||||
return entry.HTTPStatus, entry.APICode, ue.Message
|
||||
}
|
||||
|
||||
status := ue.HTTPStatus
|
||||
if status == 0 {
|
||||
status = 500
|
||||
}
|
||||
msg := ue.Message
|
||||
if msg == "" {
|
||||
msg = ue.Error()
|
||||
}
|
||||
return status, models.ErrCodeInternalError, msg
|
||||
}
|
||||
|
||||
// Respond writes the mapped error as a Fiber JSON response using the standard
|
||||
// APIResponse envelope. Handlers should call this from their err != nil
|
||||
// branches for all upstream failures.
|
||||
func Respond(c fiber.Ctx, err error) error {
|
||||
status, code, msg := Map(err)
|
||||
return c.Status(status).JSON(models.ErrorResponse(code, msg))
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package apierr
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"Noooste/garage-ui/internal/models"
|
||||
)
|
||||
|
||||
func TestMap_TableDriven(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
err error
|
||||
wantStatus int
|
||||
wantCode string
|
||||
wantMsg string
|
||||
}{
|
||||
{
|
||||
name: "BucketNotEmpty",
|
||||
err: &UpstreamError{HTTPStatus: 409, Code: "BucketNotEmpty", Message: "Tried to delete a non-empty bucket", Source: "garage"},
|
||||
wantStatus: 409,
|
||||
wantCode: models.ErrCodeBucketNotEmpty,
|
||||
wantMsg: "Tried to delete a non-empty bucket",
|
||||
},
|
||||
{
|
||||
name: "NoSuchBucket",
|
||||
err: &UpstreamError{HTTPStatus: 404, Code: "NoSuchBucket", Message: "missing", Source: "s3"},
|
||||
wantStatus: 404,
|
||||
wantCode: models.ErrCodeBucketNotFound,
|
||||
wantMsg: "missing",
|
||||
},
|
||||
{
|
||||
name: "BucketAlreadyExists",
|
||||
err: &UpstreamError{HTTPStatus: 409, Code: "BucketAlreadyExists", Message: "dup", Source: "s3"},
|
||||
wantStatus: 409,
|
||||
wantCode: models.ErrCodeBucketExists,
|
||||
wantMsg: "dup",
|
||||
},
|
||||
{
|
||||
name: "BucketAlreadyOwnedByYou",
|
||||
err: &UpstreamError{HTTPStatus: 409, Code: "BucketAlreadyOwnedByYou", Message: "yours", Source: "s3"},
|
||||
wantStatus: 409,
|
||||
wantCode: models.ErrCodeBucketExists,
|
||||
wantMsg: "yours",
|
||||
},
|
||||
{
|
||||
name: "NoSuchKey",
|
||||
err: &UpstreamError{HTTPStatus: 404, Code: "NoSuchKey", Message: "gone", Source: "s3"},
|
||||
wantStatus: 404,
|
||||
wantCode: models.ErrCodeObjectNotFound,
|
||||
wantMsg: "gone",
|
||||
},
|
||||
{
|
||||
name: "InvalidBucketName",
|
||||
err: &UpstreamError{HTTPStatus: 400, Code: "InvalidBucketName", Message: "bad", Source: "s3"},
|
||||
wantStatus: 400,
|
||||
wantCode: models.ErrCodeInvalidBucketName,
|
||||
wantMsg: "bad",
|
||||
},
|
||||
{
|
||||
name: "AccessDenied",
|
||||
err: &UpstreamError{HTTPStatus: 403, Code: "AccessDenied", Message: "nope", Source: "garage"},
|
||||
wantStatus: 403,
|
||||
wantCode: models.ErrCodeForbidden,
|
||||
wantMsg: "nope",
|
||||
},
|
||||
{
|
||||
name: "UnknownCodeWithStatus",
|
||||
err: &UpstreamError{HTTPStatus: 503, Code: "SomethingWeird", Message: "weird", Source: "garage"},
|
||||
wantStatus: 503,
|
||||
wantCode: models.ErrCodeInternalError,
|
||||
wantMsg: "weird",
|
||||
},
|
||||
{
|
||||
name: "UnknownCodeNoStatus",
|
||||
err: &UpstreamError{HTTPStatus: 0, Code: "", Message: "boom", Source: "garage"},
|
||||
wantStatus: 500,
|
||||
wantCode: models.ErrCodeInternalError,
|
||||
wantMsg: "boom",
|
||||
},
|
||||
{
|
||||
name: "NonUpstreamError",
|
||||
err: errors.New("plain"),
|
||||
wantStatus: 500,
|
||||
wantCode: models.ErrCodeInternalError,
|
||||
wantMsg: "plain",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
status, code, msg := Map(tc.err)
|
||||
if status != tc.wantStatus {
|
||||
t.Errorf("status = %d, want %d", status, tc.wantStatus)
|
||||
}
|
||||
if code != tc.wantCode {
|
||||
t.Errorf("code = %q, want %q", code, tc.wantCode)
|
||||
}
|
||||
if msg != tc.wantMsg {
|
||||
t.Errorf("msg = %q, want %q", msg, tc.wantMsg)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package apierr
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
|
||||
"github.com/Noooste/azuretls-client"
|
||||
"github.com/minio/minio-go/v7"
|
||||
)
|
||||
|
||||
// garageErrBody mirrors Garage's JSON error envelope.
|
||||
type garageErrBody struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Region string `json:"region"`
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
// ParseGarage returns nil for 2xx responses. For non-2xx it reads the body
|
||||
// once, JSON-decodes the structured Garage error envelope, and returns a
|
||||
// *UpstreamError. Malformed bodies are preserved verbatim in Message.
|
||||
//
|
||||
// ParseGarage DOES NOT close resp.RawBody on the success path — callers that
|
||||
// decode the success body (decodeResponse in services/admin.go) still need
|
||||
// access and are responsible for closing. On the error path the body is fully
|
||||
// consumed before return.
|
||||
func ParseGarage(resp *azuretls.Response) error {
|
||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||
return nil
|
||||
}
|
||||
|
||||
bodyBytes, _ := io.ReadAll(resp.RawBody)
|
||||
|
||||
ue := &UpstreamError{
|
||||
HTTPStatus: resp.StatusCode,
|
||||
Source: "garage",
|
||||
}
|
||||
|
||||
var parsed garageErrBody
|
||||
if len(bodyBytes) > 0 && json.Unmarshal(bodyBytes, &parsed) == nil && parsed.Code != "" {
|
||||
ue.Code = parsed.Code
|
||||
ue.Message = parsed.Message
|
||||
ue.Details = map[string]string{}
|
||||
if parsed.Region != "" {
|
||||
ue.Details["region"] = parsed.Region
|
||||
}
|
||||
if parsed.Path != "" {
|
||||
ue.Details["path"] = parsed.Path
|
||||
}
|
||||
} else {
|
||||
ue.Message = string(bodyBytes)
|
||||
}
|
||||
return ue
|
||||
}
|
||||
|
||||
// FromMinio converts a MinIO/S3 error into an *UpstreamError. Returns nil when
|
||||
// err is nil. If err is not a minio.ErrorResponse (e.g. a network error), a
|
||||
// generic 500 *UpstreamError is returned with the raw error string as Message.
|
||||
func FromMinio(err error) *UpstreamError {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var mer minio.ErrorResponse
|
||||
if errors.As(err, &mer) {
|
||||
details := map[string]string{}
|
||||
if mer.BucketName != "" {
|
||||
details["bucket"] = mer.BucketName
|
||||
}
|
||||
if mer.Key != "" {
|
||||
details["key"] = mer.Key
|
||||
}
|
||||
status := mer.StatusCode
|
||||
if status == 0 {
|
||||
status = 500
|
||||
}
|
||||
return &UpstreamError{
|
||||
HTTPStatus: status,
|
||||
Code: mer.Code,
|
||||
Message: mer.Message,
|
||||
Source: "s3",
|
||||
Details: details,
|
||||
}
|
||||
}
|
||||
|
||||
return &UpstreamError{
|
||||
HTTPStatus: 500,
|
||||
Source: "s3",
|
||||
Message: err.Error(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package apierr
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Noooste/azuretls-client"
|
||||
"github.com/minio/minio-go/v7"
|
||||
)
|
||||
|
||||
func fakeResp(status int, body string) *azuretls.Response {
|
||||
return &azuretls.Response{
|
||||
StatusCode: status,
|
||||
RawBody: io.NopCloser(bytes.NewBufferString(body)),
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseGarage_Success(t *testing.T) {
|
||||
resp := fakeResp(200, `{"foo":"bar"}`)
|
||||
if err := ParseGarage(resp); err != nil {
|
||||
t.Fatalf("ParseGarage(2xx) returned %v, want nil", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseGarage_StructuredError(t *testing.T) {
|
||||
body := `{"code":"BucketNotEmpty","message":"Tried to delete a non-empty bucket","region":"eu-west-1","path":"/v2/DeleteBucket"}`
|
||||
resp := fakeResp(409, body)
|
||||
|
||||
err := ParseGarage(resp)
|
||||
ue, ok := err.(*UpstreamError)
|
||||
if !ok {
|
||||
t.Fatalf("ParseGarage returned %T, want *UpstreamError", err)
|
||||
}
|
||||
if ue.HTTPStatus != 409 {
|
||||
t.Errorf("HTTPStatus = %d, want 409", ue.HTTPStatus)
|
||||
}
|
||||
if ue.Code != "BucketNotEmpty" {
|
||||
t.Errorf("Code = %q, want BucketNotEmpty", ue.Code)
|
||||
}
|
||||
if ue.Message != "Tried to delete a non-empty bucket" {
|
||||
t.Errorf("Message = %q", ue.Message)
|
||||
}
|
||||
if ue.Source != "garage" {
|
||||
t.Errorf("Source = %q, want garage", ue.Source)
|
||||
}
|
||||
if ue.Details["region"] != "eu-west-1" || ue.Details["path"] != "/v2/DeleteBucket" {
|
||||
t.Errorf("Details = %v", ue.Details)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseGarage_MalformedBody(t *testing.T) {
|
||||
resp := fakeResp(500, "not json at all")
|
||||
|
||||
err := ParseGarage(resp)
|
||||
ue, ok := err.(*UpstreamError)
|
||||
if !ok {
|
||||
t.Fatalf("ParseGarage returned %T, want *UpstreamError", err)
|
||||
}
|
||||
if ue.HTTPStatus != 500 || ue.Code != "" {
|
||||
t.Errorf("HTTPStatus=%d Code=%q, want 500 and empty code", ue.HTTPStatus, ue.Code)
|
||||
}
|
||||
if !strings.Contains(ue.Message, "not json at all") {
|
||||
t.Errorf("Message = %q, expected raw body", ue.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseGarage_EmptyBody(t *testing.T) {
|
||||
resp := fakeResp(502, "")
|
||||
|
||||
err := ParseGarage(resp)
|
||||
ue, ok := err.(*UpstreamError)
|
||||
if !ok {
|
||||
t.Fatalf("ParseGarage returned %T, want *UpstreamError", err)
|
||||
}
|
||||
if ue.HTTPStatus != 502 {
|
||||
t.Errorf("HTTPStatus = %d, want 502", ue.HTTPStatus)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFromMinio_NilInput(t *testing.T) {
|
||||
if got := FromMinio(nil); got != nil {
|
||||
t.Fatalf("FromMinio(nil) = %v, want nil", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFromMinio_MinioErrorResponse(t *testing.T) {
|
||||
in := minio.ErrorResponse{
|
||||
StatusCode: 404,
|
||||
Code: "NoSuchBucket",
|
||||
Message: "The specified bucket does not exist",
|
||||
BucketName: "missing",
|
||||
}
|
||||
got := FromMinio(in)
|
||||
if got == nil {
|
||||
t.Fatal("FromMinio returned nil")
|
||||
}
|
||||
if got.HTTPStatus != 404 {
|
||||
t.Errorf("HTTPStatus = %d, want 404", got.HTTPStatus)
|
||||
}
|
||||
if got.Code != "NoSuchBucket" {
|
||||
t.Errorf("Code = %q, want NoSuchBucket", got.Code)
|
||||
}
|
||||
if got.Message != "The specified bucket does not exist" {
|
||||
t.Errorf("Message = %q", got.Message)
|
||||
}
|
||||
if got.Source != "s3" {
|
||||
t.Errorf("Source = %q, want s3", got.Source)
|
||||
}
|
||||
if got.Details["bucket"] != "missing" {
|
||||
t.Errorf("Details[bucket] = %q, want missing", got.Details["bucket"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestFromMinio_NonMinioError(t *testing.T) {
|
||||
got := FromMinio(errors.New("connection refused"))
|
||||
if got == nil {
|
||||
t.Fatal("FromMinio returned nil")
|
||||
}
|
||||
if got.HTTPStatus != 500 {
|
||||
t.Errorf("HTTPStatus = %d, want 500", got.HTTPStatus)
|
||||
}
|
||||
if got.Code != "" {
|
||||
t.Errorf("Code = %q, want empty", got.Code)
|
||||
}
|
||||
if !strings.Contains(got.Message, "connection refused") {
|
||||
t.Errorf("Message = %q", got.Message)
|
||||
}
|
||||
if got.Source != "s3" {
|
||||
t.Errorf("Source = %q, want s3", got.Source)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"Noooste/garage-ui/internal/apierr"
|
||||
"Noooste/garage-ui/internal/models"
|
||||
"Noooste/garage-ui/internal/services"
|
||||
|
||||
@@ -37,9 +38,7 @@ func (h *BucketHandler) ListBuckets(c fiber.Ctx) error {
|
||||
// List all buckets from Garage Admin API
|
||||
adminBuckets, err := h.adminService.ListBuckets(ctx)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeListFailed, "Failed to list buckets: "+err.Error()),
|
||||
)
|
||||
return apierr.Respond(c, err)
|
||||
}
|
||||
|
||||
// Convert admin bucket response to BucketInfo
|
||||
@@ -124,9 +123,7 @@ func (h *BucketHandler) CreateBucket(c fiber.Ctx) error {
|
||||
}
|
||||
|
||||
if _, err := h.adminService.CreateBucket(ctx, createBucketReq); err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeInternalError, "Failed to create bucket: "+err.Error()),
|
||||
)
|
||||
return apierr.Respond(c, err)
|
||||
}
|
||||
|
||||
// Return success response
|
||||
@@ -165,9 +162,7 @@ func (h *BucketHandler) DeleteBucket(c fiber.Ctx) error {
|
||||
// Check if bucket already exists
|
||||
bucketInfo, err := h.adminService.GetBucketInfoByAlias(ctx, bucketName)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeInternalError, "Failed to check bucket existence: "+err.Error()),
|
||||
)
|
||||
return apierr.Respond(c, err)
|
||||
}
|
||||
|
||||
if bucketInfo == nil {
|
||||
@@ -178,9 +173,7 @@ func (h *BucketHandler) DeleteBucket(c fiber.Ctx) error {
|
||||
|
||||
// Delete the bucket
|
||||
if err := h.adminService.DeleteBucket(ctx, bucketInfo.ID); err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeDeleteFailed, "Failed to delete bucket: "+err.Error()),
|
||||
)
|
||||
return apierr.Respond(c, err)
|
||||
}
|
||||
|
||||
// Return success response
|
||||
@@ -219,9 +212,7 @@ func (h *BucketHandler) GetBucketInfo(c fiber.Ctx) error {
|
||||
// Check if bucket already exists
|
||||
bucketInfo, err := h.adminService.GetBucketInfoByAlias(ctx, bucketName)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeInternalError, "Failed to check bucket existence: "+err.Error()),
|
||||
)
|
||||
return apierr.Respond(c, err)
|
||||
}
|
||||
|
||||
if bucketInfo == nil {
|
||||
@@ -276,9 +267,7 @@ func (h *BucketHandler) GrantBucketPermission(c fiber.Ctx) error {
|
||||
// Get bucket info to retrieve bucket ID
|
||||
bucketInfo, err := h.adminService.GetBucketInfoByAlias(ctx, bucketName)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeInternalError, "Failed to get bucket info: "+err.Error()),
|
||||
)
|
||||
return apierr.Respond(c, err)
|
||||
}
|
||||
|
||||
if bucketInfo == nil {
|
||||
@@ -310,9 +299,7 @@ func (h *BucketHandler) GrantBucketPermission(c fiber.Ctx) error {
|
||||
Permissions: allow,
|
||||
})
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeInternalError, "Failed to grant permissions: "+err.Error()),
|
||||
)
|
||||
return apierr.Respond(c, err)
|
||||
}
|
||||
result = r
|
||||
}
|
||||
@@ -324,9 +311,7 @@ func (h *BucketHandler) GrantBucketPermission(c fiber.Ctx) error {
|
||||
Permissions: deny,
|
||||
})
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeInternalError, "Failed to revoke permissions: "+err.Error()),
|
||||
)
|
||||
return apierr.Respond(c, err)
|
||||
}
|
||||
result = r
|
||||
}
|
||||
@@ -336,9 +321,7 @@ func (h *BucketHandler) GrantBucketPermission(c fiber.Ctx) error {
|
||||
// Fetch current bucket state to return a consistent response.
|
||||
r, err := h.adminService.GetBucketInfo(ctx, bucketInfo.ID)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeInternalError, "Failed to fetch bucket info: "+err.Error()),
|
||||
)
|
||||
return apierr.Respond(c, err)
|
||||
}
|
||||
result = r
|
||||
}
|
||||
@@ -385,9 +368,7 @@ func (h *BucketHandler) UpdateBucketWebsite(c fiber.Ctx) error {
|
||||
|
||||
bucketInfo, err := h.adminService.GetBucketInfoByAlias(ctx, bucketName)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeInternalError, "Failed to get bucket info: "+err.Error()),
|
||||
)
|
||||
return apierr.Respond(c, err)
|
||||
}
|
||||
|
||||
if bucketInfo == nil {
|
||||
@@ -412,9 +393,7 @@ func (h *BucketHandler) UpdateBucketWebsite(c fiber.Ctx) error {
|
||||
|
||||
result, err := h.adminService.UpdateBucket(ctx, bucketInfo.ID, updateReq)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeInternalError, "Failed to update bucket website: "+err.Error()),
|
||||
)
|
||||
return apierr.Respond(c, err)
|
||||
}
|
||||
|
||||
return c.JSON(models.SuccessResponse(result))
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"Noooste/garage-ui/internal/apierr"
|
||||
"Noooste/garage-ui/internal/models"
|
||||
"Noooste/garage-ui/internal/services/mocks"
|
||||
|
||||
@@ -294,10 +295,19 @@ func TestGrantBucketPermission_Success(t *testing.T) {
|
||||
}
|
||||
admin.AllowBucketKeyFn = func(_ context.Context, req models.BucketKeyPermRequest) (*models.GarageBucketInfo, error) {
|
||||
if req.BucketID != "id-1" || req.AccessKeyID != "AKIA" {
|
||||
t.Errorf("req = %+v", req)
|
||||
t.Errorf("allow req = %+v", req)
|
||||
}
|
||||
if !req.Permissions.Read || !req.Permissions.Write || req.Permissions.Owner {
|
||||
t.Errorf("perms = %+v", req.Permissions)
|
||||
t.Errorf("allow perms = %+v", req.Permissions)
|
||||
}
|
||||
return &models.GarageBucketInfo{ID: "id-1"}, nil
|
||||
}
|
||||
admin.DenyBucketKeyFn = func(_ context.Context, req models.BucketKeyPermRequest) (*models.GarageBucketInfo, error) {
|
||||
if req.BucketID != "id-1" || req.AccessKeyID != "AKIA" {
|
||||
t.Errorf("deny req = %+v", req)
|
||||
}
|
||||
if req.Permissions.Read || req.Permissions.Write || !req.Permissions.Owner {
|
||||
t.Errorf("deny perms = %+v", req.Permissions)
|
||||
}
|
||||
return &models.GarageBucketInfo{ID: "id-1"}, nil
|
||||
}
|
||||
@@ -417,3 +427,37 @@ func TestUpdateBucketWebsite_Disable(t *testing.T) {
|
||||
t.Fatalf("status = %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteBucket_UpstreamBucketNotEmpty(t *testing.T) {
|
||||
app, admin := newBucketsTestApp(t)
|
||||
admin.GetBucketInfoByAliasFn = func(_ context.Context, _ string) (*models.GarageBucketInfo, error) {
|
||||
return &models.GarageBucketInfo{ID: "id-1"}, nil
|
||||
}
|
||||
admin.DeleteBucketFn = func(_ context.Context, _ string) error {
|
||||
return &apierr.UpstreamError{
|
||||
HTTPStatus: 409,
|
||||
Code: "BucketNotEmpty",
|
||||
Message: "Tried to delete a non-empty bucket",
|
||||
Source: "garage",
|
||||
}
|
||||
}
|
||||
resp, err := app.Test(httptest.NewRequest(http.MethodDelete, "/buckets/alpha", nil))
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusConflict {
|
||||
t.Fatalf("status = %d, want 409", resp.StatusCode)
|
||||
}
|
||||
var body models.APIResponse
|
||||
decodeJSON(t, resp.Body, &body)
|
||||
if body.Error == nil {
|
||||
t.Fatal("error missing from response body")
|
||||
}
|
||||
if body.Error.Code != models.ErrCodeBucketNotEmpty {
|
||||
t.Errorf("code = %q, want %s", body.Error.Code, models.ErrCodeBucketNotEmpty)
|
||||
}
|
||||
if body.Error.Message != "Tried to delete a non-empty bucket" {
|
||||
t.Errorf("message = %q", body.Error.Message)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"Noooste/garage-ui/internal/apierr"
|
||||
"Noooste/garage-ui/internal/models"
|
||||
"Noooste/garage-ui/internal/services"
|
||||
|
||||
@@ -122,9 +123,7 @@ func (h *ObjectHandler) ListObjects(c fiber.Ctx) error {
|
||||
// List objects in the bucket
|
||||
objects, err := h.s3Service.ListObjects(ctx, bucketName, prefix, maxKeys, continuationToken)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeListFailed, "Failed to list objects: "+err.Error()),
|
||||
)
|
||||
return apierr.Respond(c, err)
|
||||
}
|
||||
|
||||
return c.JSON(models.SuccessResponse(objects))
|
||||
@@ -186,9 +185,7 @@ func (h *ObjectHandler) UploadObject(c fiber.Ctx) error {
|
||||
// Upload to Garage
|
||||
uploadResult, err := h.s3Service.UploadObject(ctx, bucketName, key, fileHandle, contentType)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeUploadFailed, "Failed to upload object: "+err.Error()),
|
||||
)
|
||||
return apierr.Respond(c, err)
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusCreated).JSON(models.SuccessResponse(uploadResult))
|
||||
@@ -238,9 +235,7 @@ func (h *ObjectHandler) CreateDirectory(c fiber.Ctx) error {
|
||||
|
||||
result, err := h.s3Service.CreateDirectoryMarker(ctx, bucketName, key)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeUploadFailed, "Failed to create directory: "+err.Error()),
|
||||
)
|
||||
return apierr.Respond(c, err)
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusCreated).JSON(models.SuccessResponse(result))
|
||||
@@ -281,9 +276,7 @@ func (h *ObjectHandler) GetObject(c fiber.Ctx) error {
|
||||
// Get object from Garage
|
||||
body, objectInfo, err := h.s3Service.GetObject(ctx, bucketName, key)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusNotFound).JSON(
|
||||
models.ErrorResponse(models.ErrCodeObjectNotFound, "Object not found: "+err.Error()),
|
||||
)
|
||||
return apierr.Respond(c, err)
|
||||
}
|
||||
|
||||
// The uploader controls Content-Type. Rewrite executable MIME types to
|
||||
@@ -345,9 +338,7 @@ func (h *ObjectHandler) DeleteObject(c fiber.Ctx) error {
|
||||
// Check if object exists
|
||||
exists, err := h.s3Service.ObjectExists(ctx, bucketName, key)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeInternalError, "Failed to check object existence: "+err.Error()),
|
||||
)
|
||||
return apierr.Respond(c, err)
|
||||
}
|
||||
|
||||
if !exists {
|
||||
@@ -358,9 +349,7 @@ func (h *ObjectHandler) DeleteObject(c fiber.Ctx) error {
|
||||
|
||||
// Delete the object
|
||||
if err := h.s3Service.DeleteObject(ctx, bucketName, key); err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeDeleteFailed, "Failed to delete object: "+err.Error()),
|
||||
)
|
||||
return apierr.Respond(c, err)
|
||||
}
|
||||
|
||||
// Return success response
|
||||
@@ -407,9 +396,7 @@ func (h *ObjectHandler) GetObjectMetadata(c fiber.Ctx) error {
|
||||
// Get object metadata
|
||||
metadata, err := h.s3Service.GetObjectMetadata(ctx, bucketName, key)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusNotFound).JSON(
|
||||
models.ErrorResponse(models.ErrCodeObjectNotFound, "Object not found: "+err.Error()),
|
||||
)
|
||||
return apierr.Respond(c, err)
|
||||
}
|
||||
|
||||
return c.JSON(models.SuccessResponse(metadata))
|
||||
@@ -467,9 +454,7 @@ func (h *ObjectHandler) GetPresignedURL(c fiber.Ctx) error {
|
||||
// Check if object exists
|
||||
exists, err := h.s3Service.ObjectExists(ctx, bucketName, key)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeInternalError, "Failed to check object existence: "+err.Error()),
|
||||
)
|
||||
return apierr.Respond(c, err)
|
||||
}
|
||||
|
||||
if !exists {
|
||||
@@ -481,9 +466,7 @@ func (h *ObjectHandler) GetPresignedURL(c fiber.Ctx) error {
|
||||
// Generate pre-signed URL
|
||||
url, err := h.s3Service.GetPresignedURL(ctx, bucketName, key, time.Duration(expiresIn)*time.Second)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeInternalError, "Failed to generate pre-signed URL: "+err.Error()),
|
||||
)
|
||||
return apierr.Respond(c, err)
|
||||
}
|
||||
|
||||
response := models.PresignedURLResponse{
|
||||
@@ -540,9 +523,7 @@ func (h *ObjectHandler) DeleteMultipleObjects(c fiber.Ctx) error {
|
||||
|
||||
// Delete multiple objects
|
||||
if err := h.s3Service.DeleteMultipleObjects(ctx, bucketName, req.Keys); err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeDeleteFailed, "Failed to delete objects: "+err.Error()),
|
||||
)
|
||||
return apierr.Respond(c, err)
|
||||
}
|
||||
|
||||
response := models.ObjectDeleteMultipleResponse{
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"Noooste/garage-ui/internal/apierr"
|
||||
"Noooste/garage-ui/internal/models"
|
||||
"Noooste/garage-ui/internal/services"
|
||||
"Noooste/garage-ui/internal/services/mocks"
|
||||
@@ -144,18 +145,20 @@ func TestGetObjectMetadata_Success(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetObjectMetadata_NotFound404(t *testing.T) {
|
||||
func TestGetObjectMetadata_ServiceError500(t *testing.T) {
|
||||
app, s3 := newObjectsTestApp(t)
|
||||
s3.GetObjectMetadataFn = func(_ context.Context, _, _ string) (*models.ObjectInfo, error) {
|
||||
return nil, errors.New("not found")
|
||||
return nil, errors.New("boom")
|
||||
}
|
||||
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/objects/nope/metadata", nil))
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404", resp.StatusCode)
|
||||
// Generic errors map to 500/INTERNAL_ERROR; typed upstream NoSuchKey → 404
|
||||
// is covered by Task 12 tests.
|
||||
if resp.StatusCode != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want 500", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -363,18 +366,20 @@ func TestGetObject_DownloadQuerySetsAttachment(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetObject_ServiceErrorReturns404(t *testing.T) {
|
||||
func TestGetObject_ServiceErrorReturns500(t *testing.T) {
|
||||
app, s3 := newObjectsTestApp(t)
|
||||
s3.GetObjectFn = func(_ context.Context, _, _ string) (io.ReadCloser, *models.ObjectInfo, error) {
|
||||
return nil, nil, errors.New("not found")
|
||||
return nil, nil, errors.New("boom")
|
||||
}
|
||||
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/objects/nope", nil))
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404", resp.StatusCode)
|
||||
// Generic errors map to 500/INTERNAL_ERROR; typed upstream NoSuchKey → 404
|
||||
// is covered by Task 12 tests.
|
||||
if resp.StatusCode != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want 500", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -864,3 +869,32 @@ func TestCreateDirectory_ServiceError500(t *testing.T) {
|
||||
t.Fatalf("status = %d, want 500", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteObject_UpstreamNoSuchKey(t *testing.T) {
|
||||
app, s3 := newObjectsTestApp(t)
|
||||
s3.ObjectExistsFn = func(_ context.Context, _, _ string) (bool, error) { return true, nil }
|
||||
s3.DeleteObjectFn = func(_ context.Context, _, _ string) error {
|
||||
return &apierr.UpstreamError{
|
||||
HTTPStatus: 404,
|
||||
Code: "NoSuchKey",
|
||||
Message: "The specified key does not exist",
|
||||
Source: "s3",
|
||||
}
|
||||
}
|
||||
resp, err := app.Test(httptest.NewRequest(http.MethodDelete, "/buckets/b1/objects/foo.txt", nil))
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404", resp.StatusCode)
|
||||
}
|
||||
var body models.APIResponse
|
||||
decodeJSON(t, resp.Body, &body)
|
||||
if body.Error == nil || body.Error.Code != models.ErrCodeObjectNotFound {
|
||||
t.Fatalf("error = %+v, want code %s", body.Error, models.ErrCodeObjectNotFound)
|
||||
}
|
||||
if body.Error.Message != "The specified key does not exist" {
|
||||
t.Errorf("message = %q", body.Error.Message)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package handlers
|
||||
import (
|
||||
"time"
|
||||
|
||||
"Noooste/garage-ui/internal/apierr"
|
||||
"Noooste/garage-ui/internal/models"
|
||||
"Noooste/garage-ui/internal/services"
|
||||
|
||||
@@ -35,9 +36,7 @@ func (h *UserHandler) ListUsers(c fiber.Ctx) error {
|
||||
|
||||
keys, err := h.adminService.ListKeys(ctx)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeInternalError, "Failed to list users: "+err.Error()),
|
||||
)
|
||||
return apierr.Respond(c, err)
|
||||
}
|
||||
|
||||
// Convert to UserInfo format
|
||||
@@ -135,9 +134,7 @@ func (h *UserHandler) CreateUser(c fiber.Ctx) error {
|
||||
// Create the key
|
||||
keyInfo, err := h.adminService.CreateKey(ctx, createReq)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeInternalError, "Failed to create user: "+err.Error()),
|
||||
)
|
||||
return apierr.Respond(c, err)
|
||||
}
|
||||
|
||||
// Convert bucket permissions to frontend format
|
||||
@@ -188,9 +185,7 @@ func (h *UserHandler) DeleteUser(c fiber.Ctx) error {
|
||||
// Delete the key
|
||||
err := h.adminService.DeleteKey(ctx, accessKey)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeInternalError, "Failed to delete user: "+err.Error()),
|
||||
)
|
||||
return apierr.Respond(c, err)
|
||||
}
|
||||
|
||||
return c.JSON(models.SuccessResponse(map[string]interface{}{
|
||||
@@ -223,9 +218,7 @@ func (h *UserHandler) GetUser(c fiber.Ctx) error {
|
||||
// Get key information (without secret key)
|
||||
keyInfo, err := h.adminService.GetKeyInfo(ctx, accessKey, false)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeInternalError, "Failed to get user info: "+err.Error()),
|
||||
)
|
||||
return apierr.Respond(c, err)
|
||||
}
|
||||
|
||||
// Convert bucket permissions to frontend format
|
||||
@@ -275,9 +268,7 @@ func (h *UserHandler) GetUserSecretKey(c fiber.Ctx) error {
|
||||
// Get key information WITH secret key
|
||||
keyInfo, err := h.adminService.GetKeyInfo(ctx, accessKey, true)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeInternalError, "Failed to get secret key: "+err.Error()),
|
||||
)
|
||||
return apierr.Respond(c, err)
|
||||
}
|
||||
|
||||
// Return only the secret key
|
||||
@@ -347,9 +338,7 @@ func (h *UserHandler) UpdateUserPermissions(c fiber.Ctx) error {
|
||||
// Update the key
|
||||
keyInfo, err := h.adminService.UpdateKey(ctx, accessKey, updateReq)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||
models.ErrorResponse(models.ErrCodeInternalError, "Failed to update user: "+err.Error()),
|
||||
)
|
||||
return apierr.Respond(c, err)
|
||||
}
|
||||
|
||||
// Convert bucket permissions to frontend format
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"Noooste/garage-ui/internal/apierr"
|
||||
"Noooste/garage-ui/internal/models"
|
||||
"Noooste/garage-ui/internal/services/mocks"
|
||||
|
||||
@@ -365,3 +366,31 @@ func TestUpdateUser_AdminError500(t *testing.T) {
|
||||
t.Fatalf("status = %d, want 500", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteUser_UpstreamAccessDenied(t *testing.T) {
|
||||
app, admin := newUsersTestApp(t)
|
||||
admin.DeleteKeyFn = func(_ context.Context, _ string) error {
|
||||
return &apierr.UpstreamError{
|
||||
HTTPStatus: 403,
|
||||
Code: "AccessDenied",
|
||||
Message: "permission denied",
|
||||
Source: "garage",
|
||||
}
|
||||
}
|
||||
resp, err := app.Test(httptest.NewRequest(http.MethodDelete, "/users/abc", nil))
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want 403", resp.StatusCode)
|
||||
}
|
||||
var body models.APIResponse
|
||||
decodeJSON(t, resp.Body, &body)
|
||||
if body.Error == nil || body.Error.Code != models.ErrCodeForbidden {
|
||||
t.Fatalf("error = %+v, want code %s", body.Error, models.ErrCodeForbidden)
|
||||
}
|
||||
if body.Error.Message != "permission denied" {
|
||||
t.Errorf("message = %q", body.Error.Message)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,4 +196,7 @@ const (
|
||||
ErrCodeUploadFailed = "UPLOAD_FAILED"
|
||||
ErrCodeDeleteFailed = "DELETE_FAILED"
|
||||
ErrCodeListFailed = "LIST_FAILED"
|
||||
ErrCodeBucketNotEmpty = "BUCKET_NOT_EMPTY"
|
||||
ErrCodeKeyNotFound = "KEY_NOT_FOUND"
|
||||
ErrCodeInvalidRequest = "INVALID_REQUEST"
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"Noooste/garage-ui/internal/apierr"
|
||||
"Noooste/garage-ui/internal/config"
|
||||
"Noooste/garage-ui/internal/models"
|
||||
"Noooste/garage-ui/pkg/utils"
|
||||
@@ -63,13 +64,14 @@ func (s *GarageAdminService) doRequest(ctx context.Context, method, path string,
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// decodeResponse decodes a JSON response into the target structure
|
||||
// decodeResponse decodes a JSON response into the target structure. Non-2xx
|
||||
// responses are converted to *apierr.UpstreamError (Source="garage") so the
|
||||
// handler layer can map them to the correct API error code and HTTP status.
|
||||
func decodeResponse(resp *azuretls.Response, target interface{}) error {
|
||||
defer resp.RawBody.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
bodyBytes, _ := io.ReadAll(resp.RawBody)
|
||||
return fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(bodyBytes))
|
||||
if err := apierr.ParseGarage(resp); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if target != nil {
|
||||
@@ -77,7 +79,6 @@ func decodeResponse(resp *azuretls.Response, target interface{}) error {
|
||||
return fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -90,7 +91,7 @@ func (s *GarageAdminService) ListKeys(ctx context.Context) ([]models.ListKeysRes
|
||||
|
||||
var result []models.ListKeysResponseItem
|
||||
if err := decodeResponse(resp, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
@@ -105,7 +106,7 @@ func (s *GarageAdminService) CreateKey(ctx context.Context, req models.CreateKey
|
||||
|
||||
var result models.GarageKeyInfo
|
||||
if err := decodeResponse(resp, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
@@ -125,7 +126,7 @@ func (s *GarageAdminService) GetKeyInfo(ctx context.Context, keyID string, showS
|
||||
|
||||
var result models.GarageKeyInfo
|
||||
if err := decodeResponse(resp, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
@@ -142,7 +143,7 @@ func (s *GarageAdminService) UpdateKey(ctx context.Context, keyID string, req mo
|
||||
|
||||
var result models.GarageKeyInfo
|
||||
if err := decodeResponse(resp, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
@@ -158,7 +159,7 @@ func (s *GarageAdminService) DeleteKey(ctx context.Context, keyID string) error
|
||||
}
|
||||
|
||||
if err := decodeResponse(resp, nil); err != nil {
|
||||
return fmt.Errorf("failed to process response: %w", err)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -173,7 +174,7 @@ func (s *GarageAdminService) ImportKey(ctx context.Context, req models.ImportKey
|
||||
|
||||
var result models.GarageKeyInfo
|
||||
if err := decodeResponse(resp, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
@@ -204,7 +205,7 @@ func (s *GarageAdminService) ListBuckets(ctx context.Context) ([]models.ListBuck
|
||||
Float64("duration_ms", msSince(start)).
|
||||
Str("outcome", "failure").
|
||||
Msg("garage list_buckets decode failed")
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.Debug().
|
||||
@@ -235,7 +236,7 @@ func (s *GarageAdminService) GetBucketInfo(ctx context.Context, bucketID string)
|
||||
var result models.GarageBucketInfo
|
||||
if err := decodeResponse(resp, &result); err != nil {
|
||||
log.Error().Err(err).Float64("duration_ms", msSince(start)).Str("outcome", "failure").Msg("garage get_bucket_info decode failed")
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.Debug().Float64("duration_ms", msSince(start)).Str("outcome", "success").Msg("got bucket info")
|
||||
@@ -262,7 +263,7 @@ func (s *GarageAdminService) GetBucketInfoByAlias(ctx context.Context, globalAli
|
||||
var result models.GarageBucketInfo
|
||||
if err = decodeResponse(resp, &result); err != nil {
|
||||
log.Error().Err(err).Float64("duration_ms", msSince(start)).Str("outcome", "failure").Msg("garage get_bucket_info_by_alias decode failed")
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.Debug().Float64("duration_ms", msSince(start)).Str("outcome", "success").Str("bucket_id", result.ID).Msg("got bucket info by alias")
|
||||
@@ -293,7 +294,7 @@ func (s *GarageAdminService) CreateBucket(ctx context.Context, req models.Create
|
||||
var result models.GarageBucketInfo
|
||||
if err := decodeResponse(resp, &result); err != nil {
|
||||
log.Error().Err(err).Float64("duration_ms", msSince(start)).Str("outcome", "failure").Msg("garage create_bucket decode failed")
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.Info().Float64("duration_ms", msSince(start)).Str("outcome", "success").Str("bucket_id", result.ID).Msg("bucket created")
|
||||
@@ -320,7 +321,7 @@ func (s *GarageAdminService) UpdateBucket(ctx context.Context, bucketID string,
|
||||
var result models.GarageBucketInfo
|
||||
if err := decodeResponse(resp, &result); err != nil {
|
||||
log.Error().Err(err).Float64("duration_ms", msSince(start)).Str("outcome", "failure").Msg("garage update_bucket decode failed")
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.Info().Float64("duration_ms", msSince(start)).Str("outcome", "success").Msg("bucket updated")
|
||||
@@ -346,7 +347,7 @@ func (s *GarageAdminService) DeleteBucket(ctx context.Context, bucketID string)
|
||||
|
||||
if err := decodeResponse(resp, nil); err != nil {
|
||||
log.Error().Err(err).Float64("duration_ms", msSince(start)).Str("outcome", "failure").Msg("garage delete_bucket decode failed")
|
||||
return fmt.Errorf("failed to process response: %w", err)
|
||||
return err
|
||||
}
|
||||
|
||||
log.Info().Float64("duration_ms", msSince(start)).Str("outcome", "success").Msg("bucket deleted")
|
||||
@@ -362,7 +363,7 @@ func (s *GarageAdminService) AddBucketAlias(ctx context.Context, req models.AddB
|
||||
|
||||
var result models.GarageBucketInfo
|
||||
if err := decodeResponse(resp, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
@@ -377,7 +378,7 @@ func (s *GarageAdminService) RemoveBucketAlias(ctx context.Context, req models.R
|
||||
|
||||
var result models.GarageBucketInfo
|
||||
if err := decodeResponse(resp, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
@@ -407,7 +408,7 @@ func (s *GarageAdminService) AllowBucketKey(ctx context.Context, req models.Buck
|
||||
var result models.GarageBucketInfo
|
||||
if err := decodeResponse(resp, &result); err != nil {
|
||||
log.Error().Err(err).Float64("duration_ms", msSince(start)).Str("outcome", "failure").Msg("garage allow_bucket_key decode failed")
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.Info().Float64("duration_ms", msSince(start)).Str("outcome", "success").Msg("bucket key permissions granted")
|
||||
@@ -423,7 +424,7 @@ func (s *GarageAdminService) DenyBucketKey(ctx context.Context, req models.Bucke
|
||||
|
||||
var result models.GarageBucketInfo
|
||||
if err := decodeResponse(resp, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
@@ -438,7 +439,7 @@ func (s *GarageAdminService) GetClusterHealth(ctx context.Context) (*models.Clus
|
||||
|
||||
var result models.ClusterHealth
|
||||
if err := decodeResponse(resp, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
@@ -453,7 +454,7 @@ func (s *GarageAdminService) GetClusterStatus(ctx context.Context) (*models.Clus
|
||||
|
||||
var result models.ClusterStatus
|
||||
if err := decodeResponse(resp, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
@@ -468,7 +469,7 @@ func (s *GarageAdminService) GetClusterStatistics(ctx context.Context) (*models.
|
||||
|
||||
var result models.ClusterStatistics
|
||||
if err := decodeResponse(resp, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
@@ -485,7 +486,7 @@ func (s *GarageAdminService) GetNodeInfo(ctx context.Context, nodeID string) (*m
|
||||
|
||||
var result models.MultiNodeResponse
|
||||
if err := decodeResponse(resp, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
@@ -502,7 +503,7 @@ func (s *GarageAdminService) GetNodeStatistics(ctx context.Context, nodeID strin
|
||||
|
||||
var result models.MultiNodeResponse
|
||||
if err := decodeResponse(resp, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
@@ -516,7 +517,7 @@ func (s *GarageAdminService) HealthCheck(ctx context.Context) error {
|
||||
}
|
||||
|
||||
if err := decodeResponse(resp, nil); err != nil {
|
||||
return fmt.Errorf("health check returned error: %w", err)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -3,6 +3,7 @@ package services
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
@@ -11,6 +12,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"Noooste/garage-ui/internal/apierr"
|
||||
"Noooste/garage-ui/internal/config"
|
||||
"Noooste/garage-ui/internal/models"
|
||||
)
|
||||
@@ -103,8 +105,12 @@ func TestHealthCheck_Non2xxReturnsError(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for 503, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "503") {
|
||||
t.Errorf("error should mention status code, got %v", err)
|
||||
var ue *apierr.UpstreamError
|
||||
if !errors.As(err, &ue) {
|
||||
t.Fatalf("expected *apierr.UpstreamError, got %T (%v)", err, err)
|
||||
}
|
||||
if ue.HTTPStatus != http.StatusServiceUnavailable {
|
||||
t.Errorf("HTTPStatus = %d, want %d", ue.HTTPStatus, http.StatusServiceUnavailable)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -509,8 +515,15 @@ func TestDoRequest_Non2xxBodyEchoedInError(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for 400, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "400") || !strings.Contains(err.Error(), "bad request") {
|
||||
t.Errorf("error %v should contain 400 and the response body", err)
|
||||
var ue *apierr.UpstreamError
|
||||
if !errors.As(err, &ue) {
|
||||
t.Fatalf("expected *apierr.UpstreamError, got %T (%v)", err, err)
|
||||
}
|
||||
if ue.HTTPStatus != http.StatusBadRequest {
|
||||
t.Errorf("HTTPStatus = %d, want %d", ue.HTTPStatus, http.StatusBadRequest)
|
||||
}
|
||||
if !strings.Contains(ue.Message, "bad request") {
|
||||
t.Errorf("Message = %q, want it to contain the response body", ue.Message)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"Noooste/garage-ui/internal/apierr"
|
||||
"Noooste/garage-ui/internal/config"
|
||||
"Noooste/garage-ui/internal/models"
|
||||
"Noooste/garage-ui/pkg/utils"
|
||||
@@ -131,7 +132,7 @@ func (s *S3Service) ListBuckets(ctx context.Context) (*models.BucketListResponse
|
||||
return listErr
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list buckets: %w", err)
|
||||
return nil, apierr.FromMinio(err)
|
||||
}
|
||||
|
||||
// Convert MinIO buckets to our model
|
||||
@@ -164,7 +165,7 @@ func (s *S3Service) CreateBucket(ctx context.Context, bucketName string) error {
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create bucket %s: %w", bucketName, err)
|
||||
return apierr.FromMinio(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -183,7 +184,7 @@ func (s *S3Service) DeleteBucket(ctx context.Context, bucketName string) error {
|
||||
return client.RemoveBucket(ctx, bucketName)
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete bucket %s: %w", bucketName, err)
|
||||
return apierr.FromMinio(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -216,7 +217,7 @@ func (s *S3Service) ListObjects(ctx context.Context, bucketName, prefix string,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list objects in bucket %s: %w", bucketName, err)
|
||||
return nil, apierr.FromMinio(err)
|
||||
}
|
||||
|
||||
// Drop directory marker objects (zero-byte keys ending in "/"). Garage
|
||||
@@ -333,7 +334,7 @@ func (s *S3Service) UploadObject(ctx context.Context, bucketName, key string, bo
|
||||
return uploadErr
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to upload object %s to bucket %s: %w", key, bucketName, err)
|
||||
return nil, apierr.FromMinio(err)
|
||||
}
|
||||
|
||||
return &models.ObjectUploadResponse{
|
||||
@@ -366,7 +367,7 @@ func (s *S3Service) CreateDirectoryMarker(ctx context.Context, bucketName, key s
|
||||
return uploadErr
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create directory %s in bucket %s: %w", key, bucketName, err)
|
||||
return nil, apierr.FromMinio(err)
|
||||
}
|
||||
|
||||
return &models.ObjectUploadResponse{
|
||||
@@ -396,14 +397,14 @@ func (s *S3Service) GetObject(ctx context.Context, bucketName, key string) (io.R
|
||||
return getErr
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to get object %s from bucket %s: %w", key, bucketName, err)
|
||||
return nil, nil, apierr.FromMinio(err)
|
||||
}
|
||||
|
||||
// Get object info
|
||||
stat, err := object.Stat()
|
||||
if err != nil {
|
||||
object.Close()
|
||||
return nil, nil, fmt.Errorf("failed to get object info for %s in bucket %s: %w", key, bucketName, err)
|
||||
return nil, nil, apierr.FromMinio(err)
|
||||
}
|
||||
|
||||
// Create object info
|
||||
@@ -432,7 +433,7 @@ func (s *S3Service) DeleteObject(ctx context.Context, bucketName, key string) er
|
||||
return client.RemoveObject(ctx, bucketName, key, minio.RemoveObjectOptions{})
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete object %s from bucket %s: %w", key, bucketName, err)
|
||||
return apierr.FromMinio(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -461,7 +462,7 @@ func (s *S3Service) ObjectExists(ctx context.Context, bucketName, key string) (b
|
||||
if errResponse.Code == "NoSuchKey" {
|
||||
return false, nil
|
||||
}
|
||||
return false, fmt.Errorf("failed to check if object exists: %w", err)
|
||||
return false, apierr.FromMinio(err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
@@ -484,7 +485,7 @@ func (s *S3Service) GetObjectMetadata(ctx context.Context, bucketName, key strin
|
||||
return statErr
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get metadata for object %s in bucket %s: %w", key, bucketName, err)
|
||||
return nil, apierr.FromMinio(err)
|
||||
}
|
||||
|
||||
return &models.ObjectInfo{
|
||||
@@ -529,7 +530,7 @@ func (s *S3Service) DeleteMultipleObjects(ctx context.Context, bucketName string
|
||||
// Check for errors
|
||||
for err := range errorCh {
|
||||
if err.Err != nil {
|
||||
return fmt.Errorf("failed to delete object %s from bucket %s: %w", err.ObjectName, bucketName, err.Err)
|
||||
return apierr.FromMinio(err.Err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -13,6 +14,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"Noooste/garage-ui/internal/apierr"
|
||||
"Noooste/garage-ui/internal/config"
|
||||
"Noooste/garage-ui/internal/models"
|
||||
"Noooste/garage-ui/pkg/utils"
|
||||
@@ -105,8 +107,9 @@ func TestS3_ListBuckets_ServerError(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error from ListBuckets, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "failed to list buckets") {
|
||||
t.Errorf("error %v should wrap 'failed to list buckets'", err)
|
||||
var ue *apierr.UpstreamError
|
||||
if !errors.As(err, &ue) {
|
||||
t.Errorf("expected *apierr.UpstreamError, got %T: %v", err, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,8 +125,9 @@ func TestS3_CreateBucket_ServerError(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "failed to create bucket") {
|
||||
t.Errorf("error = %v, want wrap 'failed to create bucket'", err)
|
||||
var ue *apierr.UpstreamError
|
||||
if !errors.As(err, &ue) {
|
||||
t.Errorf("expected *apierr.UpstreamError, got %T: %v", err, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,8 +142,9 @@ func TestS3_DeleteBucket_ServerError(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "failed to delete bucket") {
|
||||
t.Errorf("error = %v", err)
|
||||
var ue *apierr.UpstreamError
|
||||
if !errors.As(err, &ue) {
|
||||
t.Errorf("expected *apierr.UpstreamError, got %T: %v", err, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,8 +159,9 @@ func TestS3_ListObjects_ServerError(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error from ListObjects, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "failed to list objects") {
|
||||
t.Errorf("error = %v", err)
|
||||
var ue *apierr.UpstreamError
|
||||
if !errors.As(err, &ue) {
|
||||
t.Errorf("expected *apierr.UpstreamError, got %T: %v", err, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,8 +176,9 @@ func TestS3_UploadObject_ServerError(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "failed to upload object") {
|
||||
t.Errorf("error = %v", err)
|
||||
var ue *apierr.UpstreamError
|
||||
if !errors.As(err, &ue) {
|
||||
t.Errorf("expected *apierr.UpstreamError, got %T: %v", err, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -186,8 +193,9 @@ func TestS3_CreateDirectoryMarker_ServerError(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "failed to create directory") {
|
||||
t.Errorf("error = %v", err)
|
||||
var ue *apierr.UpstreamError
|
||||
if !errors.As(err, &ue) {
|
||||
t.Errorf("expected *apierr.UpstreamError, got %T: %v", err, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,8 +223,9 @@ func TestS3_DeleteObject_ServerError(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "failed to delete object") {
|
||||
t.Errorf("error = %v", err)
|
||||
var ue *apierr.UpstreamError
|
||||
if !errors.As(err, &ue) {
|
||||
t.Errorf("expected *apierr.UpstreamError, got %T: %v", err, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -260,8 +269,9 @@ func TestS3_GetObjectMetadata_ServerError(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "failed to get metadata") {
|
||||
t.Errorf("error = %v", err)
|
||||
var ue *apierr.UpstreamError
|
||||
if !errors.As(err, &ue) {
|
||||
t.Errorf("expected *apierr.UpstreamError, got %T: %v", err, err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ services:
|
||||
start_period: 2s
|
||||
|
||||
backend:
|
||||
image: garage-ui-smoke-backend:ci
|
||||
build:
|
||||
context: ../../..
|
||||
dockerfile: Dockerfile
|
||||
|
||||
@@ -87,7 +87,7 @@ export function ObjectDetailsView() {
|
||||
document.body.removeChild(a);
|
||||
toast.success('Download started');
|
||||
} catch {
|
||||
toast.error('Download failed');
|
||||
// error toast handled by axios interceptor
|
||||
}
|
||||
};
|
||||
|
||||
@@ -99,7 +99,7 @@ export function ObjectDetailsView() {
|
||||
toast.success('Object deleted');
|
||||
navigate(backHref);
|
||||
} catch {
|
||||
toast.error('Delete failed');
|
||||
// error toast handled by axios interceptor
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
setDeleteOpen(false);
|
||||
|
||||
@@ -44,10 +44,9 @@ export function BucketPermissions() {
|
||||
accessKeyId: selectedKey,
|
||||
permissions: { read, write, owner },
|
||||
});
|
||||
toast.success('Permissions granted');
|
||||
setSelectedKey(''); setRead(false); setWrite(false); setOwner(false);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Grant failed');
|
||||
} catch {
|
||||
// error toast handled by axios interceptor
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { EmptyState } from '@/components/ui/empty-state';
|
||||
import { DangerousConfirmDialog } from '@/components/ui/dangerous-confirm-dialog';
|
||||
import { toast } from 'sonner';
|
||||
import { formatBytes } from '@/lib/file-utils';
|
||||
import { formatDate as formatDateUtil } from '@/lib/utils';
|
||||
|
||||
@@ -43,10 +42,8 @@ export function BucketSettings() {
|
||||
setDeleting(true);
|
||||
try {
|
||||
await deleteMutation.mutateAsync(bucket.name);
|
||||
toast.success('Bucket deleted');
|
||||
navigate('/buckets');
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Delete failed');
|
||||
} catch {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -65,8 +65,8 @@ export function BucketWebsite() {
|
||||
});
|
||||
await queryClient.invalidateQueries({ queryKey: ['buckets'] });
|
||||
toast.success(disabling ? 'Website disabled' : 'Website configuration updated');
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Save failed');
|
||||
} catch {
|
||||
// error toast handled by axios interceptor
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import { DangerousConfirmDialog } from '@/components/ui/dangerous-confirm-dialog
|
||||
import { PageHeader } from '@/components/ui/page-header';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type { Bucket } from '@/types';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export function Buckets() {
|
||||
const navigate = useNavigate();
|
||||
@@ -24,10 +23,8 @@ export function Buckets() {
|
||||
const createBucket = async (name: string, region?: string) => {
|
||||
try {
|
||||
await createMutation.mutateAsync({ name, region });
|
||||
toast.success(`Bucket "${name}" created`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Create failed');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -37,10 +34,9 @@ export function Buckets() {
|
||||
setDeleting(true);
|
||||
try {
|
||||
await deleteMutation.mutateAsync(deleteTarget.name);
|
||||
toast.success('Bucket deleted');
|
||||
setDeleteTarget(null);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Delete failed');
|
||||
} catch {
|
||||
// error toast handled by axios interceptor
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@ name: garage-ui
|
||||
description: A Helm chart for Garage UI - Web interface for Garage S3 object storage
|
||||
icon: https://helm.noste.dev/garage.png
|
||||
type: application
|
||||
version: 0.2.4
|
||||
appVersion: "v0.4.0"
|
||||
version: 0.2.5
|
||||
appVersion: "v0.4.2"
|
||||
keywords:
|
||||
- garage
|
||||
- s3
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
A Helm chart for deploying [Garage UI](https://github.com/Noooste/garage-ui), a modern web interface for managing [Garage](https://garagehq.deuxfleurs.fr/) distributed object storage systems.
|
||||
|
||||
[](Chart.yaml)
|
||||
[](Chart.yaml)
|
||||
[](Chart.yaml)
|
||||
[](Chart.yaml)
|
||||
|
||||
## Table of Contents
|
||||
|
||||
|
||||
Reference in New Issue
Block a user