mirror of
https://github.com/Noooste/garage-ui.git
synced 2026-07-26 15:58:13 +00:00
Compare commits
44 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cb1b14b941 | |||
| 03bb3e8fb6 | |||
| ae6af68ed9 | |||
| e918a0a940 | |||
| 005211f073 | |||
| 646813b977 | |||
| 361756859a | |||
| 9022b90f02 | |||
| 68be5ea2be | |||
| afc4da7491 | |||
| 1522439c68 | |||
| e4f86cd176 | |||
| dd275d2e78 | |||
| c14463fb8e | |||
| ab188dac4a | |||
| cbdab9a775 | |||
| 901ae70d02 | |||
| d0040be103 | |||
| 047a653446 | |||
| f63ce3452e | |||
| 5e68a77e15 | |||
| 46f904fe59 | |||
| 0baf31422a | |||
| 98842d7c7e | |||
| bd4c3b1c5f | |||
| 68d773d4af | |||
| 54a5e42db6 | |||
| 43ba68a4f9 | |||
| a0458fb5d0 | |||
| 6dc0feb374 | |||
| 33d8705431 | |||
| e7db44f45d | |||
| 126e138382 | |||
| 2efb16e248 | |||
| d494c811a0 | |||
| e800ac0ed1 | |||
| 26ae2ef515 | |||
| 4486697ca5 | |||
| 94ad142edf | |||
| b8b0b6b0fa | |||
| 8c72a7fb75 | |||
| 7df74cd7e8 | |||
| 8f81c7ffea | |||
| f6fe310e3e |
@@ -51,6 +51,7 @@ jobs:
|
||||
context: .
|
||||
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 }}
|
||||
outputs: type=image,name=${{ secrets.DOCKER_REGISTRY }},push-by-digest=true,name-canonical=true,push=true
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
name: backend-tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'backend/**'
|
||||
- 'Dockerfile'
|
||||
- 'Makefile'
|
||||
- 'scripts/coverage-gate.sh'
|
||||
- '.github/workflows/test.yml'
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
unit:
|
||||
name: Unit tests + coverage gate
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.25'
|
||||
cache: true
|
||||
cache-dependency-path: backend/go.sum
|
||||
|
||||
- name: Generate swagger docs
|
||||
run: |
|
||||
go install github.com/swaggo/swag/cmd/swag@latest
|
||||
cd backend && swag init
|
||||
|
||||
- name: Run unit tests with race detector and coverage
|
||||
run: |
|
||||
cd backend
|
||||
go test -race -coverprofile=../coverage.out -coverpkg=./... ./...
|
||||
|
||||
- name: Enforce coverage gate
|
||||
run: bash scripts/coverage-gate.sh coverage.out
|
||||
|
||||
- name: Upload coverage artifact
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: coverage
|
||||
path: coverage.out
|
||||
|
||||
smoke:
|
||||
name: Smoke test (docker compose)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.25'
|
||||
cache: true
|
||||
cache-dependency-path: backend/go.sum
|
||||
|
||||
- name: Run smoke test
|
||||
run: make test-smoke
|
||||
|
||||
- name: Dump compose logs on failure
|
||||
if: failure()
|
||||
run: |
|
||||
docker compose -p garage-ui-smoke \
|
||||
-f backend/tests/smoke/docker-compose.test.yml logs --no-color || true
|
||||
|
||||
- name: Tear down compose
|
||||
if: always()
|
||||
run: |
|
||||
docker compose -p garage-ui-smoke \
|
||||
-f backend/tests/smoke/docker-compose.test.yml down -v || true
|
||||
+4
-1
@@ -54,10 +54,13 @@ dist-ssr
|
||||
.env*
|
||||
!config.yaml.example
|
||||
docker-compose.*.yml
|
||||
!backend/tests/smoke/docker-compose.test.yml
|
||||
|
||||
data/
|
||||
meta/
|
||||
garage.toml
|
||||
!backend/tests/smoke/garage.toml
|
||||
|
||||
backend/docs/
|
||||
config.yaml
|
||||
config.yaml
|
||||
docs/**/*.md
|
||||
+7
-5
@@ -1,4 +1,4 @@
|
||||
FROM --platform=$BUILDPLATFORM node:25-alpine3.22 AS frontend-builder
|
||||
FROM --platform=$BUILDPLATFORM node:25-alpine3.23 AS frontend-builder
|
||||
|
||||
WORKDIR /app/frontend
|
||||
|
||||
@@ -11,7 +11,7 @@ COPY frontend/ .
|
||||
|
||||
RUN npm run build
|
||||
|
||||
FROM --platform=$BUILDPLATFORM golang:1.25.4-alpine3.22 AS backend-builder
|
||||
FROM --platform=$BUILDPLATFORM golang:1.26.0-alpine3.23 AS backend-builder
|
||||
|
||||
ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
@@ -29,14 +29,16 @@ RUN --mount=type=cache,target=/go/pkg/mod \
|
||||
|
||||
COPY backend .
|
||||
|
||||
ARG VERSION=dev
|
||||
|
||||
RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||
swag init
|
||||
|
||||
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||
--mount=type=cache,target=/root/.cache/go-build \
|
||||
CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -a -installsuffix cgo -o garage-ui .
|
||||
CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -a -installsuffix cgo -ldflags "-X main.version=${VERSION}" -o garage-ui .
|
||||
|
||||
FROM alpine:3.22
|
||||
FROM alpine:3.23.3
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -53,7 +55,7 @@ USER garageui
|
||||
EXPOSE 8080
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1
|
||||
|
||||
CMD ["./garage-ui"]
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
.PHONY: help build up down restart logs clean ps health dev-build dev-up dev-down dev-restart dev-logs prod-up prod-down prod-restart prod-logs push stop
|
||||
|
||||
# Variables
|
||||
DOCKER_COMPOSE = docker-compose
|
||||
DOCKER_COMPOSE_DEV = docker-compose -f docker-compose.dev.yml
|
||||
DOCKER_COMPOSE_PROD = docker-compose -f docker-compose.yml
|
||||
DOCKER_COMPOSE = docker compose
|
||||
DOCKER_COMPOSE_DEV = docker compose -f docker-compose.dev.yml
|
||||
DOCKER_COMPOSE_PROD = docker compose -f docker-compose.yml
|
||||
IMAGE_NAME = noooste/garage-ui
|
||||
IMAGE_TAG = latest
|
||||
|
||||
@@ -135,3 +135,22 @@ install:
|
||||
update: prod-pull prod-restart
|
||||
|
||||
.DEFAULT_GOAL := help
|
||||
|
||||
.PHONY: test test-race test-cover test-smoke
|
||||
|
||||
## test: Run backend unit tests
|
||||
test:
|
||||
cd backend && go test ./...
|
||||
|
||||
## test-race: Run backend unit tests with the race detector
|
||||
test-race:
|
||||
cd backend && go test -race ./...
|
||||
|
||||
## test-cover: Run backend unit tests with coverage and enforce the coverage gate
|
||||
test-cover:
|
||||
cd backend && go test -coverprofile=../coverage.out -coverpkg=./... ./...
|
||||
bash scripts/coverage-gate.sh coverage.out
|
||||
|
||||
## test-smoke: Run the docker compose smoke test (requires Docker + compose v2)
|
||||
test-smoke:
|
||||
cd backend && go test -tags=smoke -timeout 10m ./tests/smoke/...
|
||||
|
||||
@@ -49,15 +49,15 @@ cd garage-ui
|
||||
If you don't have Garage running:
|
||||
|
||||
```bash
|
||||
docker-compose up -d garage
|
||||
docker compose up -d garage
|
||||
sleep 10
|
||||
|
||||
# Initialize cluster
|
||||
docker-compose exec garage garage layout assign -z dc1 -c 1G $(docker-compose exec garage garage node id -q)
|
||||
docker-compose exec garage garage layout apply --version 1
|
||||
docker compose exec garage garage layout assign -z dc1 -c 1G $(docker compose exec garage garage node id -q)
|
||||
docker compose exec garage garage layout apply --version 1
|
||||
|
||||
# Create admin key
|
||||
docker-compose exec garage garage key create admin-key
|
||||
docker compose exec garage garage key create admin-key
|
||||
```
|
||||
|
||||
Save the access key and secret key from the output.
|
||||
@@ -73,7 +73,7 @@ Edit `config.yaml` with your Garage endpoints and admin token (from `garage.toml
|
||||
### 4. Start UI
|
||||
|
||||
```bash
|
||||
docker-compose up -d garage-ui
|
||||
docker compose up -d garage-ui
|
||||
```
|
||||
|
||||
Access at http://localhost:8080
|
||||
@@ -183,6 +183,7 @@ curl http://localhost:3903/status -H "Authorization: Bearer your-token"
|
||||
```yaml
|
||||
logging:
|
||||
level: "debug"
|
||||
format: "text" # or "json"
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
+44
-44
@@ -3,82 +3,82 @@ module Noooste/garage-ui
|
||||
go 1.25.3
|
||||
|
||||
require (
|
||||
github.com/Noooste/azuretls-client v1.12.10
|
||||
github.com/Noooste/azuretls-client v1.13.2
|
||||
github.com/Noooste/swagger v1.2.0
|
||||
github.com/coreos/go-oidc/v3 v3.17.0
|
||||
github.com/gofiber/fiber/v3 v3.0.0-rc.3
|
||||
github.com/minio/minio-go/v7 v7.0.97
|
||||
github.com/rs/zerolog v1.34.0
|
||||
github.com/coreos/go-oidc/v3 v3.18.0
|
||||
github.com/gofiber/fiber/v3 v3.1.0
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/minio/minio-go/v7 v7.0.100
|
||||
github.com/rs/zerolog v1.35.0
|
||||
github.com/spf13/viper v1.21.0
|
||||
github.com/swaggo/swag v1.16.6
|
||||
golang.org/x/oauth2 v0.33.0
|
||||
golang.org/x/oauth2 v0.36.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/KyleBanks/depth v1.2.1 // indirect
|
||||
github.com/Noooste/fhttp v1.0.15 // indirect
|
||||
github.com/Noooste/go-socks4 v0.0.2 // indirect
|
||||
github.com/Noooste/uquic-go v1.0.3 // indirect
|
||||
github.com/Noooste/utls v1.3.20 // indirect
|
||||
github.com/Noooste/uquic-go v1.0.5 // indirect
|
||||
github.com/Noooste/utls v1.3.21 // indirect
|
||||
github.com/Noooste/websocket v1.0.3 // indirect
|
||||
github.com/andybalholm/brotli v1.2.0 // indirect
|
||||
github.com/andybalholm/brotli v1.2.1 // indirect
|
||||
github.com/bdandy/go-errors v1.2.2 // indirect
|
||||
github.com/cloudflare/circl v1.6.1 // indirect
|
||||
github.com/cloudflare/circl v1.6.3 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/fatih/color v1.18.0 // indirect
|
||||
github.com/fatih/color v1.19.0 // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/gaukas/clienthellod v0.4.2 // indirect
|
||||
github.com/gaukas/godicttls v0.0.4 // indirect
|
||||
github.com/go-ini/ini v1.67.0 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.22.3 // indirect
|
||||
github.com/go-openapi/jsonreference v0.21.3 // indirect
|
||||
github.com/go-openapi/spec v0.22.1 // indirect
|
||||
github.com/go-openapi/swag/conv v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/jsonname v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/jsonutils v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/loading v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/stringutils v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/typeutils v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/yamlutils v0.25.4 // indirect
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/gofiber/schema v1.6.0 // indirect
|
||||
github.com/gofiber/utils/v2 v2.0.0-rc.3 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.23.1 // indirect
|
||||
github.com/go-openapi/jsonreference v0.21.5 // indirect
|
||||
github.com/go-openapi/spec v0.22.4 // indirect
|
||||
github.com/go-openapi/swag v0.26.0 // indirect
|
||||
github.com/go-openapi/swag/conv v0.26.0 // indirect
|
||||
github.com/go-openapi/swag/jsonname v0.26.0 // indirect
|
||||
github.com/go-openapi/swag/jsonutils v0.26.0 // indirect
|
||||
github.com/go-openapi/swag/loading v0.26.0 // indirect
|
||||
github.com/go-openapi/swag/stringutils v0.26.0 // indirect
|
||||
github.com/go-openapi/swag/typeutils v0.26.0 // indirect
|
||||
github.com/go-openapi/swag/yamlutils v0.26.0 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||
github.com/gofiber/schema v1.7.1 // indirect
|
||||
github.com/gofiber/utils/v2 v2.0.3 // indirect
|
||||
github.com/google/gopacket v1.1.19 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/klauspost/compress v1.18.2 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/klauspost/compress v1.18.5 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/klauspost/crc32 v1.3.0 // indirect
|
||||
github.com/mailru/easyjson v0.9.2 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-isatty v0.0.21 // indirect
|
||||
github.com/minio/crc64nvme v1.1.1 // indirect
|
||||
github.com/minio/md5-simd v1.1.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.3.0 // indirect
|
||||
github.com/philhofer/fwd v1.2.0 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/refraction-networking/utls v1.8.1 // indirect
|
||||
github.com/refraction-networking/utls v1.8.2 // indirect
|
||||
github.com/rs/xid v1.6.0 // indirect
|
||||
github.com/sagikazarmark/locafero v0.12.0 // indirect
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
|
||||
github.com/spf13/afero v1.15.0 // indirect
|
||||
github.com/spf13/cast v1.10.0 // indirect
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
github.com/swaggo/files/v2 v2.0.2 // indirect
|
||||
github.com/tinylib/msgp v1.6.1 // indirect
|
||||
github.com/tinylib/msgp v1.6.4 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasthttp v1.68.0 // indirect
|
||||
go.uber.org/automaxprocs v1.6.0 // indirect
|
||||
github.com/valyala/fasthttp v1.70.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/crypto v0.45.0 // indirect
|
||||
golang.org/x/mod v0.30.0 // indirect
|
||||
golang.org/x/net v0.47.0 // indirect
|
||||
golang.org/x/sync v0.18.0 // indirect
|
||||
golang.org/x/sys v0.38.0 // indirect
|
||||
golang.org/x/text v0.31.0 // indirect
|
||||
golang.org/x/tools v0.39.0 // indirect
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
|
||||
golang.org/x/crypto v0.50.0 // indirect
|
||||
golang.org/x/mod v0.35.0 // indirect
|
||||
golang.org/x/net v0.53.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
golang.org/x/text v0.36.0 // indirect
|
||||
golang.org/x/tools v0.44.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
+147
-97
@@ -1,31 +1,41 @@
|
||||
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
|
||||
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
|
||||
github.com/Noooste/azuretls-client v1.12.9 h1:w6XVao95irzge8k+yF5ZovIKf7UBe0GVE3sTRKs/4wE=
|
||||
github.com/Noooste/azuretls-client v1.12.9/go.mod h1:GHqLaS+vjBk+D3fMA0d+gNgDS2ahtic+6c+7JyMfrdU=
|
||||
github.com/Noooste/azuretls-client v1.12.10 h1:wD+hSokcB1DCFSSLpj05bMGUCBV0wbPB/Z4uPFNEpoI=
|
||||
github.com/Noooste/azuretls-client v1.12.10/go.mod h1:nVPwYA6UgHrOinlpvj6t/zDTBV+UfT3t8vmab7WYxF0=
|
||||
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
|
||||
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
|
||||
github.com/Noooste/azuretls-client v1.12.11 h1:8IvtfPf+K6wOqiRROL/APGkxQCO/+jyjH0S39rnItfQ=
|
||||
github.com/Noooste/azuretls-client v1.12.11/go.mod h1:lvXW8wpaOwrwtDrSt8nv/Dd8NAbCMVNRoU4sFrAaxYs=
|
||||
github.com/Noooste/azuretls-client v1.13.2 h1:8Dli5aKP5O6qN/FNSGFVkpQ1V1F1gGawAkLIE2Nrk+U=
|
||||
github.com/Noooste/azuretls-client v1.13.2/go.mod h1:ON+SmiBm4Zy5vAhJmBNZk61Y7nqf4iM/b1MC1lN47Bk=
|
||||
github.com/Noooste/fhttp v1.0.15 h1:sYRWOKgr1x4L+wA6REMJCs4Z/lFOSJmuQHSIXMXCcPs=
|
||||
github.com/Noooste/fhttp v1.0.15/go.mod h1:YZtq+i2M11Y22UiOR6gjNSLMNLiPhURh6M44oFVQ1TE=
|
||||
github.com/Noooste/go-socks4 v0.0.2 h1:DwHCYiCEAdjfNrQOFIid7qgKCll7ubhGS1ji5O8FYng=
|
||||
github.com/Noooste/go-socks4 v0.0.2/go.mod h1:+oOgtOFRsU8FoK7NBOhHSjiH5pveY8LgYNF5XcqVgjE=
|
||||
github.com/Noooste/swagger v1.2.0 h1:zGHin8k2V9mXDB1gxXOdKe4V8zhw79ycsw+/L2hH/pk=
|
||||
github.com/Noooste/swagger v1.2.0/go.mod h1:5N+iUZlFA43k2Paf42EZ+SFndBG1niSA1FAnwiNP1PM=
|
||||
github.com/Noooste/uquic-go v1.0.1 h1:12ARejbnh0R5FLGoHhz4p+qT/8BKkRNTPsJlkX4/pg8=
|
||||
github.com/Noooste/uquic-go v1.0.1/go.mod h1:6AUIck22N0wQ5+CY/5pLmW3IzdITlf7ABtjbRsaZq2A=
|
||||
github.com/Noooste/uquic-go v1.0.3 h1:VP8npQmU4lkVLm9Ug5Q18SJ8ExFDfUZIzd13YjYaLHE=
|
||||
github.com/Noooste/uquic-go v1.0.3/go.mod h1:MxkrvgpNcbIOSQxqglC3e/798O/6zuL3mBhlFN+04w4=
|
||||
github.com/Noooste/uquic-go v1.0.5 h1:HWfrxhxgB1a9Y2Au5mfFs2Y5Dy13OQIwa86D/kULPtE=
|
||||
github.com/Noooste/uquic-go v1.0.5/go.mod h1:1y+qiy23PqLKudi4kQiJ0b3zXXYcyctEBRfZPTuyBz4=
|
||||
github.com/Noooste/utls v1.3.20 h1:QzBNGGJ184bNMLodOzvM9YWc4vZ36QodIjqFQOHoZ88=
|
||||
github.com/Noooste/utls v1.3.20/go.mod h1:XEy+VEbTxmH6krfSG5YT7wDbjHTEi2zUXTG33R0PAAg=
|
||||
github.com/Noooste/utls v1.3.21 h1:5yEzTibikzF0/d0REfbjXURGHxJDKCrRghVAU/OQBko=
|
||||
github.com/Noooste/utls v1.3.21/go.mod h1:XEy+VEbTxmH6krfSG5YT7wDbjHTEi2zUXTG33R0PAAg=
|
||||
github.com/Noooste/websocket v1.0.3 h1:drW7tvZ3YqzqI9wApnaH1Q0syFMXO7gbLlsBWjZvMNA=
|
||||
github.com/Noooste/websocket v1.0.3/go.mod h1:Qhw0Rtuju/fPPbcb3R5XGq7poa51qPDL462jTltl9nQ=
|
||||
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
||||
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
|
||||
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/bdandy/go-errors v1.2.2 h1:WdFv/oukjTJCLa79UfkGmwX7ZxONAihKu4V0mLIs11Q=
|
||||
github.com/bdandy/go-errors v1.2.2/go.mod h1:NkYHl4Fey9oRRdbB1CoC6e84tuqQHiqrOcZpqFEkBxM=
|
||||
github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0=
|
||||
github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
|
||||
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
|
||||
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
|
||||
github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc=
|
||||
github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
|
||||
github.com/coreos/go-oidc/v3 v3.18.0 h1:V9orjXynvu5wiC9SemFTWnG4F45v403aIcjWo0d41+A=
|
||||
github.com/coreos/go-oidc/v3 v3.18.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
|
||||
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@@ -33,14 +43,15 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
||||
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
|
||||
github.com/francoispqt/gojay v1.2.13 h1:d2m3sFjloqoIUQU3TsHBgj6qg/BVGlTBeHDUmyJnXKk=
|
||||
github.com/francoispqt/gojay v1.2.13/go.mod h1:ehT5mTG4ua4581f1++1WLG0vPdaA9HaiDsoyrBGkyDY=
|
||||
github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
|
||||
github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
|
||||
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||
github.com/fxamacker/cbor/v2 v2.9.1 h1:2rWm8B193Ll4VdjsJY28jxs70IdDsHRWgQYAI80+rMQ=
|
||||
github.com/gaukas/clienthellod v0.4.2 h1:LPJ+LSeqt99pqeCV4C0cllk+pyWmERisP7w6qWr7eqE=
|
||||
github.com/gaukas/clienthellod v0.4.2/go.mod h1:M57+dsu0ZScvmdnNxaxsDPM46WhSEdPYAOdNgfL7IKA=
|
||||
github.com/gaukas/godicttls v0.0.4 h1:NlRaXb3J6hAnTmWdsEKb9bcSBD6BvcIjdGdeb0zfXbk=
|
||||
@@ -49,54 +60,60 @@ github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
|
||||
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
|
||||
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
|
||||
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
github.com/go-logr/logr v1.2.4 h1:g01GSCwiDw2xSZfjJ2/T9M+S6pFdcNtFYsp+Y43HYDQ=
|
||||
github.com/go-logr/logr v1.2.4/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-openapi/jsonpointer v0.22.3 h1:dKMwfV4fmt6Ah90zloTbUKWMD+0he+12XYAsPotrkn8=
|
||||
github.com/go-openapi/jsonpointer v0.22.3/go.mod h1:0lBbqeRsQ5lIanv3LHZBrmRGHLHcQoOXQnf88fHlGWo=
|
||||
github.com/go-openapi/jsonreference v0.21.3 h1:96Dn+MRPa0nYAR8DR1E03SblB5FJvh7W6krPI0Z7qMc=
|
||||
github.com/go-openapi/jsonreference v0.21.3/go.mod h1:RqkUP0MrLf37HqxZxrIAtTWW4ZJIK1VzduhXYBEeGc4=
|
||||
github.com/go-openapi/spec v0.22.1 h1:beZMa5AVQzRspNjvhe5aG1/XyBSMeX1eEOs7dMoXh/k=
|
||||
github.com/go-openapi/spec v0.22.1/go.mod h1:c7aeIQT175dVowfp7FeCvXXnjN/MrpaONStibD2WtDA=
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
|
||||
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
|
||||
github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
|
||||
github.com/go-openapi/jsonpointer v0.23.1 h1:1HBACs7XIwR2RcmItfdSFlALhGbe6S92p0ry4d1GWg4=
|
||||
github.com/go-openapi/jsonpointer v0.23.1/go.mod h1:iWRmZTrGn7XwYhtPt/fvdSFj1OfNBngqRT2UG3BxSqY=
|
||||
github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ=
|
||||
github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4=
|
||||
github.com/go-openapi/jsonreference v0.21.5 h1:6uCGVXU/aNF13AQNggxfysJ+5ZcU4nEAe+pJyVWRdiE=
|
||||
github.com/go-openapi/jsonreference v0.21.5/go.mod h1:u25Bw85sX4E2jzFodh1FOKMTZLcfifd1Q+iKKOUxExw=
|
||||
github.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9ZY=
|
||||
github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk=
|
||||
github.com/go-openapi/spec v0.22.4 h1:4pxGjipMKu0FzFiu/DPwN3CTBRlVM2yLf/YTWorYfDQ=
|
||||
github.com/go-openapi/spec v0.22.4/go.mod h1:WQ6Ai0VPWMZgMT4XySjlRIE6GP1bGQOtEThn3gcWLtQ=
|
||||
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
|
||||
github.com/go-openapi/swag/conv v0.25.4 h1:/Dd7p0LZXczgUcC/Ikm1+YqVzkEeCc9LnOWjfkpkfe4=
|
||||
github.com/go-openapi/swag/conv v0.25.4/go.mod h1:3LXfie/lwoAv0NHoEuY1hjoFAYkvlqI/Bn5EQDD3PPU=
|
||||
github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI=
|
||||
github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag=
|
||||
github.com/go-openapi/swag/jsonutils v0.25.4 h1:VSchfbGhD4UTf4vCdR2F4TLBdLwHyUDTd1/q4i+jGZA=
|
||||
github.com/go-openapi/swag/jsonutils v0.25.4/go.mod h1:7OYGXpvVFPn4PpaSdPHJBtF0iGnbEaTk8AvBkoWnaAY=
|
||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4 h1:IACsSvBhiNJwlDix7wq39SS2Fh7lUOCJRmx/4SN4sVo=
|
||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4/go.mod h1:Mt0Ost9l3cUzVv4OEZG+WSeoHwjWLnarzMePNDAOBiM=
|
||||
github.com/go-openapi/swag/loading v0.25.4 h1:jN4MvLj0X6yhCDduRsxDDw1aHe+ZWoLjW+9ZQWIKn2s=
|
||||
github.com/go-openapi/swag/loading v0.25.4/go.mod h1:rpUM1ZiyEP9+mNLIQUdMiD7dCETXvkkC30z53i+ftTE=
|
||||
github.com/go-openapi/swag/stringutils v0.25.4 h1:O6dU1Rd8bej4HPA3/CLPciNBBDwZj9HiEpdVsb8B5A8=
|
||||
github.com/go-openapi/swag/stringutils v0.25.4/go.mod h1:GTsRvhJW5xM5gkgiFe0fV3PUlFm0dr8vki6/VSRaZK0=
|
||||
github.com/go-openapi/swag/typeutils v0.25.4 h1:1/fbZOUN472NTc39zpa+YGHn3jzHWhv42wAJSN91wRw=
|
||||
github.com/go-openapi/swag/typeutils v0.25.4/go.mod h1:Ou7g//Wx8tTLS9vG0UmzfCsjZjKhpjxayRKTHXf2pTE=
|
||||
github.com/go-openapi/swag/yamlutils v0.25.4 h1:6jdaeSItEUb7ioS9lFoCZ65Cne1/RZtPBZ9A56h92Sw=
|
||||
github.com/go-openapi/swag/yamlutils v0.25.4/go.mod h1:MNzq1ulQu+yd8Kl7wPOut/YHAAU/H6hL91fF+E2RFwc=
|
||||
github.com/go-openapi/testify/enable/yaml/v2 v2.0.2 h1:0+Y41Pz1NkbTHz8NngxTuAXxEodtNSI1WG1c/m5Akw4=
|
||||
github.com/go-openapi/testify/enable/yaml/v2 v2.0.2/go.mod h1:kme83333GCtJQHXQ8UKX3IBZu6z8T5Dvy5+CW3NLUUg=
|
||||
github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls=
|
||||
github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54=
|
||||
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
|
||||
github.com/go-openapi/swag v0.26.0 h1:GVDXCmfvhfu1BxiHo8/FA+BbKmhecHnG3varjON5/RI=
|
||||
github.com/go-openapi/swag v0.26.0/go.mod h1:82g3193sZJRbocs7bNCqGfIgq8pkuwVwCfhKIRlEQF0=
|
||||
github.com/go-openapi/swag/conv v0.26.0 h1:5yGGsPYI1ZCva93U0AoKi/iZrNhaJEjr324YVsiD89I=
|
||||
github.com/go-openapi/swag/conv v0.26.0/go.mod h1:tpAmIL7X58VPnHHiSO4uE3jBeRamGsFsfdDeDtb5ECE=
|
||||
github.com/go-openapi/swag/jsonname v0.26.0 h1:gV1NFX9M8avo0YSpmWogqfQISigCmpaiNci8cGECU5w=
|
||||
github.com/go-openapi/swag/jsonname v0.26.0/go.mod h1:urBBR8bZNoDYGr653ynhIx+gTeIz0ARZxHkAPktJK2M=
|
||||
github.com/go-openapi/swag/jsonutils v0.26.0 h1:FawFML2iAXsPqmERscuMPIHmFsoP1tOqWkxBaKNMsnA=
|
||||
github.com/go-openapi/swag/jsonutils v0.26.0/go.mod h1:2VmA0CJlyFqgawOaPI9psnjFDqzyivIqLYN34t9p91E=
|
||||
github.com/go-openapi/swag/loading v0.26.0 h1:Apg6zaKhCJurpJer0DCxq99qwmhFddBhaMX7kilDcko=
|
||||
github.com/go-openapi/swag/loading v0.26.0/go.mod h1:dBxQ/6V2uBaAQdevN18VELE6xSpJWZxLX4txe12JwDg=
|
||||
github.com/go-openapi/swag/stringutils v0.26.0 h1:qZQngLxs5s7SLijc3N2ZO+fUq2o8LjuWAASSrJuh+xg=
|
||||
github.com/go-openapi/swag/stringutils v0.26.0/go.mod h1:sWn5uY+QIIspwPhvgnqJsH8xqFT2ZbYcvbcFanRyhFE=
|
||||
github.com/go-openapi/swag/typeutils v0.26.0 h1:2kdEwdiNWy+JJdOvu5MA2IIg2SylWAFuuyQIKYybfq4=
|
||||
github.com/go-openapi/swag/typeutils v0.26.0/go.mod h1:oovDuIUvTrEHVMqWilQzKzV4YlSKgyZmFh7AlfABNVE=
|
||||
github.com/go-openapi/swag/yamlutils v0.26.0 h1:H7O8l/8NJJQ/oiReEN+oMpnGMyt8G0hl460nRZxhLMQ=
|
||||
github.com/go-openapi/swag/yamlutils v0.26.0/go.mod h1:1evKEGAtP37Pkwcc7EWMF0hedX0/x3Rkvei2wtG/TbU=
|
||||
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI=
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
|
||||
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/gofiber/fiber/v3 v3.0.0-rc.3 h1:h0KXuRHbivSslIpoHD1R/XjUsjcGwt+2vK0avFiYonA=
|
||||
github.com/gofiber/fiber/v3 v3.0.0-rc.3/go.mod h1:LNBPuS/rGoUFlOyy03fXsWAeWfdGoT1QytwjRVNSVWo=
|
||||
github.com/gofiber/schema v1.6.0 h1:rAgVDFwhndtC+hgV7Vu5ItQCn7eC2mBA4Eu1/ZTiEYY=
|
||||
github.com/gofiber/schema v1.6.0/go.mod h1:WNZWpQx8LlPSK7ZaX0OqOh+nQo/eW2OevsXs1VZfs/s=
|
||||
github.com/gofiber/utils/v2 v2.0.0-rc.2 h1:NvJTf7yMafTq16lUOJv70nr+HIOLNQcvGme/X+ftbW8=
|
||||
github.com/gofiber/utils/v2 v2.0.0-rc.2/go.mod h1:gXins5o7up+BQFiubmO8aUJc/+Mhd7EKXIiAK5GBomI=
|
||||
github.com/gofiber/utils/v2 v2.0.0-rc.3 h1:gOL5jAEGUT2UbQkTkgMJctYt4rYewnTIt0Y7YaDATDc=
|
||||
github.com/gofiber/utils/v2 v2.0.0-rc.3/go.mod h1:gXins5o7up+BQFiubmO8aUJc/+Mhd7EKXIiAK5GBomI=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/gofiber/fiber/v3 v3.1.0 h1:1p4I820pIa+FGxfwWuQZ5rAyX0WlGZbGT6Hnuxt6hKY=
|
||||
github.com/gofiber/fiber/v3 v3.1.0/go.mod h1:n2nYQovvL9z3Too/FGOfgtERjW3GQcAUqgfoezGBZdU=
|
||||
github.com/gofiber/schema v1.7.0 h1:yNM+FNRZjyYEli9Ey0AXRBrAY9jTnb+kmGs3lJGPvKg=
|
||||
github.com/gofiber/schema v1.7.0/go.mod h1:A/X5Ffyru4p9eBdp99qu+nzviHzQiZ7odLT+TwxWhbk=
|
||||
github.com/gofiber/schema v1.7.1 h1:oSJBKdgP8JeIME4TQSAqlNKTU2iBB+2RNmKi8Nsc+TI=
|
||||
github.com/gofiber/schema v1.7.1/go.mod h1:A/X5Ffyru4p9eBdp99qu+nzviHzQiZ7odLT+TwxWhbk=
|
||||
github.com/gofiber/utils/v2 v2.0.2 h1:ShRRssz0F3AhTlAQcuEj54OEDtWF7+HJDwEi/aa6QLI=
|
||||
github.com/gofiber/utils/v2 v2.0.2/go.mod h1:+9Ub4NqQ+IaJoTliq5LfdmOJAA/Hzwf4pXOxOa3RrJ0=
|
||||
github.com/gofiber/utils/v2 v2.0.3 h1:qJyfS/t7s7Z4+/zlU1i1pafYNP2+xLupVPgkW8ce1uI=
|
||||
github.com/gofiber/utils/v2 v2.0.3/go.mod h1:GGERKU3Vhj5z6hS8YKvxL99A54DjOvTFZ0cjZnG4Lj4=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8=
|
||||
@@ -105,24 +122,27 @@ github.com/google/pprof v0.0.0-20250607225305-033d6d78b36a h1://KbezygeMJZCSHH+H
|
||||
github.com/google/pprof v0.0.0-20250607225305-033d6d78b36a/go.mod h1:5hDyRhoBCxViHszMt12TnOpEI4VVi+U8Gm9iphldiMA=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co=
|
||||
github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0=
|
||||
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
|
||||
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
|
||||
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
|
||||
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.8 h1:+StwCXwm9PdpiEkPyzBXIy+M9KUb4ODm0Zarf1kS5BM=
|
||||
github.com/klauspost/cpuid/v2 v2.2.8/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||
github.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4OxRzU=
|
||||
github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM=
|
||||
github.com/klauspost/crc32 v1.3.0/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw=
|
||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4=
|
||||
github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
|
||||
github.com/mailru/easyjson v0.9.2 h1:dX8U45hQsZpxd80nLvDGihsQ/OxlvTkVUXH2r/8cb2M=
|
||||
github.com/mailru/easyjson v0.9.2/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
@@ -130,20 +150,24 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/
|
||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
|
||||
github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
||||
github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI=
|
||||
github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
|
||||
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
|
||||
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
|
||||
github.com/minio/minio-go/v7 v7.0.80 h1:2mdUHXEykRdY/BigLt3Iuu1otL0JTogT0Nmltg0wujk=
|
||||
github.com/minio/minio-go/v7 v7.0.80/go.mod h1:84gmIilaX4zcvAWWzJ5Z1WI5axN+hAbM5w25xf8xvC0=
|
||||
github.com/minio/minio-go/v7 v7.0.97 h1:lqhREPyfgHTB/ciX8k2r8k0D93WaFqxbJX36UZq5occ=
|
||||
github.com/minio/minio-go/v7 v7.0.97/go.mod h1:re5VXuo0pwEtoNLsNuSr0RrLfT/MBtohwdaSmPPSRSk=
|
||||
github.com/onsi/ginkgo/v2 v2.23.4 h1:ktYTpKJAVZnDT4VjxSbiBenUjmlL/5QkBEocaWXiQus=
|
||||
github.com/onsi/ginkgo/v2 v2.23.4/go.mod h1:Bt66ApGPBFzHyR+JO10Zbt0Gsp4uWxu5mIOTusL46e8=
|
||||
github.com/onsi/gomega v1.37.0 h1:CdEG8g0S133B4OswTDC/5XPSzE1OeP29QOioj2PID2Y=
|
||||
github.com/onsi/gomega v1.37.0/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0=
|
||||
github.com/minio/minio-go/v7 v7.0.98 h1:MeAVKjLVz+XJ28zFcuYyImNSAh8Mq725uNW4beRisi0=
|
||||
github.com/minio/minio-go/v7 v7.0.98/go.mod h1:cY0Y+W7yozf0mdIclrttzo1Iiu7mEf9y7nk2uXqMOvM=
|
||||
github.com/minio/minio-go/v7 v7.0.100 h1:ShkWi8Tyj9RtU57OQB2HIXKz4bFgtVib0bbT1sbtLI8=
|
||||
github.com/minio/minio-go/v7 v7.0.100/go.mod h1:EtGNKtlX20iL2yaYnxEigaIvj0G0GwSDnifnG8ClIdw=
|
||||
github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns=
|
||||
github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
|
||||
github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A=
|
||||
github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM=
|
||||
github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
||||
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
@@ -153,16 +177,25 @@ github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/refraction-networking/utls v1.8.1 h1:yNY1kapmQU8JeM1sSw2H2asfTIwWxIkrMJI0pRUOCAo=
|
||||
github.com/refraction-networking/utls v1.8.1/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo=
|
||||
github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
|
||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
|
||||
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
|
||||
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
|
||||
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
|
||||
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
|
||||
github.com/rs/zerolog v1.35.0 h1:VD0ykx7HMiMJytqINBsKcbLS+BJ4WYjz+05us+LRTdI=
|
||||
github.com/rs/zerolog v1.35.0/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw=
|
||||
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
|
||||
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
|
||||
github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4=
|
||||
github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI=
|
||||
github.com/shamaton/msgpack/v2 v2.4.0 h1:O5Z08MRmbo0lA9o2xnQ4TXx6teJbPqEurqcCOQ8Oi/4=
|
||||
github.com/shamaton/msgpack/v2 v2.4.0/go.mod h1:6khjYnkx73f7VQU7wjcFS9DFjs+59naVWJv1TB7qdOI=
|
||||
github.com/shamaton/msgpack/v3 v3.1.0 h1:jsk0vEAqVvvS9+fTZ5/EcQ9tz860c9pWxJ4Iwecz8gU=
|
||||
github.com/shamaton/msgpack/v3 v3.1.0/go.mod h1:DcQG8jrdrQCIxr3HlMYkiXdMhK+KfN2CitkyzsQV4uc=
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
|
||||
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
||||
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
||||
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||
@@ -179,61 +212,78 @@ github.com/swaggo/files/v2 v2.0.2 h1:Bq4tgS/yxLB/3nwOMcul5oLEUKa877Ykgz3CJMVbQKU
|
||||
github.com/swaggo/files/v2 v2.0.2/go.mod h1:TVqetIzZsO9OhHX1Am9sRf9LdrFZqoK49N37KON/jr0=
|
||||
github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
|
||||
github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
|
||||
github.com/tinylib/msgp v1.5.0 h1:GWnqAE54wmnlFazjq2+vgr736Akg58iiHImh+kPY2pc=
|
||||
github.com/tinylib/msgp v1.5.0/go.mod h1:cvjFkb4RiC8qSBOPMGPSzSAx47nAsfhLVTCZZNuHv5o=
|
||||
github.com/tinylib/msgp v1.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY=
|
||||
github.com/tinylib/msgp v1.6.1/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
|
||||
github.com/tinylib/msgp v1.6.3 h1:bCSxiTz386UTgyT1i0MSCvdbWjVW+8sG3PjkGsZQt4s=
|
||||
github.com/tinylib/msgp v1.6.3/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
|
||||
github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
|
||||
github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fasthttp v1.68.0 h1:v12Nx16iepr8r9ySOwqI+5RBJ/DqTxhOy1HrHoDFnok=
|
||||
github.com/valyala/fasthttp v1.68.0/go.mod h1:5EXiRfYQAoiO/khu4oU9VISC/eVY6JqmSpPJoHCKsz4=
|
||||
github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZyVI=
|
||||
github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw=
|
||||
github.com/valyala/fasthttp v1.70.0 h1:LAhMGcWk13QZWm85+eg8ZBNbrq5mnkWFGbHMUJHIdXA=
|
||||
github.com/valyala/fasthttp v1.70.0/go.mod h1:oDZEHHkJ/Buyklg6uURmYs19442zFSnCIfX3j1FY3pE=
|
||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs=
|
||||
go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8=
|
||||
go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko=
|
||||
go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
||||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 h1:y5zboxd6LQAqYIhHnB48p0ByQ/GnQx2BE33L8BOHQkI=
|
||||
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6/go.mod h1:U6Lno4MTRCDY+Ba7aCcauB9T60gsv5s4ralQzP72ZoQ=
|
||||
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
|
||||
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
|
||||
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
|
||||
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
|
||||
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
|
||||
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
|
||||
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
|
||||
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
||||
golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo=
|
||||
golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
||||
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
|
||||
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
||||
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
|
||||
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
||||
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
|
||||
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
|
||||
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
||||
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
||||
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
|
||||
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"Noooste/garage-ui/pkg/logger"
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
@@ -13,8 +15,8 @@ import (
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
// AuthService handles authentication operations
|
||||
type AuthService struct {
|
||||
// Service handles authentication operations
|
||||
type Service struct {
|
||||
authConfig *config.AuthConfig
|
||||
serverConfig *config.ServerConfig
|
||||
oidcProvider *oidc.Provider
|
||||
@@ -32,13 +34,13 @@ type UserInfo struct {
|
||||
}
|
||||
|
||||
// NewAuthService creates a new authentication service
|
||||
func NewAuthService(authCfg *config.AuthConfig, serverCfg *config.ServerConfig) (*AuthService, error) {
|
||||
func NewAuthService(authCfg *config.AuthConfig, serverCfg *config.ServerConfig) (*Service, error) {
|
||||
jwtService, err := NewJWTServiceWithKey(authCfg.JWTPrivKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize JWT service: %w", err)
|
||||
}
|
||||
|
||||
service := &AuthService{
|
||||
service := &Service{
|
||||
authConfig: authCfg,
|
||||
serverConfig: serverCfg,
|
||||
jwtService: jwtService,
|
||||
@@ -55,7 +57,7 @@ func NewAuthService(authCfg *config.AuthConfig, serverCfg *config.ServerConfig)
|
||||
}
|
||||
|
||||
// initOIDC initializes the OIDC provider and configuration
|
||||
func (a *AuthService) initOIDC() error {
|
||||
func (a *Service) initOIDC() error {
|
||||
ctx := context.Background()
|
||||
|
||||
// Create OIDC provider
|
||||
@@ -91,7 +93,7 @@ func (a *AuthService) initOIDC() error {
|
||||
}
|
||||
|
||||
// ValidateBasicAuth validates basic authentication credentials
|
||||
func (a *AuthService) ValidateBasicAuth(username, password string) bool {
|
||||
func (a *Service) ValidateBasicAuth(username, password string) bool {
|
||||
// Use constant-time comparison to prevent timing attacks
|
||||
usernameMatch := subtle.ConstantTimeCompare(
|
||||
[]byte(username),
|
||||
@@ -106,37 +108,8 @@ func (a *AuthService) ValidateBasicAuth(username, password string) bool {
|
||||
return usernameMatch && passwordMatch
|
||||
}
|
||||
|
||||
// ParseBasicAuth parses the Authorization header for basic auth
|
||||
func ParseBasicAuth(authHeader string) (username, password string, ok bool) {
|
||||
if authHeader == "" {
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
// Check if it's a Basic auth header
|
||||
const prefix = "Basic "
|
||||
if !strings.HasPrefix(authHeader, prefix) {
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
// Decode base64 credentials
|
||||
encoded := authHeader[len(prefix):]
|
||||
decoded, err := base64.StdEncoding.DecodeString(encoded)
|
||||
if err != nil {
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
// Split username:password
|
||||
credentials := string(decoded)
|
||||
parts := strings.SplitN(credentials, ":", 2)
|
||||
if len(parts) != 2 {
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
return parts[0], parts[1], true
|
||||
}
|
||||
|
||||
// GetAuthorizationURL returns the OIDC authorization URL for login
|
||||
func (a *AuthService) GetAuthorizationURL(state string) (string, error) {
|
||||
func (a *Service) GetAuthorizationURL(state string) (string, error) {
|
||||
if a.oauth2Config == nil {
|
||||
return "", fmt.Errorf("OIDC not initialized")
|
||||
}
|
||||
@@ -145,7 +118,7 @@ func (a *AuthService) GetAuthorizationURL(state string) (string, error) {
|
||||
}
|
||||
|
||||
// ExchangeCode exchanges an authorization code for tokens
|
||||
func (a *AuthService) ExchangeCode(ctx context.Context, code string) (*oauth2.Token, error) {
|
||||
func (a *Service) ExchangeCode(ctx context.Context, code string) (*oauth2.Token, error) {
|
||||
if a.oauth2Config == nil {
|
||||
return nil, fmt.Errorf("OIDC not initialized")
|
||||
}
|
||||
@@ -159,7 +132,7 @@ func (a *AuthService) ExchangeCode(ctx context.Context, code string) (*oauth2.To
|
||||
}
|
||||
|
||||
// VerifyIDToken verifies an OIDC ID token and extracts user info
|
||||
func (a *AuthService) VerifyIDToken(ctx context.Context, rawIDToken string) (*UserInfo, error) {
|
||||
func (a *Service) VerifyIDToken(ctx context.Context, rawIDToken string) (*UserInfo, error) {
|
||||
if a.oidcVerifier == nil {
|
||||
return nil, fmt.Errorf("OIDC not initialized")
|
||||
}
|
||||
@@ -192,7 +165,7 @@ func (a *AuthService) VerifyIDToken(ctx context.Context, rawIDToken string) (*Us
|
||||
}
|
||||
|
||||
// GetUserInfo retrieves user information from the OIDC provider
|
||||
func (a *AuthService) GetUserInfo(ctx context.Context, token *oauth2.Token) (*UserInfo, error) {
|
||||
func (a *Service) GetUserInfo(ctx context.Context, token *oauth2.Token) (*UserInfo, error) {
|
||||
if a.oidcProvider == nil {
|
||||
return nil, fmt.Errorf("OIDC not initialized")
|
||||
}
|
||||
@@ -212,6 +185,8 @@ func (a *AuthService) GetUserInfo(ctx context.Context, token *oauth2.Token) (*Us
|
||||
return nil, fmt.Errorf("failed to parse user info claims: %w", err)
|
||||
}
|
||||
|
||||
logger.Debug().Interface("claims", claims).Msg("Extracted user info claims")
|
||||
|
||||
// Build user info
|
||||
userInfo := &UserInfo{
|
||||
Username: extractClaim(claims, a.authConfig.OIDC.UsernameAttribute),
|
||||
@@ -227,8 +202,38 @@ func (a *AuthService) GetUserInfo(ctx context.Context, token *oauth2.Token) (*Us
|
||||
return userInfo, nil
|
||||
}
|
||||
|
||||
// ExtractRolesFromAccessToken parses the access token JWT payload and extracts
|
||||
// roles using the configured role_attribute_path. Keycloak emits resource_access
|
||||
// claims only in the access token by default, so this is required to support
|
||||
// the common Keycloak client-role setup without extra mapper configuration.
|
||||
//
|
||||
// The access token was obtained via a verified code exchange with the provider,
|
||||
// so parsing its claims without re-verifying the signature is safe here.
|
||||
func (a *Service) ExtractRolesFromAccessToken(accessToken string) []string {
|
||||
if accessToken == "" || a.authConfig.OIDC.RoleAttributePath == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
parts := strings.Split(accessToken, ".")
|
||||
if len(parts) < 2 {
|
||||
return nil
|
||||
}
|
||||
|
||||
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var claims map[string]interface{}
|
||||
if err := json.Unmarshal(payload, &claims); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return extractRoles(claims, a.authConfig.OIDC.RoleAttributePath)
|
||||
}
|
||||
|
||||
// IsAdmin checks if the user has admin role
|
||||
func (a *AuthService) IsAdmin(userInfo *UserInfo) bool {
|
||||
func (a *Service) IsAdmin(userInfo *UserInfo) bool {
|
||||
if a.authConfig.OIDC.AdminRole == "" {
|
||||
return false
|
||||
}
|
||||
@@ -316,22 +321,31 @@ func extractStringArray(value interface{}) []string {
|
||||
}
|
||||
|
||||
// GenerateStateToken generates a secure CSRF state token
|
||||
func (a *AuthService) GenerateStateToken() (string, error) {
|
||||
func (a *Service) GenerateStateToken() (string, error) {
|
||||
return a.jwtService.GenerateStateToken()
|
||||
}
|
||||
|
||||
// ValidateAndConsumeState validates and consumes a CSRF state token
|
||||
func (a *AuthService) ValidateAndConsumeState(token string) bool {
|
||||
func (a *Service) ValidateAndConsumeState(token string) bool {
|
||||
return a.jwtService.ValidateAndConsumeState(token)
|
||||
}
|
||||
|
||||
// GenerateSessionToken generates a JWT session token for the user
|
||||
func (a *AuthService) GenerateSessionToken(userInfo *UserInfo) (string, error) {
|
||||
return a.jwtService.GenerateToken(userInfo, a.authConfig.OIDC.SessionMaxAge)
|
||||
// GenerateSessionToken generates a JWT session token for the user.
|
||||
//
|
||||
// SessionMaxAge lives under the OIDC config block, but the JWT is shared with
|
||||
// admin-only deployments that never set it. Treat a non-positive value as
|
||||
// "not configured" and fall back to 24h so the token isn't issued already
|
||||
// expired (which would make every subsequent request fail with 401).
|
||||
func (a *Service) GenerateSessionToken(userInfo *UserInfo) (string, error) {
|
||||
maxAge := a.authConfig.OIDC.SessionMaxAge
|
||||
if maxAge <= 0 {
|
||||
maxAge = 86400
|
||||
}
|
||||
return a.jwtService.GenerateToken(userInfo, maxAge)
|
||||
}
|
||||
|
||||
// ValidateSessionToken validates a JWT session token and returns user info
|
||||
func (a *AuthService) ValidateSessionToken(tokenString string) (*UserInfo, error) {
|
||||
func (a *Service) ValidateSessionToken(tokenString string) (*UserInfo, error) {
|
||||
claims, err := a.jwtService.ValidateToken(tokenString)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -0,0 +1,556 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"Noooste/garage-ui/internal/config"
|
||||
)
|
||||
|
||||
func TestExtractRolesFromAccessToken_Keycloak(t *testing.T) {
|
||||
payload := map[string]interface{}{
|
||||
"resource_access": map[string]interface{}{
|
||||
"GarageAdminUi": map[string]interface{}{
|
||||
"roles": []interface{}{"admin"},
|
||||
},
|
||||
"account": map[string]interface{}{
|
||||
"roles": []interface{}{"view-profile"},
|
||||
},
|
||||
},
|
||||
}
|
||||
raw, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
token := "header." + base64.RawURLEncoding.EncodeToString(raw) + ".sig"
|
||||
|
||||
svc := &Service{
|
||||
authConfig: &config.AuthConfig{
|
||||
OIDC: config.OIDCConfig{
|
||||
RoleAttributePath: "resource_access.GarageAdminUi.roles",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
roles := svc.ExtractRolesFromAccessToken(token)
|
||||
if len(roles) != 1 || roles[0] != "admin" {
|
||||
t.Fatalf("expected [admin], got %v", roles)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractRolesFromAccessToken_NoRoleClaim(t *testing.T) {
|
||||
payload := map[string]interface{}{"sub": "user"}
|
||||
raw, _ := json.Marshal(payload)
|
||||
token := "header." + base64.RawURLEncoding.EncodeToString(raw) + ".sig"
|
||||
|
||||
svc := &Service{
|
||||
authConfig: &config.AuthConfig{
|
||||
OIDC: config.OIDCConfig{RoleAttributePath: "resource_access.GarageAdminUi.roles"},
|
||||
},
|
||||
}
|
||||
|
||||
if roles := svc.ExtractRolesFromAccessToken(token); roles != nil {
|
||||
t.Fatalf("expected nil, got %v", roles)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractRolesFromAccessToken_Malformed(t *testing.T) {
|
||||
svc := &Service{
|
||||
authConfig: &config.AuthConfig{
|
||||
OIDC: config.OIDCConfig{RoleAttributePath: "resource_access.GarageAdminUi.roles"},
|
||||
},
|
||||
}
|
||||
if roles := svc.ExtractRolesFromAccessToken("not-a-jwt"); roles != nil {
|
||||
t.Fatalf("expected nil for malformed token, got %v", roles)
|
||||
}
|
||||
if roles := svc.ExtractRolesFromAccessToken(""); roles != nil {
|
||||
t.Fatalf("expected nil for empty token, got %v", roles)
|
||||
}
|
||||
}
|
||||
|
||||
// Regression for issue #16: admin-only deployments never set
|
||||
// OIDC.SessionMaxAge, leaving it at 0. Before the fix, that produced a JWT
|
||||
// whose exp == iat, so every request after /auth/login was rejected as
|
||||
// expired and the client saw UNAUTHORIZED.
|
||||
func TestGenerateSessionToken_ZeroSessionMaxAge_IsNotImmediatelyExpired(t *testing.T) {
|
||||
jwtSvc, err := NewJWTService()
|
||||
if err != nil {
|
||||
t.Fatalf("NewJWTService: %v", err)
|
||||
}
|
||||
|
||||
svc := &Service{
|
||||
authConfig: &config.AuthConfig{
|
||||
Admin: config.AdminAuthConfig{Enabled: true, Username: "admin", Password: "pw"},
|
||||
// OIDC disabled; SessionMaxAge left at zero value.
|
||||
},
|
||||
jwtService: jwtSvc,
|
||||
}
|
||||
|
||||
token, err := svc.GenerateSessionToken(&UserInfo{Username: "admin"})
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateSessionToken: %v", err)
|
||||
}
|
||||
|
||||
if _, err := svc.ValidateSessionToken(token); err != nil {
|
||||
t.Fatalf("freshly issued admin session token failed validation: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Task 5: ValidateBasicAuth
|
||||
// (ParseBasicAuth was removed from production in commit d0040be; nothing to test.)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestValidateBasicAuth(t *testing.T) {
|
||||
svc := &Service{
|
||||
authConfig: &config.AuthConfig{
|
||||
Admin: config.AdminAuthConfig{
|
||||
Enabled: true,
|
||||
Username: "admin",
|
||||
Password: "correct-horse",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
user string
|
||||
pass string
|
||||
want bool
|
||||
}{
|
||||
{"correct credentials", "admin", "correct-horse", true},
|
||||
{"wrong password", "admin", "nope", false},
|
||||
{"wrong username", "root", "correct-horse", false},
|
||||
{"both wrong", "x", "y", false},
|
||||
{"empty username", "", "correct-horse", false},
|
||||
{"empty password", "admin", "", false},
|
||||
{"both empty", "", "", false},
|
||||
{"username prefix attack", "admi", "correct-horse", false},
|
||||
{"password prefix attack", "admin", "correct-hors", false},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := svc.ValidateBasicAuth(tc.user, tc.pass); got != tc.want {
|
||||
t.Errorf("ValidateBasicAuth(%q,%q) = %v, want %v", tc.user, tc.pass, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateBasicAuth_AdminDisabledStillComparesAgainstEmpty(t *testing.T) {
|
||||
// When admin is disabled, the configured username/password are typically
|
||||
// empty strings. ValidateBasicAuth itself does not gate on Enabled (that
|
||||
// happens in middleware). Pin that behavior so a future refactor can't
|
||||
// silently change semantics.
|
||||
svc := &Service{
|
||||
authConfig: &config.AuthConfig{
|
||||
Admin: config.AdminAuthConfig{Enabled: false},
|
||||
},
|
||||
}
|
||||
if !svc.ValidateBasicAuth("", "") {
|
||||
t.Error("empty creds should match empty configured creds")
|
||||
}
|
||||
if svc.ValidateBasicAuth("anything", "") {
|
||||
t.Error("non-empty user should not match empty configured user")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Task 6: OIDC initOIDC cases
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// newDiscoveryServer returns an httptest.Server that serves a minimal but
|
||||
// valid OIDC discovery document and a JWKS endpoint (empty key set is fine
|
||||
// for init — we are not verifying any token here). The discovery document's
|
||||
// `issuer` field MUST equal the server's URL or oidc.NewProvider rejects it.
|
||||
func newDiscoveryServer(t *testing.T) *httptest.Server {
|
||||
t.Helper()
|
||||
mux := http.NewServeMux()
|
||||
var srv *httptest.Server
|
||||
mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) {
|
||||
doc := map[string]any{
|
||||
"issuer": srv.URL,
|
||||
"authorization_endpoint": srv.URL + "/auth",
|
||||
"token_endpoint": srv.URL + "/token",
|
||||
"jwks_uri": srv.URL + "/jwks",
|
||||
"userinfo_endpoint": srv.URL + "/userinfo",
|
||||
"id_token_signing_alg_values_supported": []string{"RS256", "EdDSA"},
|
||||
"response_types_supported": []string{"code"},
|
||||
"subject_types_supported": []string{"public"},
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(doc)
|
||||
})
|
||||
mux.HandleFunc("/jwks", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"keys":[]}`))
|
||||
})
|
||||
srv = httptest.NewServer(mux)
|
||||
t.Cleanup(srv.Close)
|
||||
return srv
|
||||
}
|
||||
|
||||
func TestNewAuthService_OIDCDisabled_DoesNotInitProvider(t *testing.T) {
|
||||
svc, err := NewAuthService(
|
||||
&config.AuthConfig{
|
||||
Admin: config.AdminAuthConfig{Enabled: true, Username: "u", Password: "p"},
|
||||
OIDC: config.OIDCConfig{Enabled: false},
|
||||
},
|
||||
&config.ServerConfig{},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("NewAuthService: %v", err)
|
||||
}
|
||||
if svc.oidcProvider != nil {
|
||||
t.Error("oidcProvider should be nil when OIDC disabled")
|
||||
}
|
||||
if svc.oidcVerifier != nil {
|
||||
t.Error("oidcVerifier should be nil when OIDC disabled")
|
||||
}
|
||||
if svc.oauth2Config != nil {
|
||||
t.Error("oauth2Config should be nil when OIDC disabled")
|
||||
}
|
||||
if svc.jwtService == nil {
|
||||
t.Error("jwtService must always be initialized")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewAuthService_OIDCEnabled_DiscoversProvider(t *testing.T) {
|
||||
disco := newDiscoveryServer(t)
|
||||
|
||||
authCfg := &config.AuthConfig{
|
||||
OIDC: config.OIDCConfig{
|
||||
Enabled: true,
|
||||
ClientID: "test-client",
|
||||
IssuerURL: disco.URL,
|
||||
Scopes: []string{"openid", "profile"},
|
||||
AdminRole: "admin",
|
||||
},
|
||||
}
|
||||
srvCfg := &config.ServerConfig{
|
||||
RootURL: "https://garage-ui.example",
|
||||
}
|
||||
|
||||
svc, err := NewAuthService(authCfg, srvCfg)
|
||||
if err != nil {
|
||||
t.Fatalf("NewAuthService: %v", err)
|
||||
}
|
||||
if svc.oidcProvider == nil {
|
||||
t.Fatal("oidcProvider not initialized")
|
||||
}
|
||||
if svc.oidcVerifier == nil {
|
||||
t.Fatal("oidcVerifier not initialized")
|
||||
}
|
||||
if svc.oauth2Config == nil {
|
||||
t.Fatal("oauth2Config not initialized")
|
||||
}
|
||||
if svc.oauth2Config.ClientID != "test-client" {
|
||||
t.Errorf("ClientID = %q, want test-client", svc.oauth2Config.ClientID)
|
||||
}
|
||||
wantRedirect := "https://garage-ui.example/auth/oidc/callback"
|
||||
if svc.oauth2Config.RedirectURL != wantRedirect {
|
||||
t.Errorf("RedirectURL = %q, want %q", svc.oauth2Config.RedirectURL, wantRedirect)
|
||||
}
|
||||
if len(svc.oauth2Config.Scopes) != 2 {
|
||||
t.Errorf("Scopes length = %d, want 2", len(svc.oauth2Config.Scopes))
|
||||
}
|
||||
// Endpoint should be wired from the discovery doc.
|
||||
if svc.oauth2Config.Endpoint.AuthURL != disco.URL+"/auth" {
|
||||
t.Errorf("Endpoint.AuthURL = %q", svc.oauth2Config.Endpoint.AuthURL)
|
||||
}
|
||||
if svc.oauth2Config.Endpoint.TokenURL != disco.URL+"/token" {
|
||||
t.Errorf("Endpoint.TokenURL = %q", svc.oauth2Config.Endpoint.TokenURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewAuthService_OIDCEnabled_BadIssuerURLReturnsError(t *testing.T) {
|
||||
authCfg := &config.AuthConfig{
|
||||
OIDC: config.OIDCConfig{
|
||||
Enabled: true,
|
||||
ClientID: "test-client",
|
||||
IssuerURL: "http://127.0.0.1:1", // refused — nothing listens on port 1
|
||||
Scopes: []string{"openid"},
|
||||
AdminRole: "admin",
|
||||
},
|
||||
}
|
||||
srvCfg := &config.ServerConfig{RootURL: "https://garage-ui.example"}
|
||||
|
||||
_, err := NewAuthService(authCfg, srvCfg)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unreachable issuer, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "failed to initialize OIDC") {
|
||||
t.Errorf("expected wrapping error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAuthorizationURL_OIDCDisabledReturnsError(t *testing.T) {
|
||||
svc := &Service{authConfig: &config.AuthConfig{}, serverConfig: &config.ServerConfig{}}
|
||||
if _, err := svc.GetAuthorizationURL("state-x"); err == nil {
|
||||
t.Error("expected error when OIDC not initialized")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAuthorizationURL_OIDCEnabledIncludesState(t *testing.T) {
|
||||
disco := newDiscoveryServer(t)
|
||||
svc, err := NewAuthService(
|
||||
&config.AuthConfig{
|
||||
OIDC: config.OIDCConfig{
|
||||
Enabled: true,
|
||||
ClientID: "test-client",
|
||||
IssuerURL: disco.URL,
|
||||
Scopes: []string{"openid"},
|
||||
AdminRole: "admin",
|
||||
},
|
||||
},
|
||||
&config.ServerConfig{RootURL: "https://garage-ui.example"},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("NewAuthService: %v", err)
|
||||
}
|
||||
|
||||
url, err := svc.GetAuthorizationURL("my-state-token")
|
||||
if err != nil {
|
||||
t.Fatalf("GetAuthorizationURL: %v", err)
|
||||
}
|
||||
if !strings.Contains(url, "state=my-state-token") {
|
||||
t.Errorf("URL missing state param: %s", url)
|
||||
}
|
||||
if !strings.Contains(url, "client_id=test-client") {
|
||||
t.Errorf("URL missing client_id: %s", url)
|
||||
}
|
||||
if !strings.Contains(url, "redirect_uri=") {
|
||||
t.Errorf("URL missing redirect_uri: %s", url)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Task 7: ValidateSessionToken and expanded ExtractRolesFromAccessToken
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// newServiceWithJWT wires a Service with a real JWTService so the session
|
||||
// helpers can be exercised end-to-end. OIDC is left disabled.
|
||||
func newServiceWithJWT(t *testing.T) *Service {
|
||||
t.Helper()
|
||||
jwtSvc, err := NewJWTService()
|
||||
if err != nil {
|
||||
t.Fatalf("NewJWTService: %v", err)
|
||||
}
|
||||
return &Service{
|
||||
authConfig: &config.AuthConfig{},
|
||||
serverConfig: &config.ServerConfig{},
|
||||
jwtService: jwtSvc,
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSessionToken_HappyPath(t *testing.T) {
|
||||
svc := newServiceWithJWT(t)
|
||||
user := &UserInfo{
|
||||
Username: "alice",
|
||||
Email: "alice@example.com",
|
||||
Name: "Alice",
|
||||
Roles: []string{"admin"},
|
||||
}
|
||||
tok, err := svc.GenerateSessionToken(user)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateSessionToken: %v", err)
|
||||
}
|
||||
got, err := svc.ValidateSessionToken(tok)
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateSessionToken: %v", err)
|
||||
}
|
||||
if got.Username != user.Username || got.Email != user.Email || got.Name != user.Name {
|
||||
t.Errorf("got %+v, want %+v", got, user)
|
||||
}
|
||||
if len(got.Roles) != 1 || got.Roles[0] != "admin" {
|
||||
t.Errorf("Roles = %v, want [admin]", got.Roles)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSessionToken_Expired(t *testing.T) {
|
||||
svc := newServiceWithJWT(t)
|
||||
// Bypass GenerateSessionToken's "fall back to 24h on non-positive" guard
|
||||
// by going straight through the JWT service with a negative TTL.
|
||||
tok, err := svc.jwtService.GenerateToken(&UserInfo{Username: "a"}, -1)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateToken: %v", err)
|
||||
}
|
||||
if _, err := svc.ValidateSessionToken(tok); err == nil {
|
||||
t.Error("expected expired-token error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSessionToken_BadSignatureRejected(t *testing.T) {
|
||||
signer := newServiceWithJWT(t)
|
||||
verifier := newServiceWithJWT(t)
|
||||
tok, err := signer.GenerateSessionToken(&UserInfo{Username: "a"})
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateSessionToken: %v", err)
|
||||
}
|
||||
if _, err := verifier.ValidateSessionToken(tok); err == nil {
|
||||
t.Error("expected signature-mismatch error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSessionToken_EmptyTokenRejected(t *testing.T) {
|
||||
svc := newServiceWithJWT(t)
|
||||
if _, err := svc.ValidateSessionToken(""); err == nil {
|
||||
t.Error("expected error for empty token, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// makeAccessToken builds a JWT-shaped string with arbitrary claims. The
|
||||
// signature segment is junk because ExtractRolesFromAccessToken does NOT
|
||||
// verify the signature (per the doc-comment: "obtained via a verified code
|
||||
// exchange, so parsing without re-verifying is safe").
|
||||
func makeAccessToken(t *testing.T, claims map[string]any) string {
|
||||
t.Helper()
|
||||
raw, err := json.Marshal(claims)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
return "header." + base64.RawURLEncoding.EncodeToString(raw) + ".sig"
|
||||
}
|
||||
|
||||
func TestExtractRolesFromAccessToken_DeeplyNestedPath(t *testing.T) {
|
||||
tok := makeAccessToken(t, map[string]any{
|
||||
"a": map[string]any{
|
||||
"b": map[string]any{
|
||||
"c": map[string]any{
|
||||
"roles": []any{"r1", "r2", "r3"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
svc := &Service{
|
||||
authConfig: &config.AuthConfig{
|
||||
OIDC: config.OIDCConfig{RoleAttributePath: "a.b.c.roles"},
|
||||
},
|
||||
}
|
||||
got := svc.ExtractRolesFromAccessToken(tok)
|
||||
if len(got) != 3 || got[0] != "r1" || got[2] != "r3" {
|
||||
t.Errorf("got %v, want [r1 r2 r3]", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractRolesFromAccessToken_MixedTypeArrayDropsNonStrings(t *testing.T) {
|
||||
tok := makeAccessToken(t, map[string]any{
|
||||
"roles": []any{"admin", 42, "viewer", true, nil, "writer"},
|
||||
})
|
||||
svc := &Service{
|
||||
authConfig: &config.AuthConfig{
|
||||
OIDC: config.OIDCConfig{RoleAttributePath: "roles"},
|
||||
},
|
||||
}
|
||||
got := svc.ExtractRolesFromAccessToken(tok)
|
||||
want := []string{"admin", "viewer", "writer"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("got %v, want %v", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Errorf("got[%d] = %q, want %q", i, got[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractRolesFromAccessToken_EmptyPathReturnsNil(t *testing.T) {
|
||||
tok := makeAccessToken(t, map[string]any{"roles": []any{"admin"}})
|
||||
svc := &Service{
|
||||
authConfig: &config.AuthConfig{OIDC: config.OIDCConfig{RoleAttributePath: ""}},
|
||||
}
|
||||
if got := svc.ExtractRolesFromAccessToken(tok); got != nil {
|
||||
t.Errorf("expected nil for empty path, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractRolesFromAccessToken_IntermediateNodeNotMap(t *testing.T) {
|
||||
// Path tries to descend through a string — extractRoles must bail with nil.
|
||||
tok := makeAccessToken(t, map[string]any{
|
||||
"resource_access": "this-should-be-a-map",
|
||||
})
|
||||
svc := &Service{
|
||||
authConfig: &config.AuthConfig{
|
||||
OIDC: config.OIDCConfig{RoleAttributePath: "resource_access.client.roles"},
|
||||
},
|
||||
}
|
||||
if got := svc.ExtractRolesFromAccessToken(tok); got != nil {
|
||||
t.Errorf("expected nil when path traverses non-map, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractRolesFromAccessToken_FinalValueWrongType(t *testing.T) {
|
||||
// Final value is a plain string, not an array — extractStringArray returns nil.
|
||||
tok := makeAccessToken(t, map[string]any{
|
||||
"roles": "admin",
|
||||
})
|
||||
svc := &Service{
|
||||
authConfig: &config.AuthConfig{
|
||||
OIDC: config.OIDCConfig{RoleAttributePath: "roles"},
|
||||
},
|
||||
}
|
||||
if got := svc.ExtractRolesFromAccessToken(tok); got != nil {
|
||||
t.Errorf("expected nil for non-array roles, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractRolesFromAccessToken_BadBase64InPayload(t *testing.T) {
|
||||
svc := &Service{
|
||||
authConfig: &config.AuthConfig{
|
||||
OIDC: config.OIDCConfig{RoleAttributePath: "roles"},
|
||||
},
|
||||
}
|
||||
if got := svc.ExtractRolesFromAccessToken("hdr.!!!not-base64!!!.sig"); got != nil {
|
||||
t.Errorf("expected nil for bad base64, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractRolesFromAccessToken_BadJSONInPayload(t *testing.T) {
|
||||
svc := &Service{
|
||||
authConfig: &config.AuthConfig{
|
||||
OIDC: config.OIDCConfig{RoleAttributePath: "roles"},
|
||||
},
|
||||
}
|
||||
// Valid base64 of "not json"
|
||||
tok := "hdr." + base64.RawURLEncoding.EncodeToString([]byte("not json")) + ".sig"
|
||||
if got := svc.ExtractRolesFromAccessToken(tok); got != nil {
|
||||
t.Errorf("expected nil for non-JSON payload, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Task 8: IsAdmin coverage
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestIsAdmin(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
adminRole string
|
||||
userRoles []string
|
||||
want bool
|
||||
}{
|
||||
{"empty admin role config returns false", "", []string{"admin"}, false},
|
||||
{"user has admin role", "admin", []string{"viewer", "admin"}, true},
|
||||
{"user lacks admin role", "admin", []string{"viewer"}, false},
|
||||
{"user has no roles", "admin", nil, false},
|
||||
{"role match is exact (case-sensitive)", "admin", []string{"Admin"}, false},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
svc := &Service{
|
||||
authConfig: &config.AuthConfig{
|
||||
OIDC: config.OIDCConfig{AdminRole: tc.adminRole},
|
||||
},
|
||||
}
|
||||
if got := svc.IsAdmin(&UserInfo{Roles: tc.userRoles}); got != tc.want {
|
||||
t.Errorf("IsAdmin = %v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,469 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
// generatePKCS8PEM produces a PEM-encoded PKCS#8 Ed25519 private key.
|
||||
// This is the format `openssl genpkey -algorithm ED25519` emits and the
|
||||
// format the production code documents in jwt_private_key.
|
||||
func generatePKCS8PEM(t *testing.T) (string, ed25519.PrivateKey) {
|
||||
t.Helper()
|
||||
_, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("ed25519.GenerateKey: %v", err)
|
||||
}
|
||||
der, err := x509.MarshalPKCS8PrivateKey(priv)
|
||||
if err != nil {
|
||||
t.Fatalf("MarshalPKCS8PrivateKey: %v", err)
|
||||
}
|
||||
pemBytes := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der})
|
||||
return string(pemBytes), priv
|
||||
}
|
||||
|
||||
// generateRawPEM wraps a raw 64-byte Ed25519 key in a PEM block. The
|
||||
// production code accepts this as a fallback when PKCS#8 parsing fails.
|
||||
func generateRawPEM(t *testing.T) (string, ed25519.PrivateKey) {
|
||||
t.Helper()
|
||||
_, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("ed25519.GenerateKey: %v", err)
|
||||
}
|
||||
pemBytes := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: priv})
|
||||
return string(pemBytes), priv
|
||||
}
|
||||
|
||||
func TestParseEd25519PrivateKeyFromPEM_PKCS8(t *testing.T) {
|
||||
pemStr, want := generatePKCS8PEM(t)
|
||||
got, err := parseEd25519PrivateKeyFromPEM(pemStr)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !got.Equal(want) {
|
||||
t.Errorf("parsed key does not equal generated key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEd25519PrivateKeyFromPEM_RawBytes(t *testing.T) {
|
||||
pemStr, want := generateRawPEM(t)
|
||||
got, err := parseEd25519PrivateKeyFromPEM(pemStr)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !got.Equal(want) {
|
||||
t.Errorf("parsed raw key does not equal generated key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEd25519PrivateKeyFromPEM_NotPEM(t *testing.T) {
|
||||
_, err := parseEd25519PrivateKeyFromPEM("this is not a pem block")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-PEM input, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "decode PEM block") {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEd25519PrivateKeyFromPEM_PKCS8WrongKeyType(t *testing.T) {
|
||||
// Generate a non-Ed25519 PKCS#8 key (RSA would require crypto/rsa; instead
|
||||
// we craft a PKCS#8 wrapping for an ECDSA key via x509). The simplest
|
||||
// portable way is to use a known-bad DER blob: a PKCS#8 wrapping of an
|
||||
// ed25519 PUBLIC key, which ParsePKCS8PrivateKey will reject as not a
|
||||
// private key. To keep the test deterministic and dependency-free, we
|
||||
// instead build a PEM of length-mismatched bytes that's neither PKCS#8
|
||||
// nor 64 raw bytes.
|
||||
pemBytes := pem.EncodeToMemory(&pem.Block{
|
||||
Type: "PRIVATE KEY",
|
||||
Bytes: []byte("definitely not a valid pkcs8 or raw ed25519 key"),
|
||||
})
|
||||
_, err := parseEd25519PrivateKeyFromPEM(string(pemBytes))
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid key bytes, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid Ed25519 private key format") {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewJWTService_AutoGeneratesKeyPair(t *testing.T) {
|
||||
svc, err := NewJWTService()
|
||||
if err != nil {
|
||||
t.Fatalf("NewJWTService: %v", err)
|
||||
}
|
||||
if svc.privateKey == nil {
|
||||
t.Error("privateKey is nil after auto-generate")
|
||||
}
|
||||
if svc.publicKey == nil {
|
||||
t.Error("publicKey is nil after auto-generate")
|
||||
}
|
||||
if len(svc.privateKey) != ed25519.PrivateKeySize {
|
||||
t.Errorf("privateKey size = %d, want %d", len(svc.privateKey), ed25519.PrivateKeySize)
|
||||
}
|
||||
if len(svc.publicKey) != ed25519.PublicKeySize {
|
||||
t.Errorf("publicKey size = %d, want %d", len(svc.publicKey), ed25519.PublicKeySize)
|
||||
}
|
||||
if svc.stateStore == nil || svc.stateStore.states == nil {
|
||||
t.Error("stateStore not initialized")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewJWTServiceWithKey_EmptyStringAutoGenerates(t *testing.T) {
|
||||
svc, err := NewJWTServiceWithKey("")
|
||||
if err != nil {
|
||||
t.Fatalf("NewJWTServiceWithKey(\"\"): %v", err)
|
||||
}
|
||||
if svc.privateKey == nil || svc.publicKey == nil {
|
||||
t.Error("expected auto-generated keys for empty PEM input")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewJWTServiceWithKey_PKCS8(t *testing.T) {
|
||||
pemStr, want := generatePKCS8PEM(t)
|
||||
svc, err := NewJWTServiceWithKey(pemStr)
|
||||
if err != nil {
|
||||
t.Fatalf("NewJWTServiceWithKey: %v", err)
|
||||
}
|
||||
if !svc.privateKey.Equal(want) {
|
||||
t.Error("loaded privateKey does not match input")
|
||||
}
|
||||
// Public key must match the public part of the loaded private key.
|
||||
wantPub := want.Public().(ed25519.PublicKey)
|
||||
if !svc.publicKey.Equal(wantPub) {
|
||||
t.Error("derived publicKey does not match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewJWTServiceWithKey_BadPEMReturnsWrappedError(t *testing.T) {
|
||||
_, err := NewJWTServiceWithKey("garbage")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for bad PEM, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "failed to parse Ed25519 private key") {
|
||||
t.Errorf("expected wrapping error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func newTestUserInfo() *UserInfo {
|
||||
return &UserInfo{
|
||||
Username: "alice",
|
||||
Email: "alice@example.com",
|
||||
Name: "Alice Example",
|
||||
Roles: []string{"admin", "viewer"},
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateAndValidateToken_RoundTrip(t *testing.T) {
|
||||
svc, err := NewJWTService()
|
||||
if err != nil {
|
||||
t.Fatalf("NewJWTService: %v", err)
|
||||
}
|
||||
|
||||
user := newTestUserInfo()
|
||||
tok, err := svc.GenerateToken(user, 60)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateToken: %v", err)
|
||||
}
|
||||
if tok == "" {
|
||||
t.Fatal("GenerateToken returned empty string")
|
||||
}
|
||||
|
||||
claims, err := svc.ValidateToken(tok)
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateToken: %v", err)
|
||||
}
|
||||
if claims.Username != user.Username {
|
||||
t.Errorf("Username = %q, want %q", claims.Username, user.Username)
|
||||
}
|
||||
if claims.Email != user.Email {
|
||||
t.Errorf("Email = %q, want %q", claims.Email, user.Email)
|
||||
}
|
||||
if claims.Name != user.Name {
|
||||
t.Errorf("Name = %q, want %q", claims.Name, user.Name)
|
||||
}
|
||||
if len(claims.Roles) != 2 || claims.Roles[0] != "admin" || claims.Roles[1] != "viewer" {
|
||||
t.Errorf("Roles = %v, want [admin viewer]", claims.Roles)
|
||||
}
|
||||
// ExpiresAt should be ~60s in the future.
|
||||
if claims.ExpiresAt == nil {
|
||||
t.Fatal("ExpiresAt nil")
|
||||
}
|
||||
if d := time.Until(claims.ExpiresAt.Time); d <= 0 || d > 61*time.Second {
|
||||
t.Errorf("ExpiresAt delta = %v, want (0,61s]", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateToken_Expired(t *testing.T) {
|
||||
svc, err := NewJWTService()
|
||||
if err != nil {
|
||||
t.Fatalf("NewJWTService: %v", err)
|
||||
}
|
||||
// sessionMaxAge = -1s → token is born expired.
|
||||
tok, err := svc.GenerateToken(newTestUserInfo(), -1)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateToken: %v", err)
|
||||
}
|
||||
_, err = svc.ValidateToken(tok)
|
||||
if err == nil {
|
||||
t.Fatal("expected expired-token error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "failed to parse token") {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
// jwt/v5 surfaces ErrTokenExpired wrapped in the parse error.
|
||||
if !errors.Is(err, jwt.ErrTokenExpired) {
|
||||
t.Errorf("expected wrapped jwt.ErrTokenExpired, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateToken_SignedByDifferentKey(t *testing.T) {
|
||||
signer, err := NewJWTService()
|
||||
if err != nil {
|
||||
t.Fatalf("signer: %v", err)
|
||||
}
|
||||
verifier, err := NewJWTService()
|
||||
if err != nil {
|
||||
t.Fatalf("verifier: %v", err)
|
||||
}
|
||||
tok, err := signer.GenerateToken(newTestUserInfo(), 60)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateToken: %v", err)
|
||||
}
|
||||
if _, err := verifier.ValidateToken(tok); err == nil {
|
||||
t.Fatal("expected signature-mismatch error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateToken_Malformed(t *testing.T) {
|
||||
svc, err := NewJWTService()
|
||||
if err != nil {
|
||||
t.Fatalf("NewJWTService: %v", err)
|
||||
}
|
||||
cases := []string{
|
||||
"",
|
||||
"not.a.jwt",
|
||||
"only-one-segment",
|
||||
"two.segments",
|
||||
"aaaa.bbbb.cccc", // valid shape, invalid base64/JSON
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c, func(t *testing.T) {
|
||||
if _, err := svc.ValidateToken(c); err == nil {
|
||||
t.Errorf("expected error for %q, got nil", c)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateToken_WrongSigningMethod(t *testing.T) {
|
||||
svc, err := NewJWTService()
|
||||
if err != nil {
|
||||
t.Fatalf("NewJWTService: %v", err)
|
||||
}
|
||||
// Forge an HS256 token with the same claim shape; ValidateToken's
|
||||
// keyfunc must reject the alg before signature verification.
|
||||
claims := SessionClaims{
|
||||
Username: "mallory",
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
|
||||
},
|
||||
}
|
||||
tok := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
signed, err := tok.SignedString([]byte("a-shared-secret"))
|
||||
if err != nil {
|
||||
t.Fatalf("sign HS256: %v", err)
|
||||
}
|
||||
_, err = svc.ValidateToken(signed)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-EdDSA token, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "unexpected signing method") {
|
||||
t.Errorf("expected signing-method error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateToken_NilPrivateKeyReturnsError(t *testing.T) {
|
||||
// Construct a service with a nil key directly. This guards the explicit
|
||||
// nil-check at the top of GenerateToken.
|
||||
svc := &JWTService{}
|
||||
_, err := svc.GenerateToken(newTestUserInfo(), 60)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for nil private key, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "private key not initialized") {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateToken_NilPublicKeyReturnsError(t *testing.T) {
|
||||
svc := &JWTService{}
|
||||
_, err := svc.ValidateToken("anything")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for nil public key, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "public key not initialized") {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateStateToken_ProducesUniqueValues(t *testing.T) {
|
||||
svc, err := NewJWTService()
|
||||
if err != nil {
|
||||
t.Fatalf("NewJWTService: %v", err)
|
||||
}
|
||||
a, err := svc.GenerateStateToken()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateStateToken: %v", err)
|
||||
}
|
||||
b, err := svc.GenerateStateToken()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateStateToken: %v", err)
|
||||
}
|
||||
if a == "" || b == "" {
|
||||
t.Fatal("state token is empty")
|
||||
}
|
||||
if a == b {
|
||||
t.Errorf("state tokens collided: %q", a)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAndConsumeState_HappyPath(t *testing.T) {
|
||||
svc, err := NewJWTService()
|
||||
if err != nil {
|
||||
t.Fatalf("NewJWTService: %v", err)
|
||||
}
|
||||
tok, err := svc.GenerateStateToken()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateStateToken: %v", err)
|
||||
}
|
||||
if !svc.ValidateAndConsumeState(tok) {
|
||||
t.Error("first consume should succeed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAndConsumeState_IsSingleUse(t *testing.T) {
|
||||
svc, err := NewJWTService()
|
||||
if err != nil {
|
||||
t.Fatalf("NewJWTService: %v", err)
|
||||
}
|
||||
tok, err := svc.GenerateStateToken()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateStateToken: %v", err)
|
||||
}
|
||||
_ = svc.ValidateAndConsumeState(tok)
|
||||
if svc.ValidateAndConsumeState(tok) {
|
||||
t.Error("second consume should fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAndConsumeState_UnknownTokenRejected(t *testing.T) {
|
||||
svc, err := NewJWTService()
|
||||
if err != nil {
|
||||
t.Fatalf("NewJWTService: %v", err)
|
||||
}
|
||||
if svc.ValidateAndConsumeState("never-issued") {
|
||||
t.Error("unknown token must not validate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAndConsumeState_ExpiredTokenRejected(t *testing.T) {
|
||||
svc, err := NewJWTService()
|
||||
if err != nil {
|
||||
t.Fatalf("NewJWTService: %v", err)
|
||||
}
|
||||
// Inject an expired entry directly to avoid a real 10-minute wait.
|
||||
svc.stateStore.states["expired"] = StateData{
|
||||
Created: time.Now().Add(-20 * time.Minute),
|
||||
ExpiresAt: time.Now().Add(-10 * time.Minute),
|
||||
}
|
||||
if svc.ValidateAndConsumeState("expired") {
|
||||
t.Error("expired token must not validate")
|
||||
}
|
||||
// And it should be deleted as a side effect of the rejection.
|
||||
if _, exists := svc.stateStore.states["expired"]; exists {
|
||||
t.Error("expired token should be removed from the store")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPublicKeyPEM_ParsesBackToOriginalKey(t *testing.T) {
|
||||
svc, err := NewJWTService()
|
||||
if err != nil {
|
||||
t.Fatalf("NewJWTService: %v", err)
|
||||
}
|
||||
pemStr, err := svc.GetPublicKeyPEM()
|
||||
if err != nil {
|
||||
t.Fatalf("GetPublicKeyPEM: %v", err)
|
||||
}
|
||||
block, _ := pem.Decode([]byte(pemStr))
|
||||
if block == nil {
|
||||
t.Fatalf("returned PEM did not decode: %q", pemStr)
|
||||
}
|
||||
if block.Type != "PUBLIC KEY" {
|
||||
t.Errorf("PEM type = %q, want PUBLIC KEY", block.Type)
|
||||
}
|
||||
// The implementation writes the raw 32-byte public key as the block body.
|
||||
if len(block.Bytes) != ed25519.PublicKeySize {
|
||||
t.Errorf("body length = %d, want %d", len(block.Bytes), ed25519.PublicKeySize)
|
||||
}
|
||||
if !ed25519.PublicKey(block.Bytes).Equal(svc.publicKey) {
|
||||
t.Error("decoded public key does not match service key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPublicKeyBase64_RoundTripsToOriginalKey(t *testing.T) {
|
||||
svc, err := NewJWTService()
|
||||
if err != nil {
|
||||
t.Fatalf("NewJWTService: %v", err)
|
||||
}
|
||||
b64, err := svc.GetPublicKeyBase64()
|
||||
if err != nil {
|
||||
t.Fatalf("GetPublicKeyBase64: %v", err)
|
||||
}
|
||||
if b64 == "" {
|
||||
t.Fatal("empty base64 output")
|
||||
}
|
||||
// base64.RawURLEncoding (no padding) is what the production code uses.
|
||||
// Decode and compare.
|
||||
// Use the std encoding through helper to keep the import list small.
|
||||
got, err := decodeRawURL(b64)
|
||||
if err != nil {
|
||||
t.Fatalf("base64 decode: %v", err)
|
||||
}
|
||||
if !ed25519.PublicKey(got).Equal(svc.publicKey) {
|
||||
t.Error("base64-decoded key does not match service key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPublicKeyPEM_NilKeyReturnsError(t *testing.T) {
|
||||
svc := &JWTService{}
|
||||
if _, err := svc.GetPublicKeyPEM(); err == nil {
|
||||
t.Error("expected error for nil public key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPublicKeyBase64_NilKeyReturnsError(t *testing.T) {
|
||||
svc := &JWTService{}
|
||||
if _, err := svc.GetPublicKeyBase64(); err == nil {
|
||||
t.Error("expected error for nil public key")
|
||||
}
|
||||
}
|
||||
|
||||
// decodeRawURL is a tiny shim around encoding/base64's RawURLEncoding decoder
|
||||
// so the test body stays focused on assertions, not encoding plumbing.
|
||||
func decodeRawURL(s string) ([]byte, error) {
|
||||
return base64RawURLDecode(s)
|
||||
}
|
||||
|
||||
func base64RawURLDecode(s string) ([]byte, error) {
|
||||
return base64.RawURLEncoding.DecodeString(s)
|
||||
}
|
||||
@@ -64,9 +64,6 @@ type OIDCConfig struct {
|
||||
ClientSecret string `mapstructure:"client_secret"`
|
||||
Scopes []string `mapstructure:"scopes"`
|
||||
IssuerURL string `mapstructure:"issuer_url"`
|
||||
AuthURL string `mapstructure:"auth_url"`
|
||||
TokenURL string `mapstructure:"token_url"`
|
||||
UserinfoURL string `mapstructure:"userinfo_url"`
|
||||
SkipIssuerCheck bool `mapstructure:"skip_issuer_check"`
|
||||
SkipExpiryCheck bool `mapstructure:"skip_expiry_check"`
|
||||
EmailAttribute string `mapstructure:"email_attribute"`
|
||||
@@ -175,9 +172,6 @@ func bindEnvVars() {
|
||||
viper.BindEnv("auth.oidc.client_secret", "GARAGE_UI_AUTH_OIDC_CLIENT_SECRET")
|
||||
viper.BindEnv("auth.oidc.scopes", "GARAGE_UI_AUTH_OIDC_SCOPES")
|
||||
viper.BindEnv("auth.oidc.issuer_url", "GARAGE_UI_AUTH_OIDC_ISSUER_URL")
|
||||
viper.BindEnv("auth.oidc.auth_url", "GARAGE_UI_AUTH_OIDC_AUTH_URL")
|
||||
viper.BindEnv("auth.oidc.token_url", "GARAGE_UI_AUTH_OIDC_TOKEN_URL")
|
||||
viper.BindEnv("auth.oidc.userinfo_url", "GARAGE_UI_AUTH_OIDC_USERINFO_URL")
|
||||
viper.BindEnv("auth.oidc.skip_issuer_check", "GARAGE_UI_AUTH_OIDC_SKIP_ISSUER_CHECK")
|
||||
viper.BindEnv("auth.oidc.skip_expiry_check", "GARAGE_UI_AUTH_OIDC_SKIP_EXPIRY_CHECK")
|
||||
viper.BindEnv("auth.oidc.email_attribute", "GARAGE_UI_AUTH_OIDC_EMAIL_ATTRIBUTE")
|
||||
@@ -244,6 +238,13 @@ func (c *Config) Validate() error {
|
||||
if len(c.Auth.OIDC.Scopes) == 0 {
|
||||
return fmt.Errorf("oidc scopes are required when oidc is enabled")
|
||||
}
|
||||
// Every authenticated route on this service grants full admin
|
||||
// access — there is no separate authorization layer. An empty
|
||||
// admin_role would therefore promote every user in the IdP realm
|
||||
// to cluster admin. Require operators to opt in explicitly.
|
||||
if c.Auth.OIDC.AdminRole == "" {
|
||||
return fmt.Errorf("oidc admin_role is required when oidc is enabled: leaving it empty would grant cluster-admin access to any authenticated IdP user")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -0,0 +1,396 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
// writeConfigFile writes yaml content to a temp path and returns it.
|
||||
func writeConfigFile(t *testing.T, yaml string) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.yaml")
|
||||
if err := os.WriteFile(path, []byte(yaml), 0600); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// resetViper clears all global viper state between tests.
|
||||
func resetViper(t *testing.T) {
|
||||
t.Helper()
|
||||
viper.Reset()
|
||||
}
|
||||
|
||||
// minimalValidYAML is the smallest configuration that passes Validate.
|
||||
const minimalValidYAML = `
|
||||
server:
|
||||
host: "0.0.0.0"
|
||||
port: 8080
|
||||
environment: development
|
||||
garage:
|
||||
endpoint: http://garage:3900
|
||||
admin_endpoint: http://garage:3903
|
||||
admin_token: supersecret
|
||||
`
|
||||
|
||||
func TestLoad_YAMLOnly(t *testing.T) {
|
||||
resetViper(t)
|
||||
path := writeConfigFile(t, minimalValidYAML)
|
||||
|
||||
cfg, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.Server.Host != "0.0.0.0" {
|
||||
t.Errorf("Server.Host = %q, want 0.0.0.0", cfg.Server.Host)
|
||||
}
|
||||
if cfg.Server.Port != 8080 {
|
||||
t.Errorf("Server.Port = %d, want 8080", cfg.Server.Port)
|
||||
}
|
||||
if cfg.Server.Environment != "development" {
|
||||
t.Errorf("Server.Environment = %q, want development", cfg.Server.Environment)
|
||||
}
|
||||
if cfg.Garage.Endpoint != "http://garage:3900" {
|
||||
t.Errorf("Garage.Endpoint = %q", cfg.Garage.Endpoint)
|
||||
}
|
||||
if cfg.Garage.AdminToken != "supersecret" {
|
||||
t.Errorf("Garage.AdminToken = %q", cfg.Garage.AdminToken)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_EnvOnly_MissingFile(t *testing.T) {
|
||||
resetViper(t)
|
||||
// Point at a path that definitely does not exist. Load tolerates missing
|
||||
// files and falls back to env + viper defaults.
|
||||
missing := filepath.Join(t.TempDir(), "does-not-exist.yaml")
|
||||
|
||||
// Every required field provided via env.
|
||||
t.Setenv("GARAGE_UI_SERVER_PORT", "9090")
|
||||
t.Setenv("GARAGE_UI_GARAGE_ENDPOINT", "http://g:3900")
|
||||
t.Setenv("GARAGE_UI_GARAGE_ADMIN_ENDPOINT", "http://g:3903")
|
||||
t.Setenv("GARAGE_UI_GARAGE_ADMIN_TOKEN", "env-token")
|
||||
|
||||
cfg, err := Load(missing)
|
||||
if err != nil {
|
||||
t.Fatalf("Load with env-only: %v", err)
|
||||
}
|
||||
if cfg.Server.Port != 9090 {
|
||||
t.Errorf("Server.Port = %d, want 9090 (from env)", cfg.Server.Port)
|
||||
}
|
||||
if cfg.Garage.AdminToken != "env-token" {
|
||||
t.Errorf("Garage.AdminToken = %q, want env-token", cfg.Garage.AdminToken)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_EnvOverridesYAML(t *testing.T) {
|
||||
resetViper(t)
|
||||
path := writeConfigFile(t, minimalValidYAML)
|
||||
|
||||
// YAML has port=8080; env should win.
|
||||
t.Setenv("GARAGE_UI_SERVER_PORT", "9090")
|
||||
t.Setenv("GARAGE_UI_GARAGE_ADMIN_TOKEN", "env-wins")
|
||||
|
||||
cfg, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.Server.Port != 9090 {
|
||||
t.Errorf("Server.Port = %d, want 9090 (env override)", cfg.Server.Port)
|
||||
}
|
||||
if cfg.Garage.AdminToken != "env-wins" {
|
||||
t.Errorf("Garage.AdminToken = %q, want env-wins", cfg.Garage.AdminToken)
|
||||
}
|
||||
// Host was not overridden; YAML value should persist.
|
||||
if cfg.Server.Host != "0.0.0.0" {
|
||||
t.Errorf("Server.Host = %q, want 0.0.0.0 (from YAML)", cfg.Server.Host)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_MalformedYAMLReturnsError(t *testing.T) {
|
||||
resetViper(t)
|
||||
// Deliberately broken YAML: unindented key after a mapping start.
|
||||
path := writeConfigFile(t, "server:\n port: 8080\n:: not: valid ::\n")
|
||||
|
||||
_, err := Load(path)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for malformed YAML, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "error reading config file") {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_ValidationFailurePropagates(t *testing.T) {
|
||||
resetViper(t)
|
||||
// Valid YAML syntax but Garage.Endpoint is blank → Validate must fail.
|
||||
path := writeConfigFile(t, `
|
||||
server:
|
||||
port: 8080
|
||||
garage:
|
||||
endpoint: ""
|
||||
admin_endpoint: http://g:3903
|
||||
admin_token: t
|
||||
`)
|
||||
|
||||
_, err := Load(path)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid configuration") {
|
||||
t.Errorf("expected wrapped invalid-config error, got %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "garage endpoint is required") {
|
||||
t.Errorf("expected endpoint-required message, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// validBaseConfig returns a deep copy of a minimal Config that passes Validate.
|
||||
func validBaseConfig() Config {
|
||||
return Config{
|
||||
Server: ServerConfig{Port: 8080},
|
||||
Garage: GarageConfig{
|
||||
Endpoint: "http://g:3900",
|
||||
AdminEndpoint: "http://g:3903",
|
||||
AdminToken: "t",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// applyValidOIDC fills OIDC with all required fields.
|
||||
func applyValidOIDC(c *Config) {
|
||||
c.Auth.OIDC.Enabled = true
|
||||
c.Auth.OIDC.ClientID = "client-xyz"
|
||||
c.Auth.OIDC.IssuerURL = "https://idp.example/realms/test"
|
||||
c.Auth.OIDC.Scopes = []string{"openid"}
|
||||
c.Auth.OIDC.AdminRole = "admin"
|
||||
c.Server.RootURL = "https://garage-ui.example"
|
||||
}
|
||||
|
||||
// Note on spec coverage: spec/2026-04-17-backend-test-suite-design.md lists
|
||||
// "invalid log level/format" as a Validate case, but the current Validate does
|
||||
// not check Logging.Level or Logging.Format. That's a code-vs-spec gap to
|
||||
// resolve in a follow-up plan; Stage 2 tests the current behavior only.
|
||||
func TestValidate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(*Config)
|
||||
wantErrContains string // empty = expect no error
|
||||
}{
|
||||
{
|
||||
name: "valid minimal config",
|
||||
mutate: func(c *Config) {},
|
||||
},
|
||||
{
|
||||
name: "port zero is invalid",
|
||||
mutate: func(c *Config) { c.Server.Port = 0 },
|
||||
wantErrContains: "invalid server port",
|
||||
},
|
||||
{
|
||||
name: "port negative is invalid",
|
||||
mutate: func(c *Config) { c.Server.Port = -1 },
|
||||
wantErrContains: "invalid server port",
|
||||
},
|
||||
{
|
||||
name: "port above 65535 is invalid",
|
||||
mutate: func(c *Config) { c.Server.Port = 70000 },
|
||||
wantErrContains: "invalid server port",
|
||||
},
|
||||
{
|
||||
name: "port at 65535 is valid",
|
||||
mutate: func(c *Config) { c.Server.Port = 65535 },
|
||||
wantErrContains: "",
|
||||
},
|
||||
{
|
||||
name: "missing garage endpoint",
|
||||
mutate: func(c *Config) { c.Garage.Endpoint = "" },
|
||||
wantErrContains: "garage endpoint is required",
|
||||
},
|
||||
{
|
||||
name: "missing garage admin_endpoint",
|
||||
mutate: func(c *Config) { c.Garage.AdminEndpoint = "" },
|
||||
wantErrContains: "admin_endpoint is required",
|
||||
},
|
||||
{
|
||||
name: "missing garage admin_token",
|
||||
mutate: func(c *Config) { c.Garage.AdminToken = "" },
|
||||
wantErrContains: "admin_token is required",
|
||||
},
|
||||
{
|
||||
name: "admin auth enabled without username",
|
||||
mutate: func(c *Config) {
|
||||
c.Auth.Admin.Enabled = true
|
||||
c.Auth.Admin.Password = "p"
|
||||
},
|
||||
wantErrContains: "admin auth username and password are required",
|
||||
},
|
||||
{
|
||||
name: "admin auth enabled without password",
|
||||
mutate: func(c *Config) {
|
||||
c.Auth.Admin.Enabled = true
|
||||
c.Auth.Admin.Username = "u"
|
||||
},
|
||||
wantErrContains: "admin auth username and password are required",
|
||||
},
|
||||
{
|
||||
name: "admin auth enabled with both set is valid",
|
||||
mutate: func(c *Config) {
|
||||
c.Auth.Admin.Enabled = true
|
||||
c.Auth.Admin.Username = "u"
|
||||
c.Auth.Admin.Password = "p"
|
||||
},
|
||||
wantErrContains: "",
|
||||
},
|
||||
{
|
||||
name: "admin auth disabled ignores missing credentials",
|
||||
mutate: func(c *Config) {
|
||||
c.Auth.Admin.Enabled = false
|
||||
c.Auth.Admin.Username = ""
|
||||
c.Auth.Admin.Password = ""
|
||||
},
|
||||
wantErrContains: "",
|
||||
},
|
||||
{
|
||||
name: "oidc enabled without client_id",
|
||||
mutate: func(c *Config) {
|
||||
applyValidOIDC(c)
|
||||
c.Auth.OIDC.ClientID = ""
|
||||
},
|
||||
wantErrContains: "oidc client_id is required",
|
||||
},
|
||||
{
|
||||
name: "oidc enabled without issuer_url",
|
||||
mutate: func(c *Config) {
|
||||
applyValidOIDC(c)
|
||||
c.Auth.OIDC.IssuerURL = ""
|
||||
},
|
||||
wantErrContains: "oidc issuer_url is required",
|
||||
},
|
||||
{
|
||||
name: "oidc enabled without server.root_url",
|
||||
mutate: func(c *Config) {
|
||||
applyValidOIDC(c)
|
||||
c.Server.RootURL = ""
|
||||
},
|
||||
wantErrContains: "server.root_url is required",
|
||||
},
|
||||
{
|
||||
name: "oidc enabled without scopes",
|
||||
mutate: func(c *Config) {
|
||||
applyValidOIDC(c)
|
||||
c.Auth.OIDC.Scopes = nil
|
||||
},
|
||||
wantErrContains: "oidc scopes are required",
|
||||
},
|
||||
{
|
||||
name: "oidc enabled without admin_role rejected for safety",
|
||||
mutate: func(c *Config) {
|
||||
applyValidOIDC(c)
|
||||
c.Auth.OIDC.AdminRole = ""
|
||||
},
|
||||
wantErrContains: "oidc admin_role is required",
|
||||
},
|
||||
{
|
||||
name: "oidc fully configured is valid",
|
||||
mutate: applyValidOIDC,
|
||||
wantErrContains: "",
|
||||
},
|
||||
{
|
||||
name: "oidc disabled ignores missing client_id",
|
||||
mutate: func(c *Config) {
|
||||
c.Auth.OIDC.Enabled = false
|
||||
c.Auth.OIDC.ClientID = ""
|
||||
},
|
||||
wantErrContains: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cfg := validBaseConfig()
|
||||
tc.mutate(&cfg)
|
||||
err := cfg.Validate()
|
||||
|
||||
if tc.wantErrContains == "" {
|
||||
if err != nil {
|
||||
t.Errorf("expected no error, got %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
t.Fatalf("expected error containing %q, got nil", tc.wantErrContains)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tc.wantErrContains) {
|
||||
t.Errorf("error %q does not contain %q", err.Error(), tc.wantErrContains)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAddress(t *testing.T) {
|
||||
tests := []struct {
|
||||
host string
|
||||
port int
|
||||
want string
|
||||
}{
|
||||
{"localhost", 8080, "localhost:8080"},
|
||||
{"0.0.0.0", 80, "0.0.0.0:80"},
|
||||
{"", 443, ":443"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.want, func(t *testing.T) {
|
||||
cfg := &Config{Server: ServerConfig{Host: tc.host, Port: tc.port}}
|
||||
if got := cfg.GetAddress(); got != tc.want {
|
||||
t.Errorf("GetAddress() = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsDevelopment(t *testing.T) {
|
||||
tests := []struct {
|
||||
env string
|
||||
want bool
|
||||
}{
|
||||
{"development", true},
|
||||
{"production", false},
|
||||
{"", false},
|
||||
// Case-sensitive per current impl; lock in that behavior.
|
||||
{"Development", false},
|
||||
{"DEV", false},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.env, func(t *testing.T) {
|
||||
cfg := &Config{Server: ServerConfig{Environment: tc.env}}
|
||||
if got := cfg.IsDevelopment(); got != tc.want {
|
||||
t.Errorf("IsDevelopment(%q) = %v, want %v", tc.env, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsProduction(t *testing.T) {
|
||||
tests := []struct {
|
||||
env string
|
||||
want bool
|
||||
}{
|
||||
{"production", true},
|
||||
{"development", false},
|
||||
{"", false},
|
||||
{"Production", false},
|
||||
{"PROD", false},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.env, func(t *testing.T) {
|
||||
cfg := &Config{Server: ServerConfig{Environment: tc.env}}
|
||||
if got := cfg.IsProduction(); got != tc.want {
|
||||
t.Errorf("IsProduction(%q) = %v, want %v", tc.env, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -11,11 +11,11 @@ import (
|
||||
// AuthHandler handles authentication-related requests
|
||||
type AuthHandler struct {
|
||||
cfg *config.Config
|
||||
authService *auth.AuthService
|
||||
authService *auth.Service
|
||||
}
|
||||
|
||||
// NewAuthHandler creates a new auth handler
|
||||
func NewAuthHandler(cfg *config.Config, authService *auth.AuthService) *AuthHandler {
|
||||
func NewAuthHandler(cfg *config.Config, authService *auth.Service) *AuthHandler {
|
||||
return &AuthHandler{
|
||||
cfg: cfg,
|
||||
authService: authService,
|
||||
@@ -23,12 +23,13 @@ func NewAuthHandler(cfg *config.Config, authService *auth.AuthService) *AuthHand
|
||||
}
|
||||
|
||||
// GetAuthConfig returns the current authentication configuration
|
||||
// @Summary Get authentication configuration
|
||||
// @Description Returns the current auth configuration (admin and/or OIDC)
|
||||
// @Tags auth
|
||||
// @Produce json
|
||||
// @Success 200 {object} object{admin=object,oidc=object} "Auth config"
|
||||
// @Router /auth/config [get]
|
||||
//
|
||||
// @Summary Get authentication configuration
|
||||
// @Description Returns the current auth configuration (admin and/or OIDC)
|
||||
// @Tags auth
|
||||
// @Produce json
|
||||
// @Success 200 {object} object{admin=object,oidc=object} "Auth config"
|
||||
// @Router /auth/config [get]
|
||||
func (h *AuthHandler) GetAuthConfig(c fiber.Ctx) error {
|
||||
response := fiber.Map{
|
||||
"admin": fiber.Map{
|
||||
@@ -58,16 +59,17 @@ type LoginBasicRequest struct {
|
||||
}
|
||||
|
||||
// LoginAdmin handles admin authentication login
|
||||
// @Summary Admin auth login
|
||||
// @Description Authenticate with admin username and password, returns JWT token
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param credentials body LoginBasicRequest true "Login credentials"
|
||||
// @Success 200 {object} object{success=bool,token=string,user=object} "Login successful"
|
||||
// @Failure 400 {object} models.APIResponse "Invalid request"
|
||||
// @Failure 401 {object} models.APIResponse "Invalid credentials"
|
||||
// @Router /auth/login [post]
|
||||
//
|
||||
// @Summary Admin auth login
|
||||
// @Description Authenticate with admin username and password, returns JWT token
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param credentials body LoginBasicRequest true "Login credentials"
|
||||
// @Success 200 {object} object{success=bool,token=string,user=object} "Login successful"
|
||||
// @Failure 400 {object} models.APIResponse "Invalid request"
|
||||
// @Failure 401 {object} models.APIResponse "Invalid credentials"
|
||||
// @Router /auth/login [post]
|
||||
func (h *AuthHandler) LoginAdmin(c fiber.Ctx) error {
|
||||
// Parse request body
|
||||
var req LoginBasicRequest
|
||||
@@ -107,14 +109,15 @@ func (h *AuthHandler) LoginAdmin(c fiber.Ctx) error {
|
||||
}
|
||||
|
||||
// GetMe returns the current authenticated user's information
|
||||
// @Summary Get current user
|
||||
// @Description Returns information about the currently authenticated user
|
||||
// @Tags auth
|
||||
// @Produce json
|
||||
// @Security ApiKeyAuth
|
||||
// @Success 200 {object} object{success=bool,user=object} "User information"
|
||||
// @Failure 401 {object} models.APIResponse "Not authenticated"
|
||||
// @Router /auth/me [get]
|
||||
//
|
||||
// @Summary Get current user
|
||||
// @Description Returns information about the currently authenticated user
|
||||
// @Tags auth
|
||||
// @Produce json
|
||||
// @Security ApiKeyAuth
|
||||
// @Success 200 {object} object{success=bool,user=object} "User information"
|
||||
// @Failure 401 {object} models.APIResponse "Not authenticated"
|
||||
// @Router /auth/me [get]
|
||||
func (h *AuthHandler) GetMe(c fiber.Ctx) error {
|
||||
// Try to get user info from OIDC context
|
||||
userInfoInterface := c.Locals("userInfo")
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"Noooste/garage-ui/internal/auth"
|
||||
"Noooste/garage-ui/internal/config"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
// newAuthTestService builds a real auth.Service with OIDC disabled. The JWT
|
||||
// key is auto-generated, matching the production default.
|
||||
func newAuthTestService(t *testing.T, admin config.AdminAuthConfig) *auth.Service {
|
||||
t.Helper()
|
||||
svc, err := auth.NewAuthService(
|
||||
&config.AuthConfig{
|
||||
Admin: admin,
|
||||
OIDC: config.OIDCConfig{Enabled: false},
|
||||
},
|
||||
&config.ServerConfig{},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("NewAuthService: %v", err)
|
||||
}
|
||||
return svc
|
||||
}
|
||||
|
||||
// newAuthTestApp builds a bare Fiber app with the auth handler mounted.
|
||||
// The admin and OIDC config are reflected both in cfg (for the handler) and
|
||||
// in the auth service.
|
||||
func newAuthTestApp(t *testing.T, cfg *config.Config) (*fiber.App, *AuthHandler) {
|
||||
t.Helper()
|
||||
svc := newAuthTestService(t, cfg.Auth.Admin)
|
||||
h := NewAuthHandler(cfg, svc)
|
||||
app := fiber.New()
|
||||
app.Get("/auth/config", h.GetAuthConfig)
|
||||
app.Post("/auth/login", h.LoginAdmin)
|
||||
app.Get("/auth/me", h.GetMe)
|
||||
return app, h
|
||||
}
|
||||
|
||||
func TestGetAuthConfig_AdminOnly(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Auth: config.AuthConfig{
|
||||
Admin: config.AdminAuthConfig{Enabled: true, Username: "admin", Password: "p"},
|
||||
OIDC: config.OIDCConfig{Enabled: false},
|
||||
},
|
||||
}
|
||||
app, _ := newAuthTestApp(t, cfg)
|
||||
req := httptest.NewRequest(http.MethodGet, "/auth/config", nil)
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
var body struct {
|
||||
Admin struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
} `json:"admin"`
|
||||
OIDC struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Provider string `json:"provider"`
|
||||
} `json:"oidc"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if !body.Admin.Enabled {
|
||||
t.Error("admin.enabled = false, want true")
|
||||
}
|
||||
if body.OIDC.Enabled {
|
||||
t.Error("oidc.enabled = true, want false")
|
||||
}
|
||||
if body.OIDC.Provider != "" {
|
||||
t.Errorf("oidc.provider = %q, want empty", body.OIDC.Provider)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAuthConfig_OIDCOnly_WithExplicitProvider(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Auth: config.AuthConfig{
|
||||
Admin: config.AdminAuthConfig{Enabled: false},
|
||||
OIDC: config.OIDCConfig{
|
||||
Enabled: false, // service init skipped (newAuthTestService disables OIDC); handler only reads flags
|
||||
ProviderName: "Keycloak",
|
||||
},
|
||||
},
|
||||
}
|
||||
// Re-enable OIDC only on the cfg the handler sees — the service is still
|
||||
// constructed with OIDC disabled above, which is fine because
|
||||
// GetAuthConfig does not touch the service at all.
|
||||
cfg.Auth.OIDC.Enabled = true
|
||||
app, _ := newAuthTestApp(t, cfg)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/auth/config", nil)
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
var body struct {
|
||||
OIDC struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Provider string `json:"provider"`
|
||||
} `json:"oidc"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if !body.OIDC.Enabled {
|
||||
t.Error("oidc.enabled = false, want true")
|
||||
}
|
||||
if body.OIDC.Provider != "Keycloak" {
|
||||
t.Errorf("oidc.provider = %q, want Keycloak", body.OIDC.Provider)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAuthConfig_OIDCEnabled_DefaultProviderName(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Auth: config.AuthConfig{
|
||||
Admin: config.AdminAuthConfig{Enabled: false},
|
||||
OIDC: config.OIDCConfig{Enabled: true, ProviderName: ""},
|
||||
},
|
||||
}
|
||||
app, _ := newAuthTestApp(t, cfg)
|
||||
req := httptest.NewRequest(http.MethodGet, "/auth/config", nil)
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var body struct {
|
||||
OIDC struct {
|
||||
Provider string `json:"provider"`
|
||||
} `json:"oidc"`
|
||||
}
|
||||
_ = json.NewDecoder(resp.Body).Decode(&body)
|
||||
if body.OIDC.Provider != "OIDC Provider" {
|
||||
t.Errorf("provider = %q, want default 'OIDC Provider'", body.OIDC.Provider)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginAdmin_HappyPath(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Auth: config.AuthConfig{
|
||||
Admin: config.AdminAuthConfig{Enabled: true, Username: "admin", Password: "s3cret"},
|
||||
},
|
||||
}
|
||||
app, _ := newAuthTestApp(t, cfg)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{"username": "admin", "password": "s3cret"})
|
||||
req := httptest.NewRequest(http.MethodPost, "/auth/login", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("status = %d, want 200\nbody: %s", resp.StatusCode, raw)
|
||||
}
|
||||
|
||||
var decoded struct {
|
||||
Success bool `json:"success"`
|
||||
Token string `json:"token"`
|
||||
User struct {
|
||||
Username string `json:"username"`
|
||||
} `json:"user"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&decoded); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if !decoded.Success {
|
||||
t.Error("success = false")
|
||||
}
|
||||
if decoded.Token == "" {
|
||||
t.Error("token empty")
|
||||
}
|
||||
if decoded.User.Username != "admin" {
|
||||
t.Errorf("username = %q, want admin", decoded.User.Username)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginAdmin_WrongPasswordReturns401(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Auth: config.AuthConfig{Admin: config.AdminAuthConfig{Enabled: true, Username: "admin", Password: "s3cret"}},
|
||||
}
|
||||
app, _ := newAuthTestApp(t, cfg)
|
||||
body, _ := json.Marshal(map[string]string{"username": "admin", "password": "WRONG"})
|
||||
req := httptest.NewRequest(http.MethodPost, "/auth/login", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want 401", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginAdmin_WrongUsernameReturns401(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Auth: config.AuthConfig{Admin: config.AdminAuthConfig{Enabled: true, Username: "admin", Password: "s3cret"}},
|
||||
}
|
||||
app, _ := newAuthTestApp(t, cfg)
|
||||
body, _ := json.Marshal(map[string]string{"username": "root", "password": "s3cret"})
|
||||
req := httptest.NewRequest(http.MethodPost, "/auth/login", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want 401", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginAdmin_MalformedJSONReturns400(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Auth: config.AuthConfig{Admin: config.AdminAuthConfig{Enabled: true, Username: "admin", Password: "p"}},
|
||||
}
|
||||
app, _ := newAuthTestApp(t, cfg)
|
||||
req := httptest.NewRequest(http.MethodPost, "/auth/login", strings.NewReader("{not-json"))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetMe_OIDCUserInfoLocal(t *testing.T) {
|
||||
cfg := &config.Config{Auth: config.AuthConfig{}}
|
||||
app, h := newAuthTestApp(t, cfg)
|
||||
// Re-register /auth/me with a pre-handler that seeds c.Locals("userInfo").
|
||||
// The default registration in newAuthTestApp lacks Locals; we mount a
|
||||
// second path that does.
|
||||
app.Get("/me-oidc", func(c fiber.Ctx) error {
|
||||
c.Locals("userInfo", &auth.UserInfo{
|
||||
Username: "alice",
|
||||
Email: "alice@example.com",
|
||||
Name: "Alice Example",
|
||||
})
|
||||
return h.GetMe(c)
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/me-oidc", nil)
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
var decoded struct {
|
||||
Success bool `json:"success"`
|
||||
User struct {
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
} `json:"user"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&decoded); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if decoded.User.Username != "alice" || decoded.User.Email != "alice@example.com" || decoded.User.Name != "Alice Example" {
|
||||
t.Errorf("got %+v", decoded.User)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetMe_BasicAuthUsernameLocal(t *testing.T) {
|
||||
cfg := &config.Config{Auth: config.AuthConfig{}}
|
||||
app, h := newAuthTestApp(t, cfg)
|
||||
app.Get("/me-basic", func(c fiber.Ctx) error {
|
||||
c.Locals("username", "admin")
|
||||
return h.GetMe(c)
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodGet, "/me-basic", nil)
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
var decoded struct {
|
||||
User struct {
|
||||
Username string `json:"username"`
|
||||
} `json:"user"`
|
||||
}
|
||||
_ = json.NewDecoder(resp.Body).Decode(&decoded)
|
||||
if decoded.User.Username != "admin" {
|
||||
t.Errorf("username = %q, want admin", decoded.User.Username)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetMe_NoLocalsReturns401(t *testing.T) {
|
||||
cfg := &config.Config{Auth: config.AuthConfig{}}
|
||||
app, _ := newAuthTestApp(t, cfg)
|
||||
req := httptest.NewRequest(http.MethodGet, "/auth/me", nil)
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want 401", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
@@ -7,14 +7,14 @@ import (
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
// BucketHandler handles bucket-related operations
|
||||
// BucketHandler handles bucket-related HTTP requests.
|
||||
type BucketHandler struct {
|
||||
adminService *services.GarageAdminService
|
||||
s3Service *services.S3Service
|
||||
adminService services.AdminService
|
||||
s3Service services.S3Storage
|
||||
}
|
||||
|
||||
// NewBucketHandler creates a new bucket handler
|
||||
func NewBucketHandler(adminService *services.GarageAdminService, s3Service *services.S3Service) *BucketHandler {
|
||||
// NewBucketHandler creates a new bucket handler.
|
||||
func NewBucketHandler(adminService services.AdminService, s3Service services.S3Storage) *BucketHandler {
|
||||
return &BucketHandler{
|
||||
adminService: adminService,
|
||||
s3Service: s3Service,
|
||||
@@ -67,11 +67,13 @@ func (h *BucketHandler) ListBuckets(c fiber.Ctx) error {
|
||||
}
|
||||
|
||||
bucketInfo := models.BucketInfo{
|
||||
Name: bucketName,
|
||||
CreationDate: adminBucket.Created,
|
||||
Region: "", // Garage doesn't have regions
|
||||
ObjectCount: &detailedInfo.Objects,
|
||||
Size: &detailedInfo.Bytes,
|
||||
Name: bucketName,
|
||||
CreationDate: adminBucket.Created,
|
||||
Region: "",
|
||||
ObjectCount: &detailedInfo.Objects,
|
||||
Size: &detailedInfo.Bytes,
|
||||
WebsiteAccess: detailedInfo.WebsiteAccess,
|
||||
WebsiteConfig: detailedInfo.WebsiteConfig,
|
||||
}
|
||||
|
||||
buckets = append(buckets, bucketInfo)
|
||||
@@ -306,3 +308,77 @@ func (h *BucketHandler) GrantBucketPermission(c fiber.Ctx) error {
|
||||
|
||||
return c.JSON(models.SuccessResponse(result))
|
||||
}
|
||||
|
||||
// UpdateBucketWebsite updates the website access configuration for a bucket
|
||||
//
|
||||
// @Summary Update bucket website configuration
|
||||
// @Description Enables or disables static website hosting for a bucket
|
||||
// @Tags Buckets
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param name path string true "Name of the bucket"
|
||||
// @Param request body models.UpdateBucketWebsiteRequest true "Website configuration"
|
||||
// @Success 200 {object} models.APIResponse{data=models.GarageBucketInfo} "Website configuration updated"
|
||||
// @Failure 400 {object} models.APIResponse{error=models.APIError} "Invalid request"
|
||||
// @Failure 404 {object} models.APIResponse{error=models.APIError} "Bucket not found"
|
||||
// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to update bucket"
|
||||
// @Router /api/v1/buckets/{name}/website [put]
|
||||
func (h *BucketHandler) UpdateBucketWebsite(c fiber.Ctx) error {
|
||||
ctx := c.Context()
|
||||
|
||||
bucketName := c.Params("name")
|
||||
if bucketName == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBadRequest, "Bucket name is required"),
|
||||
)
|
||||
}
|
||||
|
||||
var req models.UpdateBucketWebsiteRequest
|
||||
if err := c.Bind().JSON(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBadRequest, "Invalid request body: "+err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
if req.Enabled && req.IndexDocument == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBadRequest, "indexDocument is required when enabling website access"),
|
||||
)
|
||||
}
|
||||
|
||||
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()),
|
||||
)
|
||||
}
|
||||
|
||||
if bucketInfo == nil {
|
||||
return c.Status(fiber.StatusNotFound).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBucketNotFound, "Bucket does not exist"),
|
||||
)
|
||||
}
|
||||
|
||||
websiteAccess := &models.UpdateBucketWebsiteAccess{
|
||||
Enabled: req.Enabled,
|
||||
}
|
||||
if req.Enabled {
|
||||
websiteAccess.IndexDocument = &req.IndexDocument
|
||||
if req.ErrorDocument != "" {
|
||||
websiteAccess.ErrorDocument = &req.ErrorDocument
|
||||
}
|
||||
}
|
||||
|
||||
updateReq := models.UpdateBucketRequest{
|
||||
WebsiteAccess: websiteAccess,
|
||||
}
|
||||
|
||||
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 c.JSON(models.SuccessResponse(result))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,419 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"Noooste/garage-ui/internal/models"
|
||||
"Noooste/garage-ui/internal/services/mocks"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
func newBucketsTestApp(t *testing.T) (*fiber.App, *mocks.AdminMock) {
|
||||
t.Helper()
|
||||
admin := &mocks.AdminMock{}
|
||||
h := NewBucketHandler(admin, nil) // s3 unused in this handler
|
||||
app := fiber.New()
|
||||
app.Get("/buckets", h.ListBuckets)
|
||||
app.Post("/buckets", h.CreateBucket)
|
||||
app.Get("/buckets/:name", h.GetBucketInfo)
|
||||
app.Delete("/buckets/:name", h.DeleteBucket)
|
||||
app.Post("/buckets/:name/permissions", h.GrantBucketPermission)
|
||||
app.Put("/buckets/:name/website", h.UpdateBucketWebsite)
|
||||
return app, admin
|
||||
}
|
||||
|
||||
func decodeJSON(t *testing.T, r io.Reader, v any) {
|
||||
t.Helper()
|
||||
if err := json.NewDecoder(r).Decode(v); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- ListBuckets ---
|
||||
|
||||
func TestListBuckets_MapsAliasesAndStats(t *testing.T) {
|
||||
app, admin := newBucketsTestApp(t)
|
||||
admin.ListBucketsFn = func(_ context.Context) ([]models.ListBucketsResponseItem, error) {
|
||||
return []models.ListBucketsResponseItem{
|
||||
{ID: "id-1", Created: time.Unix(0, 0), GlobalAliases: []string{"alpha"}},
|
||||
{ID: "id-2", Created: time.Unix(0, 0), GlobalAliases: []string{}}, // skipped: no global alias
|
||||
{ID: "id-3", Created: time.Unix(0, 0), GlobalAliases: []string{"gamma"}},
|
||||
}, nil
|
||||
}
|
||||
admin.GetBucketInfoByAliasFn = func(_ context.Context, alias string) (*models.GarageBucketInfo, error) {
|
||||
switch alias {
|
||||
case "alpha":
|
||||
return &models.GarageBucketInfo{ID: "id-1", Objects: 10, Bytes: 100, WebsiteAccess: true}, nil
|
||||
case "gamma":
|
||||
return nil, errors.New("detail fetch failed") // degraded path
|
||||
}
|
||||
return nil, errors.New("unexpected alias: " + alias)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, "/buckets", nil)
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d", resp.StatusCode)
|
||||
}
|
||||
var body struct {
|
||||
Data models.BucketListResponse `json:"data"`
|
||||
}
|
||||
decodeJSON(t, resp.Body, &body)
|
||||
if body.Data.Count != 2 {
|
||||
t.Errorf("count = %d, want 2 (id-2 skipped)", body.Data.Count)
|
||||
}
|
||||
// alpha has stats; gamma degraded to no stats (ObjectCount/Size nil).
|
||||
var foundAlpha, foundGamma bool
|
||||
for _, b := range body.Data.Buckets {
|
||||
if b.Name == "alpha" {
|
||||
foundAlpha = true
|
||||
if b.ObjectCount == nil || *b.ObjectCount != 10 {
|
||||
t.Errorf("alpha.ObjectCount = %v, want 10", b.ObjectCount)
|
||||
}
|
||||
if !b.WebsiteAccess {
|
||||
t.Error("alpha.WebsiteAccess false")
|
||||
}
|
||||
}
|
||||
if b.Name == "gamma" {
|
||||
foundGamma = true
|
||||
if b.ObjectCount != nil {
|
||||
t.Errorf("gamma.ObjectCount = %v, want nil (degraded)", *b.ObjectCount)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !foundAlpha || !foundGamma {
|
||||
t.Errorf("missing buckets: alpha=%v gamma=%v", foundAlpha, foundGamma)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListBuckets_AdminErrorReturns500(t *testing.T) {
|
||||
app, admin := newBucketsTestApp(t)
|
||||
admin.ListBucketsFn = func(_ context.Context) ([]models.ListBucketsResponseItem, error) {
|
||||
return nil, errors.New("boom")
|
||||
}
|
||||
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets", nil))
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want 500", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// --- CreateBucket ---
|
||||
|
||||
func TestCreateBucket_Success201(t *testing.T) {
|
||||
app, admin := newBucketsTestApp(t)
|
||||
admin.CreateBucketFn = func(_ context.Context, r models.CreateBucketAdminRequest) (*models.GarageBucketInfo, error) {
|
||||
if r.GlobalAlias == nil || *r.GlobalAlias != "new-bucket" {
|
||||
t.Errorf("GlobalAlias = %v, want 'new-bucket'", r.GlobalAlias)
|
||||
}
|
||||
return &models.GarageBucketInfo{ID: "id-new"}, nil
|
||||
}
|
||||
body, _ := json.Marshal(map[string]string{"name": "new-bucket"})
|
||||
req := httptest.NewRequest(http.MethodPost, "/buckets", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("status = %d, want 201", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateBucket_MissingNameReturns400(t *testing.T) {
|
||||
app, _ := newBucketsTestApp(t)
|
||||
body, _ := json.Marshal(map[string]string{})
|
||||
req := httptest.NewRequest(http.MethodPost, "/buckets", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateBucket_MalformedJSONReturns400(t *testing.T) {
|
||||
app, _ := newBucketsTestApp(t)
|
||||
req := httptest.NewRequest(http.MethodPost, "/buckets", strings.NewReader("{not-json"))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateBucket_AdminErrorReturns500(t *testing.T) {
|
||||
app, admin := newBucketsTestApp(t)
|
||||
admin.CreateBucketFn = func(_ context.Context, _ models.CreateBucketAdminRequest) (*models.GarageBucketInfo, error) {
|
||||
return nil, errors.New("boom")
|
||||
}
|
||||
body, _ := json.Marshal(map[string]string{"name": "x"})
|
||||
req := httptest.NewRequest(http.MethodPost, "/buckets", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want 500", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// --- GetBucketInfo ---
|
||||
|
||||
func TestGetBucketInfo_Success(t *testing.T) {
|
||||
app, admin := newBucketsTestApp(t)
|
||||
admin.GetBucketInfoByAliasFn = func(_ context.Context, alias string) (*models.GarageBucketInfo, error) {
|
||||
return &models.GarageBucketInfo{ID: "id-1", Bytes: 1, Objects: 1}, nil
|
||||
}
|
||||
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/alpha", nil))
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetBucketInfo_NotFound404(t *testing.T) {
|
||||
app, admin := newBucketsTestApp(t)
|
||||
admin.GetBucketInfoByAliasFn = func(_ context.Context, _ string) (*models.GarageBucketInfo, error) {
|
||||
return nil, nil // nil pointer, nil error → 404
|
||||
}
|
||||
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/missing", 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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetBucketInfo_ServiceErrorReturns500(t *testing.T) {
|
||||
app, admin := newBucketsTestApp(t)
|
||||
admin.GetBucketInfoByAliasFn = func(_ context.Context, _ string) (*models.GarageBucketInfo, error) {
|
||||
return nil, errors.New("boom")
|
||||
}
|
||||
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/alpha", nil))
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want 500", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// --- DeleteBucket ---
|
||||
|
||||
func TestDeleteBucket_Success(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, id string) error {
|
||||
if id != "id-1" {
|
||||
t.Errorf("DeleteBucket id = %q, want id-1", id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
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.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteBucket_NotFound(t *testing.T) {
|
||||
app, admin := newBucketsTestApp(t)
|
||||
admin.GetBucketInfoByAliasFn = func(_ context.Context, _ string) (*models.GarageBucketInfo, error) {
|
||||
return nil, nil
|
||||
}
|
||||
resp, err := app.Test(httptest.NewRequest(http.MethodDelete, "/buckets/missing", 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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteBucket_AdminDeleteErrorReturns500(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 errors.New("boom") }
|
||||
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.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want 500", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// --- GrantBucketPermission ---
|
||||
|
||||
func TestGrantBucketPermission_Success(t *testing.T) {
|
||||
app, admin := newBucketsTestApp(t)
|
||||
admin.GetBucketInfoByAliasFn = func(_ context.Context, _ string) (*models.GarageBucketInfo, error) {
|
||||
return &models.GarageBucketInfo{ID: "id-1"}, nil
|
||||
}
|
||||
admin.AllowBucketKeyFn = func(_ context.Context, req models.BucketKeyPermRequest) (*models.GarageBucketInfo, error) {
|
||||
if req.BucketID != "id-1" || req.AccessKeyID != "AKIA" {
|
||||
t.Errorf("req = %+v", req)
|
||||
}
|
||||
if !req.Permissions.Read || !req.Permissions.Write || req.Permissions.Owner {
|
||||
t.Errorf("perms = %+v", req.Permissions)
|
||||
}
|
||||
return &models.GarageBucketInfo{ID: "id-1"}, nil
|
||||
}
|
||||
body, _ := json.Marshal(models.GrantBucketPermissionRequest{
|
||||
AccessKeyID: "AKIA",
|
||||
Permissions: models.BucketKeyPermission{Read: true, Write: true},
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/buckets/alpha/permissions", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGrantBucketPermission_MissingAccessKey400(t *testing.T) {
|
||||
app, _ := newBucketsTestApp(t)
|
||||
body, _ := json.Marshal(map[string]any{"accessKeyId": "", "permissions": map[string]bool{"read": true}})
|
||||
req := httptest.NewRequest(http.MethodPost, "/buckets/alpha/permissions", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGrantBucketPermission_BucketNotFound404(t *testing.T) {
|
||||
app, admin := newBucketsTestApp(t)
|
||||
admin.GetBucketInfoByAliasFn = func(_ context.Context, _ string) (*models.GarageBucketInfo, error) {
|
||||
return nil, nil
|
||||
}
|
||||
body, _ := json.Marshal(models.GrantBucketPermissionRequest{AccessKeyID: "AKIA"})
|
||||
req := httptest.NewRequest(http.MethodPost, "/buckets/missing/permissions", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// --- UpdateBucketWebsite ---
|
||||
|
||||
func TestUpdateBucketWebsite_EnableWithIndexDocument(t *testing.T) {
|
||||
app, admin := newBucketsTestApp(t)
|
||||
admin.GetBucketInfoByAliasFn = func(_ context.Context, _ string) (*models.GarageBucketInfo, error) {
|
||||
return &models.GarageBucketInfo{ID: "id-1"}, nil
|
||||
}
|
||||
admin.UpdateBucketFn = func(_ context.Context, id string, req models.UpdateBucketRequest) (*models.GarageBucketInfo, error) {
|
||||
if req.WebsiteAccess == nil || !req.WebsiteAccess.Enabled {
|
||||
t.Errorf("WebsiteAccess = %+v", req.WebsiteAccess)
|
||||
}
|
||||
if req.WebsiteAccess.IndexDocument == nil || *req.WebsiteAccess.IndexDocument != "index.html" {
|
||||
t.Errorf("IndexDocument = %v", req.WebsiteAccess.IndexDocument)
|
||||
}
|
||||
return &models.GarageBucketInfo{ID: id, WebsiteAccess: true}, nil
|
||||
}
|
||||
body, _ := json.Marshal(models.UpdateBucketWebsiteRequest{Enabled: true, IndexDocument: "index.html"})
|
||||
req := httptest.NewRequest(http.MethodPut, "/buckets/alpha/website", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateBucketWebsite_EnableWithoutIndexDocumentReturns400(t *testing.T) {
|
||||
app, _ := newBucketsTestApp(t)
|
||||
body, _ := json.Marshal(models.UpdateBucketWebsiteRequest{Enabled: true})
|
||||
req := httptest.NewRequest(http.MethodPut, "/buckets/alpha/website", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateBucketWebsite_Disable(t *testing.T) {
|
||||
app, admin := newBucketsTestApp(t)
|
||||
admin.GetBucketInfoByAliasFn = func(_ context.Context, _ string) (*models.GarageBucketInfo, error) {
|
||||
return &models.GarageBucketInfo{ID: "id-1"}, nil
|
||||
}
|
||||
admin.UpdateBucketFn = func(_ context.Context, _ string, req models.UpdateBucketRequest) (*models.GarageBucketInfo, error) {
|
||||
if req.WebsiteAccess == nil || req.WebsiteAccess.Enabled {
|
||||
t.Errorf("expected Enabled=false, got %+v", req.WebsiteAccess)
|
||||
}
|
||||
return &models.GarageBucketInfo{ID: "id-1"}, nil
|
||||
}
|
||||
body, _ := json.Marshal(models.UpdateBucketWebsiteRequest{Enabled: false})
|
||||
req := httptest.NewRequest(http.MethodPut, "/buckets/alpha/website", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
@@ -7,13 +7,13 @@ import (
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
// ClusterHandler handles cluster management operations
|
||||
// ClusterHandler handles cluster-status HTTP requests.
|
||||
type ClusterHandler struct {
|
||||
adminService *services.GarageAdminService
|
||||
adminService services.AdminService
|
||||
}
|
||||
|
||||
// NewClusterHandler creates a new cluster handler
|
||||
func NewClusterHandler(adminService *services.GarageAdminService) *ClusterHandler {
|
||||
// NewClusterHandler creates a new cluster handler.
|
||||
func NewClusterHandler(adminService services.AdminService) *ClusterHandler {
|
||||
return &ClusterHandler{
|
||||
adminService: adminService,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"Noooste/garage-ui/internal/models"
|
||||
"Noooste/garage-ui/internal/services/mocks"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
func newClusterTestApp(t *testing.T) (*fiber.App, *mocks.AdminMock) {
|
||||
t.Helper()
|
||||
admin := &mocks.AdminMock{}
|
||||
h := NewClusterHandler(admin)
|
||||
app := fiber.New()
|
||||
app.Get("/cluster/health", h.GetHealth)
|
||||
app.Get("/cluster/status", h.GetStatus)
|
||||
app.Get("/cluster/statistics", h.GetStatistics)
|
||||
app.Get("/cluster/nodes/:node_id", h.GetNodeInfo)
|
||||
app.Get("/cluster/nodes/:node_id/statistics", h.GetNodeStatistics)
|
||||
// Extra routes without a node_id param so we can exercise the empty-id gate.
|
||||
// Fiber requires a bound param value, so we mount a path with an empty
|
||||
// trailing segment explicitly via Locals.
|
||||
app.Get("/cluster/nodes-empty", func(c fiber.Ctx) error {
|
||||
// Set empty node_id in locals via a route that doesn't capture it.
|
||||
return h.GetNodeInfo(c)
|
||||
})
|
||||
return app, admin
|
||||
}
|
||||
|
||||
func doGet(t *testing.T, app *fiber.App, path string) *http.Response {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test %s: %v", path, err)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func TestCluster_GetHealth_Success(t *testing.T) {
|
||||
app, admin := newClusterTestApp(t)
|
||||
admin.GetClusterHealthFn = func(_ context.Context) (*models.ClusterHealth, error) {
|
||||
return &models.ClusterHealth{Status: "healthy"}, nil
|
||||
}
|
||||
resp := doGet(t, app, "/cluster/health")
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
var body struct {
|
||||
Success bool `json:"success"`
|
||||
Data models.ClusterHealth `json:"data"`
|
||||
}
|
||||
_ = json.NewDecoder(resp.Body).Decode(&body)
|
||||
if !body.Success || body.Data.Status != "healthy" {
|
||||
t.Errorf("body = %+v", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCluster_GetHealth_ServiceErrorReturns500(t *testing.T) {
|
||||
app, admin := newClusterTestApp(t)
|
||||
admin.GetClusterHealthFn = func(_ context.Context) (*models.ClusterHealth, error) {
|
||||
return nil, errors.New("upstream down")
|
||||
}
|
||||
resp := doGet(t, app, "/cluster/health")
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want 500", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCluster_GetStatus_Success(t *testing.T) {
|
||||
app, admin := newClusterTestApp(t)
|
||||
admin.GetClusterStatusFn = func(_ context.Context) (*models.ClusterStatus, error) {
|
||||
return &models.ClusterStatus{}, nil
|
||||
}
|
||||
resp := doGet(t, app, "/cluster/status")
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCluster_GetStatus_ServiceErrorReturns500(t *testing.T) {
|
||||
app, admin := newClusterTestApp(t)
|
||||
admin.GetClusterStatusFn = func(_ context.Context) (*models.ClusterStatus, error) {
|
||||
return nil, errors.New("boom")
|
||||
}
|
||||
resp := doGet(t, app, "/cluster/status")
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want 500", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCluster_GetStatistics_Success(t *testing.T) {
|
||||
app, admin := newClusterTestApp(t)
|
||||
admin.GetClusterStatisticsFn = func(_ context.Context) (*models.ClusterStatistics, error) {
|
||||
return &models.ClusterStatistics{}, nil
|
||||
}
|
||||
resp := doGet(t, app, "/cluster/statistics")
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCluster_GetStatistics_ServiceErrorReturns500(t *testing.T) {
|
||||
app, admin := newClusterTestApp(t)
|
||||
admin.GetClusterStatisticsFn = func(_ context.Context) (*models.ClusterStatistics, error) {
|
||||
return nil, errors.New("boom")
|
||||
}
|
||||
resp := doGet(t, app, "/cluster/statistics")
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCluster_GetNodeInfo_Success(t *testing.T) {
|
||||
app, admin := newClusterTestApp(t)
|
||||
admin.GetNodeInfoFn = func(_ context.Context, nodeID string) (*models.MultiNodeResponse, error) {
|
||||
if nodeID != "node-1" {
|
||||
t.Errorf("nodeID = %q, want node-1", nodeID)
|
||||
}
|
||||
return &models.MultiNodeResponse{}, nil
|
||||
}
|
||||
resp := doGet(t, app, "/cluster/nodes/node-1")
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCluster_GetNodeInfo_ServiceErrorReturns500(t *testing.T) {
|
||||
app, admin := newClusterTestApp(t)
|
||||
admin.GetNodeInfoFn = func(_ context.Context, _ string) (*models.MultiNodeResponse, error) {
|
||||
return nil, errors.New("boom")
|
||||
}
|
||||
resp := doGet(t, app, "/cluster/nodes/n1")
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCluster_GetNodeStatistics_Success(t *testing.T) {
|
||||
app, admin := newClusterTestApp(t)
|
||||
admin.GetNodeStatisticsFn = func(_ context.Context, nodeID string) (*models.MultiNodeResponse, error) {
|
||||
return &models.MultiNodeResponse{}, nil
|
||||
}
|
||||
resp := doGet(t, app, "/cluster/nodes/n1/statistics")
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCluster_GetNodeStatistics_ServiceErrorReturns500(t *testing.T) {
|
||||
app, admin := newClusterTestApp(t)
|
||||
admin.GetNodeStatisticsFn = func(_ context.Context, _ string) (*models.MultiNodeResponse, error) {
|
||||
return nil, errors.New("boom")
|
||||
}
|
||||
resp := doGet(t, app, "/cluster/nodes/n1/statistics")
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
// newHealthTestApp builds a bare Fiber app with the health endpoint mounted.
|
||||
func newHealthTestApp(t *testing.T, version string) *fiber.App {
|
||||
t.Helper()
|
||||
h := NewHealthHandler(version)
|
||||
app := fiber.New()
|
||||
app.Get("/health", h.Check)
|
||||
return app
|
||||
}
|
||||
|
||||
func TestHealthCheck_ReturnsHealthyEnvelope(t *testing.T) {
|
||||
app := newHealthTestApp(t, "v0.0.0-test")
|
||||
req := httptest.NewRequest(http.MethodGet, "/health", nil)
|
||||
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read body: %v", err)
|
||||
}
|
||||
|
||||
var envelope struct {
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
Status string `json:"status"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Version string `json:"version"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &envelope); err != nil {
|
||||
t.Fatalf("decode body: %v\n%s", err, body)
|
||||
}
|
||||
|
||||
if !envelope.Success {
|
||||
t.Error("success = false, want true")
|
||||
}
|
||||
if envelope.Data.Status != "healthy" {
|
||||
t.Errorf("status = %q, want healthy", envelope.Data.Status)
|
||||
}
|
||||
if envelope.Data.Version != "v0.0.0-test" {
|
||||
t.Errorf("version = %q, want v0.0.0-test", envelope.Data.Version)
|
||||
}
|
||||
if envelope.Data.Timestamp.IsZero() {
|
||||
t.Error("timestamp zero")
|
||||
}
|
||||
// Timestamp must be within a reasonable window (1 minute) of now.
|
||||
if d := time.Since(envelope.Data.Timestamp); d < 0 || d > time.Minute {
|
||||
t.Errorf("timestamp delta = %v, want within 1 minute of now", d)
|
||||
}
|
||||
}
|
||||
@@ -7,14 +7,14 @@ import (
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
// MonitoringHandler handles monitoring operations
|
||||
// MonitoringHandler handles metrics and dashboard HTTP requests.
|
||||
type MonitoringHandler struct {
|
||||
adminService *services.GarageAdminService
|
||||
s3Service *services.S3Service
|
||||
adminService services.AdminService
|
||||
s3Service services.S3Storage
|
||||
}
|
||||
|
||||
// NewMonitoringHandler creates a new monitoring handler
|
||||
func NewMonitoringHandler(adminService *services.GarageAdminService, s3Service *services.S3Service) *MonitoringHandler {
|
||||
// NewMonitoringHandler creates a new monitoring handler.
|
||||
func NewMonitoringHandler(adminService services.AdminService, s3Service services.S3Storage) *MonitoringHandler {
|
||||
return &MonitoringHandler{
|
||||
adminService: adminService,
|
||||
s3Service: s3Service,
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"Noooste/garage-ui/internal/models"
|
||||
"Noooste/garage-ui/internal/services/mocks"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
func newMonitoringTestApp(t *testing.T) (*fiber.App, *mocks.AdminMock) {
|
||||
t.Helper()
|
||||
admin := &mocks.AdminMock{}
|
||||
h := NewMonitoringHandler(admin, nil) // s3Service unused by this handler
|
||||
app := fiber.New()
|
||||
app.Get("/monitoring/metrics", h.GetMetrics)
|
||||
app.Get("/monitoring/admin-health", h.CheckAdminHealth)
|
||||
app.Get("/monitoring/dashboard", h.GetDashboardMetrics)
|
||||
return app, admin
|
||||
}
|
||||
|
||||
func TestMonitoring_GetMetrics_PassesThroughAsPlainText(t *testing.T) {
|
||||
app, admin := newMonitoringTestApp(t)
|
||||
admin.GetMetricsFn = func(_ context.Context) (string, error) {
|
||||
return "# HELP foo\nfoo 1\n", nil
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, "/monitoring/metrics", nil)
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
if ct := resp.Header.Get("Content-Type"); ct != "text/plain; charset=utf-8" {
|
||||
t.Errorf("Content-Type = %q", ct)
|
||||
}
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
if string(b) != "# HELP foo\nfoo 1\n" {
|
||||
t.Errorf("body = %q", b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonitoring_GetMetrics_ServiceErrorReturns500JSON(t *testing.T) {
|
||||
app, admin := newMonitoringTestApp(t)
|
||||
admin.GetMetricsFn = func(_ context.Context) (string, error) {
|
||||
return "", errors.New("scrape failed")
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, "/monitoring/metrics", nil)
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want 500", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonitoring_CheckAdminHealth_Healthy(t *testing.T) {
|
||||
app, admin := newMonitoringTestApp(t)
|
||||
admin.HealthCheckFn = func(_ context.Context) error { return nil }
|
||||
req := httptest.NewRequest(http.MethodGet, "/monitoring/admin-health", nil)
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
var body struct {
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
} `json:"data"`
|
||||
}
|
||||
_ = json.NewDecoder(resp.Body).Decode(&body)
|
||||
if !body.Success || body.Data.Status != "healthy" {
|
||||
t.Errorf("body = %+v", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonitoring_CheckAdminHealth_Unhealthy503(t *testing.T) {
|
||||
app, admin := newMonitoringTestApp(t)
|
||||
admin.HealthCheckFn = func(_ context.Context) error { return errors.New("down") }
|
||||
req := httptest.NewRequest(http.MethodGet, "/monitoring/admin-health", nil)
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusServiceUnavailable {
|
||||
t.Fatalf("status = %d, want 503", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonitoring_GetDashboardMetrics_AggregatesSizesAndPercentages(t *testing.T) {
|
||||
app, admin := newMonitoringTestApp(t)
|
||||
admin.ListBucketsFn = func(_ context.Context) ([]models.ListBucketsResponseItem, error) {
|
||||
return []models.ListBucketsResponseItem{
|
||||
{ID: "b1", Created: time.Unix(0, 0), GlobalAliases: []string{"alpha"}},
|
||||
{ID: "b2", Created: time.Unix(0, 0), GlobalAliases: []string{"beta"}},
|
||||
}, nil
|
||||
}
|
||||
admin.GetBucketInfoFn = func(_ context.Context, id string) (*models.GarageBucketInfo, error) {
|
||||
switch id {
|
||||
case "b1":
|
||||
return &models.GarageBucketInfo{ID: "b1", Bytes: 300, Objects: 3}, nil
|
||||
case "b2":
|
||||
return &models.GarageBucketInfo{ID: "b2", Bytes: 100, Objects: 1}, nil
|
||||
}
|
||||
return nil, errors.New("unexpected id: " + id)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/monitoring/dashboard", nil)
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Data models.DashboardMetrics `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
|
||||
if body.Data.TotalSize != 400 {
|
||||
t.Errorf("TotalSize = %d, want 400", body.Data.TotalSize)
|
||||
}
|
||||
if body.Data.ObjectCount != 4 {
|
||||
t.Errorf("ObjectCount = %d, want 4", body.Data.ObjectCount)
|
||||
}
|
||||
if body.Data.BucketCount != 2 {
|
||||
t.Errorf("BucketCount = %d, want 2", body.Data.BucketCount)
|
||||
}
|
||||
if len(body.Data.UsageByBucket) != 2 {
|
||||
t.Fatalf("UsageByBucket len = %d, want 2", len(body.Data.UsageByBucket))
|
||||
}
|
||||
// Percentages: 300/400 = 75, 100/400 = 25.
|
||||
for _, u := range body.Data.UsageByBucket {
|
||||
if u.BucketName == "alpha" && u.Percentage != 75 {
|
||||
t.Errorf("alpha pct = %v, want 75", u.Percentage)
|
||||
}
|
||||
if u.BucketName == "beta" && u.Percentage != 25 {
|
||||
t.Errorf("beta pct = %v, want 25", u.Percentage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonitoring_GetDashboardMetrics_SkipsInaccessibleBuckets(t *testing.T) {
|
||||
app, admin := newMonitoringTestApp(t)
|
||||
admin.ListBucketsFn = func(_ context.Context) ([]models.ListBucketsResponseItem, error) {
|
||||
return []models.ListBucketsResponseItem{
|
||||
{ID: "good", GlobalAliases: []string{"good"}},
|
||||
{ID: "bad", GlobalAliases: []string{"bad"}},
|
||||
}, nil
|
||||
}
|
||||
admin.GetBucketInfoFn = func(_ context.Context, id string) (*models.GarageBucketInfo, error) {
|
||||
if id == "bad" {
|
||||
return nil, errors.New("access denied")
|
||||
}
|
||||
return &models.GarageBucketInfo{ID: "good", Bytes: 10, Objects: 1}, nil
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, "/monitoring/dashboard", nil)
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
var body struct {
|
||||
Data models.DashboardMetrics `json:"data"`
|
||||
}
|
||||
_ = json.NewDecoder(resp.Body).Decode(&body)
|
||||
if body.Data.BucketCount != 2 {
|
||||
t.Errorf("BucketCount = %d, want 2 (count includes inaccessible)", body.Data.BucketCount)
|
||||
}
|
||||
if len(body.Data.UsageByBucket) != 1 {
|
||||
t.Errorf("UsageByBucket = %d, want 1 (inaccessible skipped)", len(body.Data.UsageByBucket))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonitoring_GetDashboardMetrics_ListBucketsErrorReturns500(t *testing.T) {
|
||||
app, admin := newMonitoringTestApp(t)
|
||||
admin.ListBucketsFn = func(_ context.Context) ([]models.ListBucketsResponseItem, error) {
|
||||
return nil, errors.New("upstream")
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, "/monitoring/dashboard", nil)
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want 500", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"io"
|
||||
"net/url"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"Noooste/garage-ui/internal/models"
|
||||
@@ -11,13 +15,66 @@ import (
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
// ObjectHandler handles object-related operations
|
||||
type ObjectHandler struct {
|
||||
s3Service *services.S3Service
|
||||
// unsafeInlineContentTypes are MIME types that a browser can execute as
|
||||
// JavaScript in the response's origin when rendered inline. Since the SPA is
|
||||
// served from the same origin as the API, any uploader could otherwise plant
|
||||
// stored XSS by uploading a file with one of these Content-Types.
|
||||
var unsafeInlineContentTypes = map[string]struct{}{
|
||||
"text/html": {},
|
||||
"application/xhtml+xml": {},
|
||||
"image/svg+xml": {},
|
||||
"application/xml": {},
|
||||
"text/xml": {},
|
||||
"application/javascript": {},
|
||||
"text/javascript": {},
|
||||
}
|
||||
|
||||
// NewObjectHandler creates a new object handler
|
||||
func NewObjectHandler(s3Service *services.S3Service) *ObjectHandler {
|
||||
// safeContentType rewrites Content-Types that the browser would treat as
|
||||
// executable to application/octet-stream.
|
||||
func safeContentType(ct string) string {
|
||||
base := strings.TrimSpace(strings.ToLower(ct))
|
||||
if i := strings.IndexByte(base, ';'); i >= 0 {
|
||||
base = strings.TrimSpace(base[:i])
|
||||
}
|
||||
if _, bad := unsafeInlineContentTypes[base]; bad {
|
||||
return "application/octet-stream"
|
||||
}
|
||||
return ct
|
||||
}
|
||||
|
||||
// contentDispositionHeader builds an RFC 6266 / RFC 5987 Content-Disposition
|
||||
// header value with the user-controlled object key safely encoded. Strips
|
||||
// path components and control characters before emitting the ASCII fallback,
|
||||
// then appends the percent-encoded UTF-8 filename*= for full fidelity.
|
||||
func contentDispositionHeader(disposition, key string) string {
|
||||
name := path.Base(key)
|
||||
if name == "." || name == "/" || name == "" {
|
||||
name = "download"
|
||||
}
|
||||
// ASCII-safe fallback: drop anything that could break the quoted value.
|
||||
var asciiFallback strings.Builder
|
||||
for _, r := range name {
|
||||
if r < 0x20 || r == 0x7f || r == '"' || r == '\\' || r > 0x7e {
|
||||
asciiFallback.WriteByte('_')
|
||||
continue
|
||||
}
|
||||
asciiFallback.WriteRune(r)
|
||||
}
|
||||
fallback := asciiFallback.String()
|
||||
if fallback == "" {
|
||||
fallback = "download"
|
||||
}
|
||||
encoded := url.PathEscape(name)
|
||||
return disposition + "; filename=\"" + fallback + "\"; filename*=UTF-8''" + encoded
|
||||
}
|
||||
|
||||
// ObjectHandler handles object-related HTTP requests.
|
||||
type ObjectHandler struct {
|
||||
s3Service services.S3Storage
|
||||
}
|
||||
|
||||
// NewObjectHandler creates a new object handler.
|
||||
func NewObjectHandler(s3Service services.S3Storage) *ObjectHandler {
|
||||
return &ObjectHandler{
|
||||
s3Service: s3Service,
|
||||
}
|
||||
@@ -137,6 +194,58 @@ func (h *ObjectHandler) UploadObject(c fiber.Ctx) error {
|
||||
return c.Status(fiber.StatusCreated).JSON(models.SuccessResponse(uploadResult))
|
||||
}
|
||||
|
||||
// CreateDirectory creates an empty directory marker in a bucket.
|
||||
//
|
||||
// @Summary Create directory in bucket
|
||||
// @Description Creates a zero-byte object whose key ends with "/" so that S3 clients display it as an empty folder.
|
||||
// @Tags Objects
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param bucket path string true "Name of the bucket"
|
||||
// @Param request body object{key=string} true "Directory key (must end with '/')"
|
||||
// @Success 201 {object} models.APIResponse{data=models.ObjectUploadResponse} "Directory created"
|
||||
// @Failure 400 {object} models.APIResponse{error=models.APIError} "Invalid request parameters"
|
||||
// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to create directory"
|
||||
// @Router /api/v1/buckets/{bucket}/directories [post]
|
||||
func (h *ObjectHandler) CreateDirectory(c fiber.Ctx) error {
|
||||
ctx := c.Context()
|
||||
|
||||
bucketName := c.Params("bucket")
|
||||
if bucketName == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBadRequest, "Bucket name is required"),
|
||||
)
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Key string `json:"key"`
|
||||
}
|
||||
if err := c.Bind().JSON(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBadRequest, "Invalid request body: "+err.Error()),
|
||||
)
|
||||
}
|
||||
|
||||
key := strings.TrimLeft(req.Key, "/")
|
||||
if key == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(
|
||||
models.ErrorResponse(models.ErrCodeBadRequest, "Directory key is required"),
|
||||
)
|
||||
}
|
||||
if !strings.HasSuffix(key, "/") {
|
||||
key += "/"
|
||||
}
|
||||
|
||||
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 c.Status(fiber.StatusCreated).JSON(models.SuccessResponse(result))
|
||||
}
|
||||
|
||||
// GetObject retrieves an object from a bucket
|
||||
//
|
||||
// @Summary Get object from bucket
|
||||
@@ -176,21 +285,29 @@ func (h *ObjectHandler) GetObject(c fiber.Ctx) error {
|
||||
models.ErrorResponse(models.ErrCodeObjectNotFound, "Object not found: "+err.Error()),
|
||||
)
|
||||
}
|
||||
defer body.Close()
|
||||
|
||||
// Set response headers
|
||||
c.Set("Content-Type", objectInfo.ContentType)
|
||||
c.Set("Content-Length", string(rune(objectInfo.Size)))
|
||||
// The uploader controls Content-Type. Rewrite executable MIME types to
|
||||
// application/octet-stream and always disable sniffing so stored HTML/SVG
|
||||
// cannot run as XSS in the SPA origin when fetched inline.
|
||||
c.Set("Content-Type", safeContentType(objectInfo.ContentType))
|
||||
c.Set("X-Content-Type-Options", "nosniff")
|
||||
c.Set("Content-Length", strconv.FormatInt(objectInfo.Size, 10))
|
||||
c.Set("ETag", objectInfo.ETag)
|
||||
c.Set("Last-Modified", objectInfo.LastModified.Format(time.RFC1123))
|
||||
|
||||
// Check if client wants to download or view inline
|
||||
// The object key is attacker-controlled — build the header via the safe
|
||||
// RFC 6266 helper to avoid quote/semicolon injection into filename=.
|
||||
disposition := "inline"
|
||||
if c.Query("download") == "true" {
|
||||
c.Set("Content-Disposition", "attachment; filename=\""+key+"\"")
|
||||
disposition = "attachment"
|
||||
}
|
||||
c.Set("Content-Disposition", contentDispositionHeader(disposition, key))
|
||||
|
||||
// Stream the object body to the client
|
||||
return c.SendStream(body)
|
||||
// Stream the object body to the client without buffering the entire file
|
||||
return c.SendStreamWriter(func(w *bufio.Writer) {
|
||||
defer body.Close()
|
||||
io.Copy(w, body)
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteObject deletes an object from a bucket
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSafeContentType_RewritesExecutableTypes(t *testing.T) {
|
||||
cases := []struct {
|
||||
in, want string
|
||||
}{
|
||||
{"text/html", "application/octet-stream"},
|
||||
{"text/html; charset=utf-8", "application/octet-stream"},
|
||||
{"TEXT/HTML", "application/octet-stream"},
|
||||
{" text/html ", "application/octet-stream"},
|
||||
{"application/xhtml+xml", "application/octet-stream"},
|
||||
{"image/svg+xml", "application/octet-stream"},
|
||||
{"application/xml", "application/octet-stream"},
|
||||
{"text/xml", "application/octet-stream"},
|
||||
{"application/javascript", "application/octet-stream"},
|
||||
{"text/javascript", "application/octet-stream"},
|
||||
// Safe types pass through unchanged (including parameters).
|
||||
{"image/png", "image/png"},
|
||||
{"application/octet-stream", "application/octet-stream"},
|
||||
{"text/plain; charset=utf-8", "text/plain; charset=utf-8"},
|
||||
{"", ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.in, func(t *testing.T) {
|
||||
if got := safeContentType(tc.in); got != tc.want {
|
||||
t.Errorf("safeContentType(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestContentDispositionHeader_StripsPathAndEscapes(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
disp, key string
|
||||
mustContain []string
|
||||
mustNotContain []string
|
||||
}{
|
||||
{
|
||||
name: "simple filename",
|
||||
disp: "inline", key: "photo.png",
|
||||
mustContain: []string{
|
||||
`inline; filename="photo.png"; filename*=UTF-8''photo.png`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "path components stripped",
|
||||
disp: "attachment", key: "a/b/c/file.txt",
|
||||
mustContain: []string{`filename="file.txt"`, `filename*=UTF-8''file.txt`},
|
||||
mustNotContain: []string{`a/b/c`},
|
||||
},
|
||||
{
|
||||
name: "quote and backslash replaced in ASCII fallback",
|
||||
disp: "inline", key: `"evil\name".txt`,
|
||||
mustContain: []string{`filename="_evil_name_.txt"`},
|
||||
mustNotContain: []string{`"evil`, `\name`},
|
||||
},
|
||||
{
|
||||
name: "control character replaced",
|
||||
disp: "inline", key: "line\nbreak.txt",
|
||||
mustContain: []string{`filename="line_break.txt"`},
|
||||
},
|
||||
{
|
||||
name: "non-ASCII preserved in filename* only",
|
||||
disp: "inline", key: "漢字.txt",
|
||||
mustContain: []string{
|
||||
`filename*=UTF-8''`,
|
||||
// fallback replaced every non-ASCII rune with _ (2 runes + .txt).
|
||||
`filename="__.txt"`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty key falls back to download",
|
||||
disp: "inline", key: "",
|
||||
mustContain: []string{`filename="download"`, `filename*=UTF-8''download`},
|
||||
},
|
||||
{
|
||||
name: "key of only slashes falls back to download",
|
||||
disp: "inline", key: "/",
|
||||
mustContain: []string{`filename="download"`, `filename*=UTF-8''`},
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := contentDispositionHeader(tc.disp, tc.key)
|
||||
for _, sub := range tc.mustContain {
|
||||
if !strings.Contains(got, sub) {
|
||||
t.Errorf("got %q\nmust contain %q", got, sub)
|
||||
}
|
||||
}
|
||||
for _, sub := range tc.mustNotContain {
|
||||
if strings.Contains(got, sub) {
|
||||
t.Errorf("got %q\nmust NOT contain %q", got, sub)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,768 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"Noooste/garage-ui/internal/models"
|
||||
"Noooste/garage-ui/internal/services"
|
||||
"Noooste/garage-ui/internal/services/mocks"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
func newObjectsTestApp(t *testing.T) (*fiber.App, *mocks.S3Mock) {
|
||||
t.Helper()
|
||||
s3 := &mocks.S3Mock{}
|
||||
h := NewObjectHandler(s3)
|
||||
app := fiber.New()
|
||||
app.Get("/buckets/:bucket/objects", h.ListObjects)
|
||||
app.Post("/buckets/:bucket/objects", h.UploadObject)
|
||||
app.Post("/buckets/:bucket/objects/upload-multiple", h.UploadMultipleObjects)
|
||||
app.Post("/buckets/:bucket/objects/delete-multiple", h.DeleteMultipleObjects)
|
||||
// Wildcard endpoints — mount under :key for tests. Handlers prefer
|
||||
// c.Locals("objectKey") but fall back to c.Params("key"), so :key works.
|
||||
app.Get("/buckets/:bucket/objects/:key", h.GetObject)
|
||||
app.Get("/buckets/:bucket/objects/:key/metadata", h.GetObjectMetadata)
|
||||
app.Get("/buckets/:bucket/objects/:key/presigned", h.GetPresignedURL)
|
||||
app.Delete("/buckets/:bucket/objects/:key", h.DeleteObject)
|
||||
return app, s3
|
||||
}
|
||||
|
||||
// --- ListObjects ---
|
||||
|
||||
func TestListObjects_Success(t *testing.T) {
|
||||
app, s3 := newObjectsTestApp(t)
|
||||
s3.ListObjectsFn = func(_ context.Context, bucket, prefix string, max int, tok string) (*models.ObjectListResponse, error) {
|
||||
if bucket != "b1" || prefix != "p/" || max != 50 || tok != "T" {
|
||||
t.Errorf("args = (%q, %q, %d, %q)", bucket, prefix, max, tok)
|
||||
}
|
||||
return &models.ObjectListResponse{
|
||||
Bucket: bucket, Count: 1,
|
||||
Objects: []models.ObjectInfo{{Key: "k1", Size: 1}},
|
||||
}, nil
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, "/buckets/b1/objects?prefix=p/&max_keys=50&continuation_token=T", nil)
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d", resp.StatusCode)
|
||||
}
|
||||
var body struct {
|
||||
Data models.ObjectListResponse `json:"data"`
|
||||
}
|
||||
decodeJSON(t, resp.Body, &body)
|
||||
if body.Data.Count != 1 {
|
||||
t.Errorf("count = %d", body.Data.Count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListObjects_DefaultMaxKeys(t *testing.T) {
|
||||
app, s3 := newObjectsTestApp(t)
|
||||
s3.ListObjectsFn = func(_ context.Context, _, _ string, max int, _ string) (*models.ObjectListResponse, error) {
|
||||
if max != 100 {
|
||||
t.Errorf("max = %d, want default 100", max)
|
||||
}
|
||||
return &models.ObjectListResponse{}, nil
|
||||
}
|
||||
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/objects", nil))
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListObjects_InvalidMaxKeys400(t *testing.T) {
|
||||
app, _ := newObjectsTestApp(t)
|
||||
cases := []string{"0", "-1", "abc"}
|
||||
for _, mk := range cases {
|
||||
t.Run(mk, func(t *testing.T) {
|
||||
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/objects?max_keys="+mk, nil))
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestListObjects_ServiceError500(t *testing.T) {
|
||||
app, s3 := newObjectsTestApp(t)
|
||||
s3.ListObjectsFn = func(_ context.Context, _, _ string, _ int, _ string) (*models.ObjectListResponse, error) {
|
||||
return nil, errors.New("boom")
|
||||
}
|
||||
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/objects", nil))
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want 500", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// --- GetObjectMetadata ---
|
||||
|
||||
func TestGetObjectMetadata_Success(t *testing.T) {
|
||||
app, s3 := newObjectsTestApp(t)
|
||||
s3.GetObjectMetadataFn = func(_ context.Context, b, k string) (*models.ObjectInfo, error) {
|
||||
return &models.ObjectInfo{Key: k, Size: 42, ContentType: "image/png"}, nil
|
||||
}
|
||||
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/objects/k1/metadata", nil))
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d", resp.StatusCode)
|
||||
}
|
||||
var body struct {
|
||||
Data models.ObjectInfo `json:"data"`
|
||||
}
|
||||
decodeJSON(t, resp.Body, &body)
|
||||
if body.Data.Size != 42 {
|
||||
t.Errorf("size = %d", body.Data.Size)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetObjectMetadata_NotFound404(t *testing.T) {
|
||||
app, s3 := newObjectsTestApp(t)
|
||||
s3.GetObjectMetadataFn = func(_ context.Context, _, _ string) (*models.ObjectInfo, error) {
|
||||
return nil, errors.New("not found")
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// --- DeleteObject ---
|
||||
|
||||
func TestDeleteObject_Success(t *testing.T) {
|
||||
app, s3 := newObjectsTestApp(t)
|
||||
s3.ObjectExistsFn = func(_ context.Context, _, _ string) (bool, error) { return true, nil }
|
||||
s3.DeleteObjectFn = func(_ context.Context, b, k string) error {
|
||||
if b != "b1" || k != "k1" {
|
||||
t.Errorf("args = (%q, %q)", b, k)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
resp, err := app.Test(httptest.NewRequest(http.MethodDelete, "/buckets/b1/objects/k1", nil))
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteObject_NotExists404(t *testing.T) {
|
||||
app, s3 := newObjectsTestApp(t)
|
||||
s3.ObjectExistsFn = func(_ context.Context, _, _ string) (bool, error) { return false, nil }
|
||||
resp, err := app.Test(httptest.NewRequest(http.MethodDelete, "/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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteObject_ExistsCheckError500(t *testing.T) {
|
||||
app, s3 := newObjectsTestApp(t)
|
||||
s3.ObjectExistsFn = func(_ context.Context, _, _ string) (bool, error) { return false, errors.New("boom") }
|
||||
resp, err := app.Test(httptest.NewRequest(http.MethodDelete, "/buckets/b1/objects/k1", nil))
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want 500", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteObject_DeleteError500(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 errors.New("boom") }
|
||||
resp, err := app.Test(httptest.NewRequest(http.MethodDelete, "/buckets/b1/objects/k1", nil))
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want 500", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// --- GetPresignedURL ---
|
||||
|
||||
func TestGetPresignedURL_DefaultExpiration(t *testing.T) {
|
||||
app, s3 := newObjectsTestApp(t)
|
||||
s3.ObjectExistsFn = func(_ context.Context, _, _ string) (bool, error) { return true, nil }
|
||||
s3.GetPresignedURLFn = func(_ context.Context, b, k string, exp time.Duration) (string, error) {
|
||||
if exp != 3600*time.Second {
|
||||
t.Errorf("exp = %v, want 1h", exp)
|
||||
}
|
||||
return "https://example/signed", nil
|
||||
}
|
||||
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/objects/k1/presigned", nil))
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d", resp.StatusCode)
|
||||
}
|
||||
var body struct {
|
||||
Data models.PresignedURLResponse `json:"data"`
|
||||
}
|
||||
decodeJSON(t, resp.Body, &body)
|
||||
if body.Data.URL != "https://example/signed" || body.Data.ExpiresIn != 3600 {
|
||||
t.Errorf("body = %+v", body.Data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPresignedURL_CustomExpirationWithinRange(t *testing.T) {
|
||||
app, s3 := newObjectsTestApp(t)
|
||||
s3.ObjectExistsFn = func(_ context.Context, _, _ string) (bool, error) { return true, nil }
|
||||
s3.GetPresignedURLFn = func(_ context.Context, _, _ string, exp time.Duration) (string, error) {
|
||||
if exp != 60*time.Second {
|
||||
t.Errorf("exp = %v, want 60s", exp)
|
||||
}
|
||||
return "u", nil
|
||||
}
|
||||
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/objects/k1/presigned?expires_in=60", nil))
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPresignedURL_InvalidExpiration400(t *testing.T) {
|
||||
app, _ := newObjectsTestApp(t)
|
||||
cases := []string{"0", "-1", "604801", "abc"}
|
||||
for _, val := range cases {
|
||||
t.Run(val, func(t *testing.T) {
|
||||
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/objects/k1/presigned?expires_in="+val, nil))
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPresignedURL_ObjectMissing404(t *testing.T) {
|
||||
app, s3 := newObjectsTestApp(t)
|
||||
s3.ObjectExistsFn = func(_ context.Context, _, _ string) (bool, error) { return false, nil }
|
||||
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/objects/nope/presigned", 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)
|
||||
}
|
||||
}
|
||||
|
||||
// --- GetObject ---
|
||||
|
||||
func TestGetObject_Success_StreamsBodyAndHeaders(t *testing.T) {
|
||||
app, s3 := newObjectsTestApp(t)
|
||||
content := []byte("hello world")
|
||||
s3.GetObjectFn = func(_ context.Context, b, k string) (io.ReadCloser, *models.ObjectInfo, error) {
|
||||
return io.NopCloser(bytes.NewReader(content)), &models.ObjectInfo{
|
||||
Key: k, Size: int64(len(content)), ETag: `"etag-1"`,
|
||||
ContentType: "image/png", LastModified: time.Date(2026, 4, 1, 12, 0, 0, 0, time.UTC),
|
||||
}, nil
|
||||
}
|
||||
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/objects/k1", nil))
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d", resp.StatusCode)
|
||||
}
|
||||
if got := resp.Header.Get("Content-Type"); got != "image/png" {
|
||||
t.Errorf("Content-Type = %q", got)
|
||||
}
|
||||
if got := resp.Header.Get("X-Content-Type-Options"); got != "nosniff" {
|
||||
t.Errorf("X-Content-Type-Options = %q", got)
|
||||
}
|
||||
if !strings.Contains(resp.Header.Get("Content-Disposition"), `filename="k1"`) {
|
||||
t.Errorf("Content-Disposition = %q", resp.Header.Get("Content-Disposition"))
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if !bytes.Equal(body, content) {
|
||||
t.Errorf("body = %q, want %q", body, content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetObject_RewritesExecutableContentType(t *testing.T) {
|
||||
app, s3 := newObjectsTestApp(t)
|
||||
s3.GetObjectFn = func(_ context.Context, _, _ string) (io.ReadCloser, *models.ObjectInfo, error) {
|
||||
return io.NopCloser(strings.NewReader("<script>alert(1)</script>")),
|
||||
&models.ObjectInfo{Key: "evil.html", Size: 25, ContentType: "text/html"},
|
||||
nil
|
||||
}
|
||||
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/objects/evil.html", nil))
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if got := resp.Header.Get("Content-Type"); got != "application/octet-stream" {
|
||||
t.Errorf("Content-Type = %q, want application/octet-stream", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetObject_DownloadQuerySetsAttachment(t *testing.T) {
|
||||
app, s3 := newObjectsTestApp(t)
|
||||
s3.GetObjectFn = func(_ context.Context, _, _ string) (io.ReadCloser, *models.ObjectInfo, error) {
|
||||
return io.NopCloser(strings.NewReader("x")), &models.ObjectInfo{Key: "file.txt", Size: 1}, nil
|
||||
}
|
||||
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/objects/file.txt?download=true", nil))
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if !strings.HasPrefix(resp.Header.Get("Content-Disposition"), "attachment") {
|
||||
t.Errorf("Content-Disposition = %q, want attachment", resp.Header.Get("Content-Disposition"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetObject_ServiceErrorReturns404(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")
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// buildMultipart builds a multipart body with a single file field plus
|
||||
// optional additional form fields. Returns body bytes and the Content-Type
|
||||
// header value (which includes the boundary).
|
||||
func buildMultipart(t *testing.T, fields map[string]string, files map[string]struct {
|
||||
Filename string
|
||||
Content []byte
|
||||
ContentType string
|
||||
}) ([]byte, string) {
|
||||
t.Helper()
|
||||
buf := &bytes.Buffer{}
|
||||
w := multipart.NewWriter(buf)
|
||||
for k, v := range fields {
|
||||
if err := w.WriteField(k, v); err != nil {
|
||||
t.Fatalf("WriteField: %v", err)
|
||||
}
|
||||
}
|
||||
for name, f := range files {
|
||||
h := make(map[string][]string)
|
||||
h["Content-Disposition"] = []string{
|
||||
`form-data; name="` + name + `"; filename="` + f.Filename + `"`,
|
||||
}
|
||||
ct := f.ContentType
|
||||
if ct == "" {
|
||||
ct = "application/octet-stream"
|
||||
}
|
||||
h["Content-Type"] = []string{ct}
|
||||
part, err := w.CreatePart(h)
|
||||
if err != nil {
|
||||
t.Fatalf("CreatePart: %v", err)
|
||||
}
|
||||
if _, err := part.Write(f.Content); err != nil {
|
||||
t.Fatalf("part.Write: %v", err)
|
||||
}
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
t.Fatalf("writer.Close: %v", err)
|
||||
}
|
||||
return buf.Bytes(), w.FormDataContentType()
|
||||
}
|
||||
|
||||
func TestUploadObject_Success(t *testing.T) {
|
||||
app, s3 := newObjectsTestApp(t)
|
||||
s3.UploadObjectFn = func(_ context.Context, bucket, key string, body io.Reader, ct string) (*models.ObjectUploadResponse, error) {
|
||||
if bucket != "b1" || key != "myfile.bin" {
|
||||
t.Errorf("args = (%q, %q)", bucket, key)
|
||||
}
|
||||
if ct != "application/octet-stream" {
|
||||
t.Errorf("contentType = %q", ct)
|
||||
}
|
||||
b, _ := io.ReadAll(body)
|
||||
if string(b) != "payload" {
|
||||
t.Errorf("body = %q, want 'payload'", b)
|
||||
}
|
||||
return &models.ObjectUploadResponse{Bucket: bucket, Key: key, Size: int64(len(b))}, nil
|
||||
}
|
||||
body, ct := buildMultipart(t, nil, map[string]struct {
|
||||
Filename string
|
||||
Content []byte
|
||||
ContentType string
|
||||
}{
|
||||
"file": {Filename: "myfile.bin", Content: []byte("payload")},
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", ct)
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("status = %d, want 201\nbody: %s", resp.StatusCode, raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadObject_ExplicitKeyOverridesFilename(t *testing.T) {
|
||||
app, s3 := newObjectsTestApp(t)
|
||||
s3.UploadObjectFn = func(_ context.Context, _, key string, _ io.Reader, _ string) (*models.ObjectUploadResponse, error) {
|
||||
if key != "custom/key.txt" {
|
||||
t.Errorf("key = %q, want custom/key.txt", key)
|
||||
}
|
||||
return &models.ObjectUploadResponse{Key: key}, nil
|
||||
}
|
||||
body, ct := buildMultipart(t, map[string]string{"key": "custom/key.txt"}, map[string]struct {
|
||||
Filename string
|
||||
Content []byte
|
||||
ContentType string
|
||||
}{
|
||||
"file": {Filename: "whatever.txt", Content: []byte("x")},
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", ct)
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("status = %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadObject_MissingFileReturns400(t *testing.T) {
|
||||
app, _ := newObjectsTestApp(t)
|
||||
body, ct := buildMultipart(t, map[string]string{"key": "k"}, nil)
|
||||
req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", ct)
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadObject_ServiceError500(t *testing.T) {
|
||||
app, s3 := newObjectsTestApp(t)
|
||||
s3.UploadObjectFn = func(_ context.Context, _, _ string, _ io.Reader, _ string) (*models.ObjectUploadResponse, error) {
|
||||
return nil, errors.New("boom")
|
||||
}
|
||||
body, ct := buildMultipart(t, nil, map[string]struct {
|
||||
Filename string
|
||||
Content []byte
|
||||
ContentType string
|
||||
}{"file": {Filename: "f.bin", Content: []byte("x")}})
|
||||
req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", ct)
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want 500", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteMultipleObjects_Success(t *testing.T) {
|
||||
app, s3 := newObjectsTestApp(t)
|
||||
s3.DeleteMultipleObjectsFn = func(_ context.Context, bucket string, keys []string) error {
|
||||
if bucket != "b1" || len(keys) != 3 {
|
||||
t.Errorf("args = (%q, %v)", bucket, keys)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
body, _ := json.Marshal(map[string]any{"keys": []string{"a", "b", "c"}})
|
||||
req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects/delete-multiple", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d", resp.StatusCode)
|
||||
}
|
||||
var out struct {
|
||||
Data models.ObjectDeleteMultipleResponse `json:"data"`
|
||||
}
|
||||
decodeJSON(t, resp.Body, &out)
|
||||
if out.Data.Deleted != 3 {
|
||||
t.Errorf("Deleted = %d, want 3", out.Data.Deleted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteMultipleObjects_EmptyKeys400(t *testing.T) {
|
||||
app, _ := newObjectsTestApp(t)
|
||||
body, _ := json.Marshal(map[string]any{"keys": []string{}})
|
||||
req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects/delete-multiple", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteMultipleObjects_MalformedJSON400(t *testing.T) {
|
||||
app, _ := newObjectsTestApp(t)
|
||||
req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects/delete-multiple", strings.NewReader("{not-json"))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteMultipleObjects_ServiceError500(t *testing.T) {
|
||||
app, s3 := newObjectsTestApp(t)
|
||||
s3.DeleteMultipleObjectsFn = func(_ context.Context, _ string, _ []string) error { return errors.New("boom") }
|
||||
body, _ := json.Marshal(map[string]any{"keys": []string{"a"}})
|
||||
req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects/delete-multiple", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// buildMultipartMulti builds a body with N "files" parts.
|
||||
func buildMultipartMulti(t *testing.T, files []struct {
|
||||
Filename, ContentType string
|
||||
Content []byte
|
||||
}) ([]byte, string) {
|
||||
t.Helper()
|
||||
buf := &bytes.Buffer{}
|
||||
w := multipart.NewWriter(buf)
|
||||
for _, f := range files {
|
||||
h := map[string][]string{
|
||||
"Content-Disposition": {`form-data; name="files"; filename="` + f.Filename + `"`},
|
||||
"Content-Type": {f.ContentType},
|
||||
}
|
||||
part, err := w.CreatePart(h)
|
||||
if err != nil {
|
||||
t.Fatalf("CreatePart: %v", err)
|
||||
}
|
||||
_, _ = part.Write(f.Content)
|
||||
}
|
||||
_ = w.Close()
|
||||
return buf.Bytes(), w.FormDataContentType()
|
||||
}
|
||||
|
||||
func TestUploadMultiple_AllSuccess201(t *testing.T) {
|
||||
app, s3 := newObjectsTestApp(t)
|
||||
s3.UploadMultipleObjectsFn = func(_ context.Context, bucket string, files []struct {
|
||||
Key string
|
||||
Body io.Reader
|
||||
ContentType string
|
||||
}) []services.UploadResult {
|
||||
if bucket != "b1" || len(files) != 2 {
|
||||
t.Errorf("got bucket=%q files=%d", bucket, len(files))
|
||||
}
|
||||
out := make([]services.UploadResult, len(files))
|
||||
for i, f := range files {
|
||||
out[i] = services.UploadResult{Key: f.Key, Success: true, ContentType: f.ContentType, Size: 1}
|
||||
}
|
||||
return out
|
||||
}
|
||||
body, ct := buildMultipartMulti(t, []struct {
|
||||
Filename, ContentType string
|
||||
Content []byte
|
||||
}{
|
||||
{"a.txt", "text/plain", []byte("a")},
|
||||
{"b.txt", "text/plain", []byte("b")},
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects/upload-multiple", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", ct)
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("status = %d, want 201\nbody: %s", resp.StatusCode, raw)
|
||||
}
|
||||
var out struct {
|
||||
Data models.ObjectUploadMultipleResponse `json:"data"`
|
||||
}
|
||||
decodeJSON(t, resp.Body, &out)
|
||||
if out.Data.SuccessCount != 2 || out.Data.FailureCount != 0 {
|
||||
t.Errorf("counts = (%d, %d)", out.Data.SuccessCount, out.Data.FailureCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadMultiple_PartialReturns207(t *testing.T) {
|
||||
app, s3 := newObjectsTestApp(t)
|
||||
s3.UploadMultipleObjectsFn = func(_ context.Context, _ string, files []struct {
|
||||
Key string
|
||||
Body io.Reader
|
||||
ContentType string
|
||||
}) []services.UploadResult {
|
||||
return []services.UploadResult{
|
||||
{Key: files[0].Key, Success: true, Size: 1, ContentType: files[0].ContentType},
|
||||
{Key: files[1].Key, Success: false, Error: errors.New("upload failed"), ContentType: files[1].ContentType},
|
||||
}
|
||||
}
|
||||
body, ct := buildMultipartMulti(t, []struct {
|
||||
Filename, ContentType string
|
||||
Content []byte
|
||||
}{
|
||||
{"a.txt", "text/plain", []byte("a")},
|
||||
{"b.txt", "text/plain", []byte("b")},
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects/upload-multiple", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", ct)
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusMultiStatus {
|
||||
t.Fatalf("status = %d, want 207", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadMultiple_AllFailReturns500(t *testing.T) {
|
||||
app, s3 := newObjectsTestApp(t)
|
||||
s3.UploadMultipleObjectsFn = func(_ context.Context, _ string, files []struct {
|
||||
Key string
|
||||
Body io.Reader
|
||||
ContentType string
|
||||
}) []services.UploadResult {
|
||||
out := make([]services.UploadResult, len(files))
|
||||
for i, f := range files {
|
||||
out[i] = services.UploadResult{Key: f.Key, Success: false, Error: errors.New("boom")}
|
||||
}
|
||||
return out
|
||||
}
|
||||
body, ct := buildMultipartMulti(t, []struct {
|
||||
Filename, ContentType string
|
||||
Content []byte
|
||||
}{{"a.txt", "text/plain", []byte("a")}})
|
||||
req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects/upload-multiple", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", ct)
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want 500", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadMultiple_NoFiles400(t *testing.T) {
|
||||
app, _ := newObjectsTestApp(t)
|
||||
body, ct := buildMultipartMulti(t, nil)
|
||||
req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects/upload-multiple", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", ct)
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadMultiple_DefaultsContentType(t *testing.T) {
|
||||
app, s3 := newObjectsTestApp(t)
|
||||
s3.UploadMultipleObjectsFn = func(_ context.Context, _ string, files []struct {
|
||||
Key string
|
||||
Body io.Reader
|
||||
ContentType string
|
||||
}) []services.UploadResult {
|
||||
if files[0].ContentType != "application/octet-stream" {
|
||||
t.Errorf("ContentType = %q, want default application/octet-stream", files[0].ContentType)
|
||||
}
|
||||
return []services.UploadResult{{Key: files[0].Key, Success: true}}
|
||||
}
|
||||
// Write a part with an empty Content-Type header explicitly.
|
||||
buf := &bytes.Buffer{}
|
||||
w := multipart.NewWriter(buf)
|
||||
part, err := w.CreatePart(map[string][]string{
|
||||
"Content-Disposition": {`form-data; name="files"; filename="a.txt"`},
|
||||
"Content-Type": {""},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreatePart: %v", err)
|
||||
}
|
||||
_, _ = part.Write([]byte("x"))
|
||||
_ = w.Close()
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects/upload-multiple", bytes.NewReader(buf.Bytes()))
|
||||
req.Header.Set("Content-Type", w.FormDataContentType())
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("status = %d, want 201", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
@@ -9,13 +9,13 @@ import (
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
// UserHandler handles user/key management operations using Garage Admin API
|
||||
// UserHandler handles user and access key HTTP requests.
|
||||
type UserHandler struct {
|
||||
adminService *services.GarageAdminService
|
||||
adminService services.AdminService
|
||||
}
|
||||
|
||||
// NewUserHandler creates a new user handler
|
||||
func NewUserHandler(adminService *services.GarageAdminService) *UserHandler {
|
||||
// NewUserHandler creates a new user handler.
|
||||
func NewUserHandler(adminService services.AdminService) *UserHandler {
|
||||
return &UserHandler{
|
||||
adminService: adminService,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"Noooste/garage-ui/internal/models"
|
||||
"Noooste/garage-ui/internal/services/mocks"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
func newUsersTestApp(t *testing.T) (*fiber.App, *mocks.AdminMock) {
|
||||
t.Helper()
|
||||
admin := &mocks.AdminMock{}
|
||||
h := NewUserHandler(admin)
|
||||
app := fiber.New()
|
||||
app.Get("/users", h.ListUsers)
|
||||
app.Post("/users", h.CreateUser)
|
||||
app.Get("/users/:access_key", h.GetUser)
|
||||
app.Get("/users/:access_key/secret", h.GetUserSecretKey)
|
||||
app.Delete("/users/:access_key", h.DeleteUser)
|
||||
app.Patch("/users/:access_key", h.UpdateUserPermissions)
|
||||
return app, admin
|
||||
}
|
||||
|
||||
// --- ListUsers ---
|
||||
|
||||
func TestListUsers_MapsAndSkipsFailed(t *testing.T) {
|
||||
app, admin := newUsersTestApp(t)
|
||||
admin.ListKeysFn = func(_ context.Context) ([]models.ListKeysResponseItem, error) {
|
||||
return []models.ListKeysResponseItem{
|
||||
{ID: "AKIA-1", Name: "one"},
|
||||
{ID: "AKIA-2", Name: "bad-detail"},
|
||||
{ID: "AKIA-3", Name: "three"},
|
||||
}, nil
|
||||
}
|
||||
admin.GetKeyInfoFn = func(_ context.Context, id string, showSecret bool) (*models.GarageKeyInfo, error) {
|
||||
if showSecret {
|
||||
t.Error("ListUsers must not request secret")
|
||||
}
|
||||
if id == "AKIA-2" {
|
||||
return nil, errors.New("forbidden")
|
||||
}
|
||||
return &models.GarageKeyInfo{AccessKeyID: id, Name: id, Expired: false}, nil
|
||||
}
|
||||
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/users", nil))
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d", resp.StatusCode)
|
||||
}
|
||||
var body struct {
|
||||
Data models.UserListResponse `json:"data"`
|
||||
}
|
||||
decodeJSON(t, resp.Body, &body)
|
||||
if body.Data.Count != 2 {
|
||||
t.Errorf("count = %d, want 2 (failed detail skipped)", body.Data.Count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListUsers_ListError500(t *testing.T) {
|
||||
app, admin := newUsersTestApp(t)
|
||||
admin.ListKeysFn = func(_ context.Context) ([]models.ListKeysResponseItem, error) {
|
||||
return nil, errors.New("boom")
|
||||
}
|
||||
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/users", nil))
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want 500", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// --- CreateUser ---
|
||||
|
||||
func TestCreateUser_Success201(t *testing.T) {
|
||||
app, admin := newUsersTestApp(t)
|
||||
admin.CreateKeyFn = func(_ context.Context, req models.CreateKeyRequest) (*models.GarageKeyInfo, error) {
|
||||
if req.Name == nil || *req.Name != "alice" {
|
||||
t.Errorf("Name = %v", req.Name)
|
||||
}
|
||||
sk := "secret-xyz"
|
||||
return &models.GarageKeyInfo{AccessKeyID: "AKIA-1", Name: "alice", SecretAccessKey: &sk}, nil
|
||||
}
|
||||
body, _ := json.Marshal(models.CreateUserRequest{Name: "alice"})
|
||||
req := httptest.NewRequest(http.MethodPost, "/users", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("status = %d, want 201", resp.StatusCode)
|
||||
}
|
||||
var decoded struct {
|
||||
Data models.UserInfo `json:"data"`
|
||||
}
|
||||
decodeJSON(t, resp.Body, &decoded)
|
||||
if decoded.Data.SecretKey == nil || *decoded.Data.SecretKey != "secret-xyz" {
|
||||
t.Errorf("SecretKey = %v, want secret-xyz", decoded.Data.SecretKey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateUser_MalformedJSON400(t *testing.T) {
|
||||
app, _ := newUsersTestApp(t)
|
||||
req := httptest.NewRequest(http.MethodPost, "/users", strings.NewReader("{not-json"))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateUser_AdminError500(t *testing.T) {
|
||||
app, admin := newUsersTestApp(t)
|
||||
admin.CreateKeyFn = func(_ context.Context, _ models.CreateKeyRequest) (*models.GarageKeyInfo, error) {
|
||||
return nil, errors.New("boom")
|
||||
}
|
||||
body, _ := json.Marshal(models.CreateUserRequest{})
|
||||
req := httptest.NewRequest(http.MethodPost, "/users", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want 500", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// --- GetUser ---
|
||||
|
||||
func TestGetUser_Success(t *testing.T) {
|
||||
app, admin := newUsersTestApp(t)
|
||||
admin.GetKeyInfoFn = func(_ context.Context, id string, showSecret bool) (*models.GarageKeyInfo, error) {
|
||||
if showSecret {
|
||||
t.Error("GetUser must not request secret")
|
||||
}
|
||||
return &models.GarageKeyInfo{AccessKeyID: id, Name: "alice"}, nil
|
||||
}
|
||||
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/users/AKIA-1", nil))
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUser_ServiceError500(t *testing.T) {
|
||||
app, admin := newUsersTestApp(t)
|
||||
admin.GetKeyInfoFn = func(_ context.Context, _ string, _ bool) (*models.GarageKeyInfo, error) {
|
||||
return nil, errors.New("boom")
|
||||
}
|
||||
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/users/AKIA-1", nil))
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want 500", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// --- GetUserSecretKey ---
|
||||
|
||||
func TestGetUserSecretKey_Success(t *testing.T) {
|
||||
app, admin := newUsersTestApp(t)
|
||||
admin.GetKeyInfoFn = func(_ context.Context, id string, showSecret bool) (*models.GarageKeyInfo, error) {
|
||||
if !showSecret {
|
||||
t.Error("GetUserSecretKey must request secret")
|
||||
}
|
||||
sk := "s3cr3t"
|
||||
return &models.GarageKeyInfo{AccessKeyID: id, SecretAccessKey: &sk}, nil
|
||||
}
|
||||
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/users/AKIA-1/secret", nil))
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d", resp.StatusCode)
|
||||
}
|
||||
var body struct {
|
||||
Data map[string]string `json:"data"`
|
||||
}
|
||||
decodeJSON(t, resp.Body, &body)
|
||||
if body.Data["secretKey"] != "s3cr3t" {
|
||||
t.Errorf("secretKey = %q", body.Data["secretKey"])
|
||||
}
|
||||
}
|
||||
|
||||
// --- DeleteUser ---
|
||||
|
||||
func TestDeleteUser_Success(t *testing.T) {
|
||||
app, admin := newUsersTestApp(t)
|
||||
admin.DeleteKeyFn = func(_ context.Context, id string) error {
|
||||
if id != "AKIA-1" {
|
||||
t.Errorf("id = %q", id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
resp, err := app.Test(httptest.NewRequest(http.MethodDelete, "/users/AKIA-1", nil))
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteUser_ServiceError500(t *testing.T) {
|
||||
app, admin := newUsersTestApp(t)
|
||||
admin.DeleteKeyFn = func(_ context.Context, _ string) error { return errors.New("boom") }
|
||||
resp, err := app.Test(httptest.NewRequest(http.MethodDelete, "/users/AKIA-1", nil))
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want 500", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// --- UpdateUserPermissions ---
|
||||
|
||||
func TestUpdateUser_StatusInactiveSetsPastExpiration(t *testing.T) {
|
||||
app, admin := newUsersTestApp(t)
|
||||
admin.UpdateKeyFn = func(_ context.Context, _ string, req models.UpdateKeyRequest) (*models.GarageKeyInfo, error) {
|
||||
if req.NeverExpires {
|
||||
t.Error("NeverExpires should be false when deactivating")
|
||||
}
|
||||
if req.Expiration == nil || !req.Expiration.Before(time.Now()) {
|
||||
t.Errorf("Expiration = %v, want past time", req.Expiration)
|
||||
}
|
||||
return &models.GarageKeyInfo{}, nil
|
||||
}
|
||||
status := "inactive"
|
||||
body, _ := json.Marshal(models.UpdateUserRequest{Status: &status})
|
||||
req := httptest.NewRequest(http.MethodPatch, "/users/AKIA-1", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateUser_StatusActiveSetsNeverExpires(t *testing.T) {
|
||||
app, admin := newUsersTestApp(t)
|
||||
admin.UpdateKeyFn = func(_ context.Context, _ string, req models.UpdateKeyRequest) (*models.GarageKeyInfo, error) {
|
||||
if !req.NeverExpires {
|
||||
t.Error("NeverExpires should be true when activating")
|
||||
}
|
||||
return &models.GarageKeyInfo{}, nil
|
||||
}
|
||||
status := "active"
|
||||
body, _ := json.Marshal(models.UpdateUserRequest{Status: &status})
|
||||
req := httptest.NewRequest(http.MethodPatch, "/users/AKIA-1", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateUser_ExplicitExpiration(t *testing.T) {
|
||||
app, admin := newUsersTestApp(t)
|
||||
wantTime, _ := time.Parse(time.RFC3339, "2030-01-02T03:04:05Z")
|
||||
admin.UpdateKeyFn = func(_ context.Context, _ string, req models.UpdateKeyRequest) (*models.GarageKeyInfo, error) {
|
||||
if req.Expiration == nil || !req.Expiration.Equal(wantTime) {
|
||||
t.Errorf("Expiration = %v, want %v", req.Expiration, wantTime)
|
||||
}
|
||||
if req.NeverExpires {
|
||||
t.Error("NeverExpires should be false with explicit expiration")
|
||||
}
|
||||
return &models.GarageKeyInfo{}, nil
|
||||
}
|
||||
exp := "2030-01-02T03:04:05Z"
|
||||
body, _ := json.Marshal(models.UpdateUserRequest{Expiration: &exp})
|
||||
req := httptest.NewRequest(http.MethodPatch, "/users/AKIA-1", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateUser_BadExpirationFormat400(t *testing.T) {
|
||||
app, _ := newUsersTestApp(t)
|
||||
exp := "not-a-date"
|
||||
body, _ := json.Marshal(models.UpdateUserRequest{Expiration: &exp})
|
||||
req := httptest.NewRequest(http.MethodPatch, "/users/AKIA-1", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateUser_MalformedJSON400(t *testing.T) {
|
||||
app, _ := newUsersTestApp(t)
|
||||
req := httptest.NewRequest(http.MethodPatch, "/users/AKIA-1", strings.NewReader("{not-json"))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateUser_AdminError500(t *testing.T) {
|
||||
app, admin := newUsersTestApp(t)
|
||||
admin.UpdateKeyFn = func(_ context.Context, _ string, _ models.UpdateKeyRequest) (*models.GarageKeyInfo, error) {
|
||||
return nil, errors.New("boom")
|
||||
}
|
||||
status := "active"
|
||||
body, _ := json.Marshal(models.UpdateUserRequest{Status: &status})
|
||||
req := httptest.NewRequest(http.MethodPatch, "/users/AKIA-1", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want 500", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
@@ -4,98 +4,90 @@ import (
|
||||
"Noooste/garage-ui/internal/auth"
|
||||
"Noooste/garage-ui/internal/config"
|
||||
"Noooste/garage-ui/internal/models"
|
||||
logpkg "Noooste/garage-ui/pkg/logger"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
// AuthMiddleware supports admin and OIDC authentication
|
||||
func AuthMiddleware(cfg *config.AuthConfig, authService *auth.AuthService) fiber.Handler {
|
||||
// AuthMiddleware supports admin and OIDC authentication. On success it
|
||||
// enriches the per-request logger (stored in c.Context() by the Logging
|
||||
// middleware) with user_id and auth_method so downstream service log lines
|
||||
// carry user identity. On failure it emits a warn log with the auth_method
|
||||
// tried and a reason — never the token value.
|
||||
func AuthMiddleware(cfg *config.AuthConfig, authService *auth.Service) fiber.Handler {
|
||||
return func(c fiber.Ctx) error {
|
||||
// If no auth is enabled, allow all requests
|
||||
// If no auth is enabled, allow all requests.
|
||||
if !cfg.Admin.Enabled && !cfg.OIDC.Enabled {
|
||||
return c.Next()
|
||||
}
|
||||
|
||||
// Get Authorization header
|
||||
authHeader := c.Get("Authorization")
|
||||
|
||||
// Try admin auth if enabled and header is present
|
||||
// Try admin auth if enabled and header is present.
|
||||
if cfg.Admin.Enabled && authHeader != "" {
|
||||
// Check if it's a Bearer token (JWT from admin login)
|
||||
if len(authHeader) > 7 && authHeader[:7] == "Bearer " {
|
||||
token := authHeader[7:]
|
||||
|
||||
// Validate JWT session token
|
||||
userInfo, err := authService.ValidateSessionToken(token)
|
||||
if err == nil {
|
||||
// Valid admin token
|
||||
c.Locals("userInfo", userInfo)
|
||||
c.Locals("username", userInfo.Username)
|
||||
if userInfo.Email != "" {
|
||||
c.Locals("email", userInfo.Email)
|
||||
}
|
||||
enrichRequestLogger(c, userInfo.Username, "admin")
|
||||
return c.Next()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try OIDC auth if enabled
|
||||
// Try OIDC auth if enabled.
|
||||
if cfg.OIDC.Enabled {
|
||||
sessionCookie := c.Cookies(cfg.OIDC.CookieName)
|
||||
if sessionCookie != "" {
|
||||
// Validate JWT session token from cookie
|
||||
userInfo, err := authService.ValidateSessionToken(sessionCookie)
|
||||
if err == nil {
|
||||
// Valid OIDC token
|
||||
c.Locals("userInfo", userInfo)
|
||||
c.Locals("username", userInfo.Username)
|
||||
c.Locals("email", userInfo.Email)
|
||||
enrichRequestLogger(c, userInfo.Username, "oidc")
|
||||
return c.Next()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No valid authentication found
|
||||
// Auth failed — log at warn without exposing token material.
|
||||
logpkg.FromCtx(c.Context()).Warn().
|
||||
Str("auth_method", authMethodsEnabled(cfg)).
|
||||
Str("reason", "no_valid_credentials").
|
||||
Msg("authentication_failed")
|
||||
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(
|
||||
models.ErrorResponse(models.ErrCodeUnauthorized, "Authentication required"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func RequireAuth(cfg *config.AuthConfig) fiber.Handler {
|
||||
return func(c fiber.Ctx) error {
|
||||
if !cfg.Admin.Enabled && !cfg.OIDC.Enabled {
|
||||
return c.Status(fiber.StatusForbidden).JSON(
|
||||
models.ErrorResponse(models.ErrCodeForbidden, "Authentication is required but not configured"),
|
||||
)
|
||||
}
|
||||
return c.Next()
|
||||
}
|
||||
// enrichRequestLogger rebinds the per-request logger in c.Context() with
|
||||
// user_id and auth_method. Subsequent logpkg.FromCtx(c.Context()) calls
|
||||
// return the enriched logger.
|
||||
func enrichRequestLogger(c fiber.Ctx, userID, authMethod string) {
|
||||
l := logpkg.FromCtx(c.Context()).With().
|
||||
Str("user_id", userID).
|
||||
Str("auth_method", authMethod).
|
||||
Logger()
|
||||
c.Locals(LoggerLocalsKey, l)
|
||||
c.SetContext(logpkg.IntoCtx(c.Context(), l))
|
||||
}
|
||||
|
||||
func RequireAdmin(authService *auth.AuthService) fiber.Handler {
|
||||
return func(c fiber.Ctx) error {
|
||||
userInfoInterface := c.Locals("userInfo")
|
||||
if userInfoInterface == nil {
|
||||
return c.Status(fiber.StatusForbidden).JSON(
|
||||
models.ErrorResponse(models.ErrCodeForbidden, "Admin access required"),
|
||||
)
|
||||
}
|
||||
|
||||
userInfo, ok := userInfoInterface.(*auth.UserInfo)
|
||||
if !ok {
|
||||
return c.Status(fiber.StatusForbidden).JSON(
|
||||
models.ErrorResponse(models.ErrCodeForbidden, "Admin access required"),
|
||||
)
|
||||
}
|
||||
|
||||
// Check if user has admin role
|
||||
if !authService.IsAdmin(userInfo) {
|
||||
return c.Status(fiber.StatusForbidden).JSON(
|
||||
models.ErrorResponse(models.ErrCodeForbidden, "Admin role required"),
|
||||
)
|
||||
}
|
||||
|
||||
return c.Next()
|
||||
func authMethodsEnabled(cfg *config.AuthConfig) string {
|
||||
switch {
|
||||
case cfg.Admin.Enabled && cfg.OIDC.Enabled:
|
||||
return "admin+oidc"
|
||||
case cfg.Admin.Enabled:
|
||||
return "admin"
|
||||
case cfg.OIDC.Enabled:
|
||||
return "oidc"
|
||||
default:
|
||||
return "none"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,403 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"Noooste/garage-ui/internal/auth"
|
||||
"Noooste/garage-ui/internal/config"
|
||||
logpkg "Noooste/garage-ui/pkg/logger"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
// newAuthTestApp builds a fiber.App with RequestID + Logging (buffer-backed) +
|
||||
// AuthMiddleware + a trivial /protected handler that echoes username/auth
|
||||
// method into the JSON body so tests can assert on locals.
|
||||
func newAuthTestApp(t *testing.T, buf *bytes.Buffer, authCfg *config.AuthConfig, svc *auth.Service) *fiber.App {
|
||||
t.Helper()
|
||||
base := zerolog.New(buf)
|
||||
app := fiber.New()
|
||||
app.Use(RequestID())
|
||||
app.Use(Logging(base))
|
||||
app.Use(AuthMiddleware(authCfg, svc))
|
||||
app.Get("/protected", func(c fiber.Ctx) error {
|
||||
uname, _ := c.Locals("username").(string)
|
||||
email, _ := c.Locals("email").(string)
|
||||
logpkg.FromCtx(c.Context()).Info().Msg("in_handler")
|
||||
return c.JSON(fiber.Map{
|
||||
"ok": true,
|
||||
"username": uname,
|
||||
"email": email,
|
||||
})
|
||||
})
|
||||
return app
|
||||
}
|
||||
|
||||
// newAuthSvc returns an *auth.Service with the given auth config, JWT
|
||||
// service initialized, OIDC disabled unless the caller wires it.
|
||||
func newAuthSvc(t *testing.T, authCfg *config.AuthConfig) *auth.Service {
|
||||
t.Helper()
|
||||
svc, err := auth.NewAuthService(authCfg, &config.ServerConfig{})
|
||||
if err != nil {
|
||||
t.Fatalf("NewAuthService: %v", err)
|
||||
}
|
||||
return svc
|
||||
}
|
||||
|
||||
// findLine returns the first parsed log line whose "message" field equals msg,
|
||||
// failing the test if no such line is present.
|
||||
func findLine(t *testing.T, buf *bytes.Buffer, msg string) map[string]any {
|
||||
t.Helper()
|
||||
for _, line := range parseLines(t, buf) {
|
||||
if line["message"] == msg {
|
||||
return line
|
||||
}
|
||||
}
|
||||
t.Fatalf("no %q log line: %s", msg, buf.String())
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestAuthMiddleware_BothDisabled_AllowsRequest(t *testing.T) {
|
||||
authCfg := &config.AuthConfig{
|
||||
Admin: config.AdminAuthConfig{Enabled: false},
|
||||
OIDC: config.OIDCConfig{Enabled: false},
|
||||
}
|
||||
svc := newAuthSvc(t, authCfg)
|
||||
|
||||
var buf bytes.Buffer
|
||||
app := newAuthTestApp(t, &buf, authCfg, svc)
|
||||
|
||||
req := httptest.NewRequest("GET", "/protected", nil)
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if body["ok"] != true {
|
||||
t.Errorf("ok = %v, want true", body["ok"])
|
||||
}
|
||||
if body["username"] != "" {
|
||||
t.Errorf("username should be empty when auth disabled, got %q", body["username"])
|
||||
}
|
||||
}
|
||||
|
||||
func newAdminCfg() *config.AuthConfig {
|
||||
return &config.AuthConfig{
|
||||
Admin: config.AdminAuthConfig{Enabled: true, Username: "admin", Password: "pw"},
|
||||
OIDC: config.OIDCConfig{Enabled: false},
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthMiddleware_Admin_BearerValid_AllowsAndEnrichesLogger(t *testing.T) {
|
||||
authCfg := newAdminCfg()
|
||||
svc := newAuthSvc(t, authCfg)
|
||||
tok, err := svc.GenerateSessionToken(&auth.UserInfo{Username: "admin", Email: "a@b"})
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateSessionToken: %v", err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
app := newAuthTestApp(t, &buf, authCfg, svc)
|
||||
|
||||
req := httptest.NewRequest("GET", "/protected", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+tok)
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
var body map[string]any
|
||||
_ = json.NewDecoder(resp.Body).Decode(&body)
|
||||
if body["username"] != "admin" {
|
||||
t.Errorf("username = %v, want admin", body["username"])
|
||||
}
|
||||
if body["email"] != "a@b" {
|
||||
t.Errorf("email = %v, want a@b", body["email"])
|
||||
}
|
||||
|
||||
// Enriched handler log line should carry user_id and auth_method=admin.
|
||||
access := findLine(t, &buf, "in_handler")
|
||||
if access["user_id"] != "admin" {
|
||||
t.Errorf("user_id = %v, want admin", access["user_id"])
|
||||
}
|
||||
if access["auth_method"] != "admin" {
|
||||
t.Errorf("auth_method = %v, want admin", access["auth_method"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthMiddleware_Admin_BearerInvalid_Returns401(t *testing.T) {
|
||||
authCfg := newAdminCfg()
|
||||
svc := newAuthSvc(t, authCfg)
|
||||
|
||||
var buf bytes.Buffer
|
||||
app := newAuthTestApp(t, &buf, authCfg, svc)
|
||||
|
||||
req := httptest.NewRequest("GET", "/protected", nil)
|
||||
req.Header.Set("Authorization", "Bearer not-a-real-token")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
if resp.StatusCode != 401 {
|
||||
t.Fatalf("status = %d, want 401", resp.StatusCode)
|
||||
}
|
||||
var env struct {
|
||||
Success bool `json:"success"`
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
} `json:"error"`
|
||||
}
|
||||
_ = json.NewDecoder(resp.Body).Decode(&env)
|
||||
if env.Success {
|
||||
t.Error("success should be false")
|
||||
}
|
||||
if env.Error.Code != "UNAUTHORIZED" {
|
||||
t.Errorf("error.code = %q, want UNAUTHORIZED", env.Error.Code)
|
||||
}
|
||||
|
||||
// Warn log should carry reason=no_valid_credentials without leaking the token.
|
||||
warn := findLine(t, &buf, "authentication_failed")
|
||||
if warn["reason"] != "no_valid_credentials" {
|
||||
t.Errorf("reason = %v", warn["reason"])
|
||||
}
|
||||
if warn["level"] != "warn" {
|
||||
t.Errorf("level = %v, want warn", warn["level"])
|
||||
}
|
||||
if strings.Contains(buf.String(), "not-a-real-token") {
|
||||
t.Error("token value must not appear in logs")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthMiddleware_Admin_NoAuthHeader_Returns401(t *testing.T) {
|
||||
authCfg := newAdminCfg()
|
||||
svc := newAuthSvc(t, authCfg)
|
||||
|
||||
var buf bytes.Buffer
|
||||
app := newAuthTestApp(t, &buf, authCfg, svc)
|
||||
|
||||
req := httptest.NewRequest("GET", "/protected", nil)
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
if resp.StatusCode != 401 {
|
||||
t.Fatalf("status = %d, want 401", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthMiddleware_Admin_NonBearerScheme_Returns401(t *testing.T) {
|
||||
authCfg := newAdminCfg()
|
||||
svc := newAuthSvc(t, authCfg)
|
||||
|
||||
var buf bytes.Buffer
|
||||
app := newAuthTestApp(t, &buf, authCfg, svc)
|
||||
|
||||
req := httptest.NewRequest("GET", "/protected", nil)
|
||||
req.Header.Set("Authorization", "Basic dXNlcjpwdw==")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
if resp.StatusCode != 401 {
|
||||
t.Fatalf("status = %d, want 401", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func newOIDCCfg(cookieName string) *config.AuthConfig {
|
||||
return &config.AuthConfig{
|
||||
Admin: config.AdminAuthConfig{Enabled: false},
|
||||
OIDC: config.OIDCConfig{
|
||||
Enabled: true,
|
||||
CookieName: cookieName,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// newOIDCSvc returns a Service whose OIDC is *not* initialized (no dialing
|
||||
// needed), which is fine because AuthMiddleware's OIDC branch only calls
|
||||
// ValidateSessionToken — a pure JWT operation.
|
||||
func newOIDCSvc(t *testing.T) *auth.Service {
|
||||
t.Helper()
|
||||
return newAuthSvc(t, &config.AuthConfig{OIDC: config.OIDCConfig{Enabled: false}})
|
||||
}
|
||||
|
||||
func TestAuthMiddleware_OIDC_ValidCookie_Allows(t *testing.T) {
|
||||
cfg := newOIDCCfg("session")
|
||||
svc := newOIDCSvc(t)
|
||||
|
||||
tok, err := svc.GenerateSessionToken(&auth.UserInfo{Username: "alice", Email: "a@x"})
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateSessionToken: %v", err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
app := newAuthTestApp(t, &buf, cfg, svc)
|
||||
|
||||
req := httptest.NewRequest("GET", "/protected", nil)
|
||||
req.AddCookie(&http.Cookie{Name: "session", Value: tok})
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
|
||||
access := findLine(t, &buf, "in_handler")
|
||||
if access["auth_method"] != "oidc" {
|
||||
t.Errorf("auth_method = %v, want oidc", access["auth_method"])
|
||||
}
|
||||
if access["user_id"] != "alice" {
|
||||
t.Errorf("user_id = %v, want alice", access["user_id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthMiddleware_OIDC_InvalidCookie_Returns401(t *testing.T) {
|
||||
cfg := newOIDCCfg("session")
|
||||
svc := newOIDCSvc(t)
|
||||
|
||||
var buf bytes.Buffer
|
||||
app := newAuthTestApp(t, &buf, cfg, svc)
|
||||
|
||||
req := httptest.NewRequest("GET", "/protected", nil)
|
||||
req.AddCookie(&http.Cookie{Name: "session", Value: "garbage"})
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
if resp.StatusCode != 401 {
|
||||
t.Fatalf("status = %d, want 401", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthMiddleware_OIDC_NoCookie_Returns401(t *testing.T) {
|
||||
cfg := newOIDCCfg("session")
|
||||
svc := newOIDCSvc(t)
|
||||
|
||||
var buf bytes.Buffer
|
||||
app := newAuthTestApp(t, &buf, cfg, svc)
|
||||
|
||||
req := httptest.NewRequest("GET", "/protected", nil)
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
if resp.StatusCode != 401 {
|
||||
t.Fatalf("status = %d, want 401", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func newBothCfg(cookieName string) *config.AuthConfig {
|
||||
return &config.AuthConfig{
|
||||
Admin: config.AdminAuthConfig{Enabled: true, Username: "admin", Password: "pw"},
|
||||
OIDC: config.OIDCConfig{
|
||||
Enabled: true,
|
||||
CookieName: cookieName,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthMiddleware_Both_BearerValid_AdminPathWins(t *testing.T) {
|
||||
cfg := newBothCfg("session")
|
||||
// OIDC disabled on the Service is fine — see Task 3 rationale.
|
||||
svc := newAuthSvc(t, &config.AuthConfig{Admin: cfg.Admin})
|
||||
|
||||
tok, err := svc.GenerateSessionToken(&auth.UserInfo{Username: "admin"})
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateSessionToken: %v", err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
app := newAuthTestApp(t, &buf, cfg, svc)
|
||||
|
||||
req := httptest.NewRequest("GET", "/protected", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+tok)
|
||||
// Also set a valid OIDC cookie; admin should still win.
|
||||
cookieTok, _ := svc.GenerateSessionToken(&auth.UserInfo{Username: "alice"})
|
||||
req.AddCookie(&http.Cookie{Name: "session", Value: cookieTok})
|
||||
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
|
||||
access := findLine(t, &buf, "in_handler")
|
||||
if access["auth_method"] != "admin" {
|
||||
t.Errorf("auth_method = %v, want admin", access["auth_method"])
|
||||
}
|
||||
if access["user_id"] != "admin" {
|
||||
t.Errorf("user_id = %v, want admin", access["user_id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthMiddleware_Both_BearerInvalid_FallsThroughToOIDCCookie(t *testing.T) {
|
||||
cfg := newBothCfg("session")
|
||||
svc := newAuthSvc(t, &config.AuthConfig{Admin: cfg.Admin})
|
||||
|
||||
cookieTok, err := svc.GenerateSessionToken(&auth.UserInfo{Username: "alice"})
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateSessionToken: %v", err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
app := newAuthTestApp(t, &buf, cfg, svc)
|
||||
|
||||
req := httptest.NewRequest("GET", "/protected", nil)
|
||||
req.Header.Set("Authorization", "Bearer bogus")
|
||||
req.AddCookie(&http.Cookie{Name: "session", Value: cookieTok})
|
||||
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("status = %d, want 200 (OIDC fallback)", resp.StatusCode)
|
||||
}
|
||||
|
||||
access := findLine(t, &buf, "in_handler")
|
||||
if access["auth_method"] != "oidc" {
|
||||
t.Errorf("auth_method = %v, want oidc", access["auth_method"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthMiddleware_Both_AllInvalid_Returns401WithCombinedMethodLabel(t *testing.T) {
|
||||
cfg := newBothCfg("session")
|
||||
svc := newAuthSvc(t, &config.AuthConfig{Admin: cfg.Admin})
|
||||
|
||||
var buf bytes.Buffer
|
||||
app := newAuthTestApp(t, &buf, cfg, svc)
|
||||
|
||||
req := httptest.NewRequest("GET", "/protected", nil)
|
||||
req.Header.Set("Authorization", "Bearer bogus")
|
||||
req.AddCookie(&http.Cookie{Name: "session", Value: "also-bogus"})
|
||||
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
if resp.StatusCode != 401 {
|
||||
t.Fatalf("status = %d, want 401", resp.StatusCode)
|
||||
}
|
||||
|
||||
warn := findLine(t, &buf, "authentication_failed")
|
||||
if warn["auth_method"] != "admin+oidc" {
|
||||
t.Errorf("auth_method = %v, want admin+oidc", warn["auth_method"])
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"Noooste/garage-ui/internal/config"
|
||||
@@ -20,10 +21,14 @@ func CORSMiddleware(cfg *config.CORSConfig) fiber.Handler {
|
||||
return func(c fiber.Ctx) error {
|
||||
origin := c.Get("Origin")
|
||||
|
||||
// Check if origin is allowed
|
||||
if origin != "" && isAllowedOrigin(origin, cfg.AllowedOrigins) {
|
||||
// Check if origin is allowed. When credentials are allowed we refuse
|
||||
// to treat "*" as a match: reflecting an arbitrary Origin alongside
|
||||
// Access-Control-Allow-Credentials: true lets any site read responses
|
||||
// cross-origin with the user's session cookie.
|
||||
if origin != "" && isAllowedOrigin(origin, cfg.AllowedOrigins, cfg.AllowCredentials) {
|
||||
// Set CORS headers
|
||||
c.Set("Access-Control-Allow-Origin", origin)
|
||||
c.Set("Vary", "Origin")
|
||||
|
||||
if cfg.AllowCredentials {
|
||||
c.Set("Access-Control-Allow-Credentials", "true")
|
||||
@@ -41,7 +46,7 @@ func CORSMiddleware(cfg *config.CORSConfig) fiber.Handler {
|
||||
|
||||
// Set max age for preflight cache
|
||||
if cfg.MaxAge > 0 {
|
||||
c.Set("Access-Control-Max-Age", string(rune(cfg.MaxAge)))
|
||||
c.Set("Access-Control-Max-Age", strconv.Itoa(cfg.MaxAge))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,10 +59,14 @@ func CORSMiddleware(cfg *config.CORSConfig) fiber.Handler {
|
||||
}
|
||||
}
|
||||
|
||||
// isAllowedOrigin checks if an origin is in the allowed list
|
||||
func isAllowedOrigin(origin string, allowedOrigins []string) bool {
|
||||
// isAllowedOrigin checks if an origin is in the allowed list.
|
||||
// When allowCredentials is true, "*" is NOT honored — exact match is required.
|
||||
func isAllowedOrigin(origin string, allowedOrigins []string, allowCredentials bool) bool {
|
||||
for _, allowed := range allowedOrigins {
|
||||
if allowed == "*" || allowed == origin {
|
||||
if allowed == origin {
|
||||
return true
|
||||
}
|
||||
if allowed == "*" && !allowCredentials {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"Noooste/garage-ui/internal/config"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
func newCORSApp(t *testing.T, cfg *config.CORSConfig) *fiber.App {
|
||||
t.Helper()
|
||||
app := fiber.New()
|
||||
app.Use(CORSMiddleware(cfg))
|
||||
app.Get("/x", func(c fiber.Ctx) error {
|
||||
return c.SendString("ok")
|
||||
})
|
||||
return app
|
||||
}
|
||||
|
||||
func TestCORS_Disabled_NoHeadersSet(t *testing.T) {
|
||||
cfg := &config.CORSConfig{Enabled: false}
|
||||
app := newCORSApp(t, cfg)
|
||||
|
||||
req := httptest.NewRequest("GET", "/x", nil)
|
||||
req.Header.Set("Origin", "https://foo.example")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "" {
|
||||
t.Errorf("Allow-Origin = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCORS_Enabled_AllowedOriginEchoes(t *testing.T) {
|
||||
cfg := &config.CORSConfig{
|
||||
Enabled: true,
|
||||
AllowedOrigins: []string{"https://ok.example"},
|
||||
AllowedMethods: []string{"GET", "POST"},
|
||||
AllowedHeaders: []string{"Authorization", "Content-Type"},
|
||||
MaxAge: 300,
|
||||
}
|
||||
app := newCORSApp(t, cfg)
|
||||
|
||||
req := httptest.NewRequest("GET", "/x", nil)
|
||||
req.Header.Set("Origin", "https://ok.example")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "https://ok.example" {
|
||||
t.Errorf("Allow-Origin = %q", got)
|
||||
}
|
||||
if got := resp.Header.Get("Vary"); got != "Origin" {
|
||||
t.Errorf("Vary = %q, want Origin", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCORS_Enabled_OriginNotInList_NoHeaders(t *testing.T) {
|
||||
cfg := &config.CORSConfig{
|
||||
Enabled: true,
|
||||
AllowedOrigins: []string{"https://ok.example"},
|
||||
}
|
||||
app := newCORSApp(t, cfg)
|
||||
|
||||
req := httptest.NewRequest("GET", "/x", nil)
|
||||
req.Header.Set("Origin", "https://evil.example")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "" {
|
||||
t.Errorf("Allow-Origin = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCORS_Enabled_NoOriginHeader_NoCORSHeaders(t *testing.T) {
|
||||
cfg := &config.CORSConfig{
|
||||
Enabled: true,
|
||||
AllowedOrigins: []string{"*"},
|
||||
}
|
||||
app := newCORSApp(t, cfg)
|
||||
|
||||
req := httptest.NewRequest("GET", "/x", nil)
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "" {
|
||||
t.Errorf("Allow-Origin = %q, want empty (no Origin header)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCORS_Wildcard_NoCredentials_AllowsAnyOrigin(t *testing.T) {
|
||||
cfg := &config.CORSConfig{
|
||||
Enabled: true,
|
||||
AllowedOrigins: []string{"*"},
|
||||
AllowCredentials: false,
|
||||
}
|
||||
app := newCORSApp(t, cfg)
|
||||
|
||||
req := httptest.NewRequest("GET", "/x", nil)
|
||||
req.Header.Set("Origin", "https://anywhere.example")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "https://anywhere.example" {
|
||||
t.Errorf("Allow-Origin = %q, want echo", got)
|
||||
}
|
||||
if got := resp.Header.Get("Access-Control-Allow-Credentials"); got != "" {
|
||||
t.Errorf("Allow-Credentials set unexpectedly: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCORS_Wildcard_WithCredentials_RejectsUnlistedOrigin(t *testing.T) {
|
||||
cfg := &config.CORSConfig{
|
||||
Enabled: true,
|
||||
AllowedOrigins: []string{"*"},
|
||||
AllowCredentials: true,
|
||||
}
|
||||
app := newCORSApp(t, cfg)
|
||||
|
||||
req := httptest.NewRequest("GET", "/x", nil)
|
||||
req.Header.Set("Origin", "https://evil.example")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "" {
|
||||
t.Errorf("Allow-Origin = %q, want empty (wildcard+creds must not honor *)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCORS_ExactMatch_WithCredentials_SetsAllowCredentials(t *testing.T) {
|
||||
cfg := &config.CORSConfig{
|
||||
Enabled: true,
|
||||
AllowedOrigins: []string{"https://ok.example"},
|
||||
AllowCredentials: true,
|
||||
}
|
||||
app := newCORSApp(t, cfg)
|
||||
|
||||
req := httptest.NewRequest("GET", "/x", nil)
|
||||
req.Header.Set("Origin", "https://ok.example")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "https://ok.example" {
|
||||
t.Errorf("Allow-Origin = %q", got)
|
||||
}
|
||||
if got := resp.Header.Get("Access-Control-Allow-Credentials"); got != "true" {
|
||||
t.Errorf("Allow-Credentials = %q, want true", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCORS_Preflight_AllowedOrigin_Returns204WithHeaders(t *testing.T) {
|
||||
cfg := &config.CORSConfig{
|
||||
Enabled: true,
|
||||
AllowedOrigins: []string{"https://ok.example"},
|
||||
AllowedMethods: []string{"GET", "POST", "PUT"},
|
||||
AllowedHeaders: []string{"Authorization"},
|
||||
MaxAge: 600,
|
||||
}
|
||||
app := newCORSApp(t, cfg)
|
||||
|
||||
req := httptest.NewRequest("OPTIONS", "/x", nil)
|
||||
req.Header.Set("Origin", "https://ok.example")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
if resp.StatusCode != 204 {
|
||||
t.Fatalf("status = %d, want 204", resp.StatusCode)
|
||||
}
|
||||
if got := resp.Header.Get("Access-Control-Allow-Methods"); got != "GET, POST, PUT" {
|
||||
t.Errorf("Allow-Methods = %q", got)
|
||||
}
|
||||
if got := resp.Header.Get("Access-Control-Allow-Headers"); got != "Authorization" {
|
||||
t.Errorf("Allow-Headers = %q", got)
|
||||
}
|
||||
if got := resp.Header.Get("Access-Control-Max-Age"); got != "600" {
|
||||
t.Errorf("Max-Age = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCORS_Preflight_DisallowedOrigin_Returns204NoHeaders(t *testing.T) {
|
||||
cfg := &config.CORSConfig{
|
||||
Enabled: true,
|
||||
AllowedOrigins: []string{"https://ok.example"},
|
||||
}
|
||||
app := newCORSApp(t, cfg)
|
||||
|
||||
req := httptest.NewRequest("OPTIONS", "/x", nil)
|
||||
req.Header.Set("Origin", "https://evil.example")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
if resp.StatusCode != 204 {
|
||||
t.Fatalf("status = %d, want 204", resp.StatusCode)
|
||||
}
|
||||
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "" {
|
||||
t.Errorf("Allow-Origin set for disallowed preflight: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCORS_EmptyAllowedMethods_NoAllowMethodsHeader(t *testing.T) {
|
||||
cfg := &config.CORSConfig{
|
||||
Enabled: true,
|
||||
AllowedOrigins: []string{"https://ok.example"},
|
||||
// AllowedMethods intentionally nil
|
||||
}
|
||||
app := newCORSApp(t, cfg)
|
||||
|
||||
req := httptest.NewRequest("GET", "/x", nil)
|
||||
req.Header.Set("Origin", "https://ok.example")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
if got := resp.Header.Get("Access-Control-Allow-Methods"); got != "" {
|
||||
t.Errorf("Allow-Methods set when list empty: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCORS_MaxAgeZero_NoMaxAgeHeader(t *testing.T) {
|
||||
cfg := &config.CORSConfig{
|
||||
Enabled: true,
|
||||
AllowedOrigins: []string{"https://ok.example"},
|
||||
MaxAge: 0,
|
||||
}
|
||||
app := newCORSApp(t, cfg)
|
||||
|
||||
req := httptest.NewRequest("GET", "/x", nil)
|
||||
req.Header.Set("Origin", "https://ok.example")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
if got := resp.Header.Get("Access-Control-Max-Age"); got != "" {
|
||||
t.Errorf("Max-Age set when zero: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsAllowedOrigin(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
origin string
|
||||
allowed []string
|
||||
allowCredentials bool
|
||||
want bool
|
||||
}{
|
||||
{"exact match", "https://ok.example", []string{"https://ok.example"}, false, true},
|
||||
{"exact match with creds", "https://ok.example", []string{"https://ok.example"}, true, true},
|
||||
{"wildcard without creds matches", "https://any.example", []string{"*"}, false, true},
|
||||
{"wildcard with creds rejected", "https://any.example", []string{"*"}, true, false},
|
||||
{"no match", "https://evil.example", []string{"https://ok.example"}, false, false},
|
||||
{"empty allowed list", "https://ok.example", nil, false, false},
|
||||
{"multiple entries — exact hit", "https://b.example", []string{"https://a.example", "https://b.example"}, false, true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := isAllowedOrigin(tc.origin, tc.allowed, tc.allowCredentials); got != tc.want {
|
||||
t.Errorf("isAllowedOrigin(%q, %v, %v) = %v, want %v",
|
||||
tc.origin, tc.allowed, tc.allowCredentials, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
logpkg "Noooste/garage-ui/pkg/logger"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
// LoggerLocalsKey is the fiber.Ctx.Locals key carrying the per-request logger.
|
||||
const LoggerLocalsKey = "logger"
|
||||
|
||||
// Logging returns middleware that (1) builds a per-request zerolog.Logger
|
||||
// bound with request_id/method/path/remote_ip/user_agent, (2) injects it into
|
||||
// c.Context() so service layers can retrieve it via logger.FromCtx, and
|
||||
// (3) emits a single access-log line after the handler runs.
|
||||
//
|
||||
// The base logger is the one to derive from — typically the global zerolog
|
||||
// logger configured at startup. Tests pass a buffer-backed logger here.
|
||||
//
|
||||
// Access-log line fields: request_id, method, path, remote_ip, user_agent,
|
||||
// status, duration_ms, bytes_out. Skipped for /health and OPTIONS.
|
||||
// Level is chosen from status: >=500 error, >=400 warn, else info.
|
||||
func Logging(base zerolog.Logger) fiber.Handler {
|
||||
return func(c fiber.Ctx) error {
|
||||
requestID, _ := c.Locals(RequestIDLocalsKey).(string)
|
||||
|
||||
reqLogger := base.With().
|
||||
Str("request_id", requestID).
|
||||
Str("method", c.Method()).
|
||||
Str("path", c.Path()).
|
||||
Str("remote_ip", c.IP()).
|
||||
Str("user_agent", c.Get("User-Agent")).
|
||||
Logger()
|
||||
|
||||
c.Locals(LoggerLocalsKey, reqLogger)
|
||||
c.SetContext(logpkg.IntoCtx(c.Context(), reqLogger))
|
||||
|
||||
start := time.Now()
|
||||
err := c.Next()
|
||||
duration := time.Since(start)
|
||||
|
||||
if skipAccessLog(c) {
|
||||
return err
|
||||
}
|
||||
|
||||
status := c.Response().StatusCode()
|
||||
bytesOut := len(c.Response().Body())
|
||||
|
||||
evt := eventForStatus(&reqLogger, status)
|
||||
evt.
|
||||
Int("status", status).
|
||||
Float64("duration_ms", float64(duration.Microseconds())/1000.0).
|
||||
Int("bytes_out", bytesOut).
|
||||
Msg("http_request")
|
||||
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
func skipAccessLog(c fiber.Ctx) bool {
|
||||
if c.Method() == fiber.MethodOptions {
|
||||
return true
|
||||
}
|
||||
if c.Path() == "/health" {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func eventForStatus(l *zerolog.Logger, status int) *zerolog.Event {
|
||||
switch {
|
||||
case status >= 500:
|
||||
return l.Error()
|
||||
case status >= 400:
|
||||
return l.Warn()
|
||||
default:
|
||||
return l.Info()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
logpkg "Noooste/garage-ui/pkg/logger"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
// newLoggingTestApp installs RequestID + Logging middleware with a provided
|
||||
// zerolog.Logger writing to buf so tests can assert on the JSON output.
|
||||
func newLoggingTestApp(t *testing.T, buf *bytes.Buffer, handler fiber.Handler) *fiber.App {
|
||||
t.Helper()
|
||||
base := zerolog.New(buf)
|
||||
app := fiber.New()
|
||||
app.Use(RequestID())
|
||||
app.Use(Logging(base))
|
||||
app.Get("/ping", handler)
|
||||
app.Get("/health", handler)
|
||||
return app
|
||||
}
|
||||
|
||||
func parseLines(t *testing.T, buf *bytes.Buffer) []map[string]any {
|
||||
t.Helper()
|
||||
out := []map[string]any{}
|
||||
for _, line := range strings.Split(buf.String(), "\n") {
|
||||
if strings.TrimSpace(line) == "" {
|
||||
continue
|
||||
}
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal([]byte(line), &m); err != nil {
|
||||
t.Fatalf("not JSON: %v — %s", err, line)
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestLogging_InjectsLoggerIntoContext(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
app := newLoggingTestApp(t, &buf, func(c fiber.Ctx) error {
|
||||
logpkg.FromCtx(c.Context()).Info().Str("stage", "handler").Msg("handled")
|
||||
return c.SendString("ok")
|
||||
})
|
||||
|
||||
req := httptest.NewRequest("GET", "/ping", nil)
|
||||
if _, err := app.Test(req); err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
|
||||
lines := parseLines(t, &buf)
|
||||
if len(lines) < 2 {
|
||||
t.Fatalf("expected >=2 log lines (handler + access), got %d: %q", len(lines), buf.String())
|
||||
}
|
||||
|
||||
h := lines[0]
|
||||
if h["stage"] != "handler" {
|
||||
t.Errorf("handler line stage = %v", h["stage"])
|
||||
}
|
||||
if _, ok := h["request_id"].(string); !ok || h["request_id"] == "" {
|
||||
t.Errorf("handler line missing request_id: %v", h)
|
||||
}
|
||||
if h["method"] != "GET" || h["path"] != "/ping" {
|
||||
t.Errorf("handler line missing method/path: %v", h)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogging_EmitsAccessLogLine(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
app := newLoggingTestApp(t, &buf, func(c fiber.Ctx) error {
|
||||
return c.SendString("ok")
|
||||
})
|
||||
|
||||
req := httptest.NewRequest("GET", "/ping", nil)
|
||||
if _, err := app.Test(req); err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
|
||||
lines := parseLines(t, &buf)
|
||||
var access map[string]any
|
||||
for i := len(lines) - 1; i >= 0; i-- {
|
||||
if _, ok := lines[i]["status"]; ok {
|
||||
access = lines[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if access == nil {
|
||||
t.Fatalf("no access-log line found: %s", buf.String())
|
||||
}
|
||||
|
||||
if access["method"] != "GET" || access["path"] != "/ping" {
|
||||
t.Errorf("access line method/path wrong: %v", access)
|
||||
}
|
||||
if got, _ := access["status"].(float64); got != 200 {
|
||||
t.Errorf("access line status = %v, want 200", access["status"])
|
||||
}
|
||||
if _, ok := access["duration_ms"].(float64); !ok {
|
||||
t.Errorf("access line missing duration_ms: %v", access)
|
||||
}
|
||||
if access["level"] != "info" {
|
||||
t.Errorf("200 should log at info; got level %v", access["level"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogging_SkipsHealthEndpoint(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
app := newLoggingTestApp(t, &buf, func(c fiber.Ctx) error {
|
||||
return c.SendString("ok")
|
||||
})
|
||||
|
||||
req := httptest.NewRequest("GET", "/health", nil)
|
||||
if _, err := app.Test(req); err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
|
||||
for _, line := range parseLines(t, &buf) {
|
||||
if _, hasStatus := line["status"]; hasStatus {
|
||||
if line["path"] == "/health" {
|
||||
t.Errorf("/health should not emit an access log line: %v", line)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogging_LevelByStatus(t *testing.T) {
|
||||
cases := []struct {
|
||||
status int
|
||||
wantLevel string
|
||||
}{
|
||||
{200, "info"},
|
||||
{404, "warn"},
|
||||
{500, "error"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.wantLevel, func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
app := newLoggingTestApp(t, &buf, func(c fiber.Ctx) error {
|
||||
return c.Status(tc.status).SendString("x")
|
||||
})
|
||||
req := httptest.NewRequest("GET", "/ping", nil)
|
||||
if _, err := app.Test(req); err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
|
||||
var access map[string]any
|
||||
for _, line := range parseLines(t, &buf) {
|
||||
if _, ok := line["status"]; ok {
|
||||
access = line
|
||||
}
|
||||
}
|
||||
if access == nil {
|
||||
t.Fatalf("no access line")
|
||||
}
|
||||
if access["level"] != tc.wantLevel {
|
||||
t.Errorf("status %d → level %v, want %v", tc.status, access["level"], tc.wantLevel)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"github.com/gofiber/fiber/v3"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// RequestIDHeader is the HTTP header used to read an incoming request ID
|
||||
// (for cross-service correlation) and to echo the request ID in the response.
|
||||
const RequestIDHeader = "X-Request-ID"
|
||||
|
||||
// RequestIDLocalsKey is the fiber.Ctx.Locals key carrying the request ID.
|
||||
const RequestIDLocalsKey = "request_id"
|
||||
|
||||
// RequestID returns middleware that assigns a request ID to every request.
|
||||
// If the client sends X-Request-ID, that value is used; otherwise a new
|
||||
// UUIDv4 is generated. The ID is stored on c.Locals and echoed in the
|
||||
// response header so clients and downstream services can correlate logs.
|
||||
func RequestID() fiber.Handler {
|
||||
return func(c fiber.Ctx) error {
|
||||
id := c.Get(RequestIDHeader)
|
||||
if id == "" {
|
||||
id = uuid.NewString()
|
||||
}
|
||||
c.Locals(RequestIDLocalsKey, id)
|
||||
c.Set(RequestIDHeader, id)
|
||||
return c.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func newTestApp(t *testing.T, handler fiber.Handler, mw ...fiber.Handler) *fiber.App {
|
||||
t.Helper()
|
||||
app := fiber.New()
|
||||
for _, m := range mw {
|
||||
app.Use(m)
|
||||
}
|
||||
app.Get("/ping", handler)
|
||||
return app
|
||||
}
|
||||
|
||||
func TestRequestID_GeneratesWhenAbsent(t *testing.T) {
|
||||
var seen string
|
||||
app := newTestApp(t, func(c fiber.Ctx) error {
|
||||
seen, _ = c.Locals("request_id").(string)
|
||||
return c.SendString("ok")
|
||||
}, RequestID())
|
||||
|
||||
req := httptest.NewRequest("GET", "/ping", nil)
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
|
||||
if seen == "" {
|
||||
t.Fatal("request_id not set on c.Locals")
|
||||
}
|
||||
if _, err := uuid.Parse(seen); err != nil {
|
||||
t.Errorf("request_id %q is not a valid UUID: %v", seen, err)
|
||||
}
|
||||
if got := resp.Header.Get("X-Request-ID"); got != seen {
|
||||
t.Errorf("X-Request-ID response header = %q, want %q", got, seen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestID_HonorsIncomingHeader(t *testing.T) {
|
||||
var seen string
|
||||
app := newTestApp(t, func(c fiber.Ctx) error {
|
||||
seen, _ = c.Locals("request_id").(string)
|
||||
return c.SendString("ok")
|
||||
}, RequestID())
|
||||
|
||||
req := httptest.NewRequest("GET", "/ping", nil)
|
||||
req.Header.Set("X-Request-ID", "incoming-abc-123")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
|
||||
if seen != "incoming-abc-123" {
|
||||
t.Errorf("request_id = %q, want incoming-abc-123", seen)
|
||||
}
|
||||
if got := resp.Header.Get("X-Request-ID"); got != "incoming-abc-123" {
|
||||
t.Errorf("X-Request-ID response header = %q, want incoming-abc-123", got)
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,6 @@ package models
|
||||
|
||||
import "time"
|
||||
|
||||
// ====================================
|
||||
// Access Key Models
|
||||
// ====================================
|
||||
|
||||
// GarageKeyInfo represents detailed information about a Garage access key
|
||||
type GarageKeyInfo struct {
|
||||
AccessKeyID string `json:"accessKeyId"`
|
||||
@@ -72,10 +68,6 @@ type ListKeysResponseItem struct {
|
||||
Expiration *time.Time `json:"expiration,omitempty"`
|
||||
}
|
||||
|
||||
// ====================================
|
||||
// Bucket Models (Admin API)
|
||||
// ====================================
|
||||
|
||||
// GarageBucketInfo represents detailed information about a bucket from Admin API
|
||||
type GarageBucketInfo struct {
|
||||
ID string `json:"id"`
|
||||
@@ -153,10 +145,6 @@ type BucketLocalAlias struct {
|
||||
Alias string `json:"alias"`
|
||||
}
|
||||
|
||||
// ====================================
|
||||
// Bucket Alias Models
|
||||
// ====================================
|
||||
|
||||
// AddBucketAliasRequest represents the request to add a bucket alias
|
||||
type AddBucketAliasRequest struct {
|
||||
BucketID string `json:"bucketId"`
|
||||
@@ -173,10 +161,6 @@ type RemoveBucketAliasRequest struct {
|
||||
AccessKeyID *string `json:"accessKeyId,omitempty"`
|
||||
}
|
||||
|
||||
// ====================================
|
||||
// Permission Models
|
||||
// ====================================
|
||||
|
||||
// BucketKeyPermRequest represents a request to change bucket-key permissions
|
||||
type BucketKeyPermRequest struct {
|
||||
BucketID string `json:"bucketId"`
|
||||
@@ -184,10 +168,6 @@ type BucketKeyPermRequest struct {
|
||||
Permissions BucketKeyPermission `json:"permissions"`
|
||||
}
|
||||
|
||||
// ====================================
|
||||
// Cluster Models
|
||||
// ====================================
|
||||
|
||||
// ClusterHealth represents the health status of the cluster
|
||||
type ClusterHealth struct {
|
||||
Status string `json:"status"`
|
||||
@@ -211,10 +191,6 @@ type ClusterStatistics struct {
|
||||
Freeform string `json:"freeform"`
|
||||
}
|
||||
|
||||
// ====================================
|
||||
// Node Models
|
||||
// ====================================
|
||||
|
||||
// NodeInfo represents information about a cluster node
|
||||
type NodeInfo struct {
|
||||
ID string `json:"id"`
|
||||
@@ -12,50 +12,20 @@ type GrantBucketPermissionRequest struct {
|
||||
Permissions BucketKeyPermission `json:"permissions" validate:"required"`
|
||||
}
|
||||
|
||||
// DeleteBucketRequest represents a request to delete a bucket
|
||||
type DeleteBucketRequest struct {
|
||||
Name string `json:"name" validate:"required"`
|
||||
}
|
||||
|
||||
// ListObjectsRequest represents a request to list objects in a bucket
|
||||
type ListObjectsRequest struct {
|
||||
Bucket string `json:"bucket" validate:"required"`
|
||||
Prefix string `json:"prefix,omitempty"`
|
||||
MaxKeys int `json:"max_keys,omitempty"`
|
||||
Marker string `json:"marker,omitempty"`
|
||||
}
|
||||
|
||||
// UploadObjectRequest represents metadata for an object upload
|
||||
type UploadObjectRequest struct {
|
||||
Bucket string `json:"bucket" validate:"required"`
|
||||
Key string `json:"key" validate:"required"`
|
||||
ContentType string `json:"content_type,omitempty"`
|
||||
}
|
||||
|
||||
// DeleteObjectRequest represents a request to delete an object
|
||||
type DeleteObjectRequest struct {
|
||||
Bucket string `json:"bucket" validate:"required"`
|
||||
Key string `json:"key" validate:"required"`
|
||||
}
|
||||
|
||||
// GetObjectRequest represents a request to get/download an object
|
||||
type GetObjectRequest struct {
|
||||
Bucket string `json:"bucket" validate:"required"`
|
||||
Key string `json:"key" validate:"required"`
|
||||
}
|
||||
|
||||
// CreateUserRequest represents a request to create a new user/key
|
||||
type CreateUserRequest struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
}
|
||||
|
||||
// DeleteUserRequest represents a request to delete a user/key
|
||||
type DeleteUserRequest struct {
|
||||
AccessKey string `json:"access_key" validate:"required"`
|
||||
}
|
||||
|
||||
// UpdateUserRequest represents a request to update user permissions
|
||||
type UpdateUserRequest struct {
|
||||
Status *string `json:"status,omitempty"` // "active" or "inactive"
|
||||
Expiration *string `json:"expiration,omitempty"` // ISO 8601 date string
|
||||
}
|
||||
|
||||
// UpdateBucketWebsiteRequest represents a request to update bucket website configuration
|
||||
type UpdateBucketWebsiteRequest struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
IndexDocument string `json:"indexDocument,omitempty"`
|
||||
ErrorDocument string `json:"errorDocument,omitempty"`
|
||||
}
|
||||
|
||||
@@ -40,11 +40,13 @@ type HealthResponse struct {
|
||||
|
||||
// BucketInfo represents information about a bucket
|
||||
type BucketInfo struct {
|
||||
Name string `json:"name"`
|
||||
CreationDate time.Time `json:"creationDate"`
|
||||
ObjectCount *int64 `json:"objectCount,omitempty"`
|
||||
Size *int64 `json:"size,omitempty"`
|
||||
Region string `json:"region,omitempty"`
|
||||
Name string `json:"name"`
|
||||
CreationDate time.Time `json:"creationDate"`
|
||||
ObjectCount *int64 `json:"objectCount,omitempty"`
|
||||
Size *int64 `json:"size,omitempty"`
|
||||
Region string `json:"region,omitempty"`
|
||||
WebsiteAccess bool `json:"websiteAccess"`
|
||||
WebsiteConfig *BucketWebsiteConfig `json:"websiteConfig,omitempty"`
|
||||
}
|
||||
|
||||
// BucketListResponse represents a list of buckets
|
||||
@@ -136,13 +138,6 @@ type BucketPermission struct {
|
||||
Owner bool `json:"owner"`
|
||||
}
|
||||
|
||||
// Permission represents a permission entry for access control (legacy/deprecated)
|
||||
type Permission struct {
|
||||
Resource string `json:"resource"`
|
||||
Actions []string `json:"actions"`
|
||||
Effect string `json:"effect"` // "Allow" or "Deny"
|
||||
}
|
||||
|
||||
type PresignedURLResponse struct {
|
||||
URL string `json:"url"`
|
||||
ExpiresIn int64 `json:"expires_in"` // in seconds
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"Noooste/garage-ui/internal/config"
|
||||
"Noooste/garage-ui/internal/handlers"
|
||||
"Noooste/garage-ui/internal/middleware"
|
||||
"fmt"
|
||||
"Noooste/garage-ui/pkg/logger"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -22,7 +22,7 @@ import (
|
||||
func SetupRoutes(
|
||||
app *fiber.App,
|
||||
cfg *config.Config,
|
||||
authService *auth.AuthService,
|
||||
authService *auth.Service,
|
||||
healthHandler *handlers.HealthHandler,
|
||||
bucketHandler *handlers.BucketHandler,
|
||||
objectHandler *handlers.ObjectHandler,
|
||||
@@ -60,6 +60,7 @@ func SetupRoutes(
|
||||
buckets.Get("/:name", bucketHandler.GetBucketInfo) // Get bucket info
|
||||
buckets.Delete("/:name", bucketHandler.DeleteBucket) // Delete a bucket
|
||||
buckets.Post("/:name/permissions", bucketHandler.GrantBucketPermission) // Grant bucket permissions
|
||||
buckets.Put("/:name/website", bucketHandler.UpdateBucketWebsite) // Update bucket website configuration
|
||||
}
|
||||
|
||||
// Object routes
|
||||
@@ -71,64 +72,41 @@ func SetupRoutes(
|
||||
objects.Post("/delete-multiple", objectHandler.DeleteMultipleObjects) // Delete multiple objects
|
||||
}
|
||||
|
||||
// Object-specific routes with wildcard key parameter (supports paths with slashes)
|
||||
// These need to be registered on the main app with auth middleware applied
|
||||
// Directory routes (zero-byte directory markers)
|
||||
api.Post("/buckets/:bucket/directories", objectHandler.CreateDirectory)
|
||||
|
||||
// Fiber v3 does not auto-decode wildcard params; fall back to the raw
|
||||
// value when QueryUnescape fails.
|
||||
decodeObjectKey := func(c fiber.Ctx) string {
|
||||
raw := c.Params("*")
|
||||
if decoded, err := url.QueryUnescape(raw); err == nil {
|
||||
return decoded
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
objectWildcardHandler := func(c fiber.Ctx) error {
|
||||
// Get the full path from wildcard parameter
|
||||
// Note: Fiber v3 does NOT automatically decode params, we need to do it manually
|
||||
path := c.Params("*")
|
||||
|
||||
// Decode the full path using QueryUnescape (handles %20, %2F, etc.)
|
||||
decodedPath, err := url.QueryUnescape(path)
|
||||
if err != nil {
|
||||
// If decoding fails, use the original path
|
||||
decodedPath = path
|
||||
}
|
||||
|
||||
// Check if it's a metadata request
|
||||
if strings.HasSuffix(decodedPath, "/metadata") {
|
||||
// Remove /metadata suffix to get the actual key
|
||||
key := strings.TrimSuffix(decodedPath, "/metadata")
|
||||
c.Locals("objectKey", key)
|
||||
path := decodeObjectKey(c)
|
||||
switch {
|
||||
case strings.HasSuffix(path, "/metadata"):
|
||||
c.Locals("objectKey", strings.TrimSuffix(path, "/metadata"))
|
||||
return objectHandler.GetObjectMetadata(c)
|
||||
}
|
||||
// Check if it's a presign request
|
||||
if strings.HasSuffix(decodedPath, "/presign") {
|
||||
// Remove /presign suffix to get the actual key
|
||||
key := strings.TrimSuffix(decodedPath, "/presign")
|
||||
c.Locals("objectKey", key)
|
||||
case strings.HasSuffix(path, "/presign"):
|
||||
c.Locals("objectKey", strings.TrimSuffix(path, "/presign"))
|
||||
return objectHandler.GetPresignedURL(c)
|
||||
default:
|
||||
c.Locals("objectKey", path)
|
||||
return objectHandler.GetObject(c)
|
||||
}
|
||||
// Otherwise, it's a regular object download
|
||||
c.Locals("objectKey", decodedPath)
|
||||
return objectHandler.GetObject(c)
|
||||
}
|
||||
|
||||
objectDeleteHandler := func(c fiber.Ctx) error {
|
||||
path := c.Params("*")
|
||||
|
||||
// Decode the full path using QueryUnescape
|
||||
key, err := url.QueryUnescape(path)
|
||||
if err != nil {
|
||||
// If decoding fails, use the original path
|
||||
key = path
|
||||
}
|
||||
|
||||
c.Locals("objectKey", key)
|
||||
c.Locals("objectKey", decodeObjectKey(c))
|
||||
return objectHandler.DeleteObject(c)
|
||||
}
|
||||
|
||||
objectHeadHandler := func(c fiber.Ctx) error {
|
||||
path := c.Params("*")
|
||||
|
||||
// Decode the full path using QueryUnescape
|
||||
key, err := url.QueryUnescape(path)
|
||||
if err != nil {
|
||||
// If decoding fails, use the original path
|
||||
key = path
|
||||
}
|
||||
|
||||
c.Locals("objectKey", key)
|
||||
c.Locals("objectKey", decodeObjectKey(c))
|
||||
return objectHandler.GetObjectMetadata(c)
|
||||
}
|
||||
|
||||
@@ -225,6 +203,11 @@ func SetupRoutes(
|
||||
})
|
||||
}
|
||||
|
||||
logger.Debug().
|
||||
Str("access_token", token.AccessToken).
|
||||
Interface("token", token).
|
||||
Msg("Exchanged authorization code for token")
|
||||
|
||||
// Extract ID token from OAuth2 token
|
||||
rawIDToken, ok := token.Extra("id_token").(string)
|
||||
if !ok {
|
||||
@@ -241,6 +224,33 @@ func SetupRoutes(
|
||||
})
|
||||
}
|
||||
|
||||
// Enforce admin role if configured. Roles are often absent from the
|
||||
// ID token and the userinfo endpoint (Keycloak emits resource_access
|
||||
// only in the access token by default), so fall back to the access
|
||||
// token and then the userinfo endpoint before denying access.
|
||||
if cfg.Auth.OIDC.AdminRole != "" {
|
||||
if !authService.IsAdmin(userInfo) {
|
||||
if roles := authService.ExtractRolesFromAccessToken(token.AccessToken); len(roles) > 0 {
|
||||
userInfo.Roles = roles
|
||||
}
|
||||
}
|
||||
if !authService.IsAdmin(userInfo) {
|
||||
if ui, err := authService.GetUserInfo(ctx, token); err == nil && len(ui.Roles) > 0 {
|
||||
userInfo.Roles = ui.Roles
|
||||
}
|
||||
}
|
||||
if !authService.IsAdmin(userInfo) {
|
||||
logger.Warn().
|
||||
Str("username", userInfo.Username).
|
||||
Str("required_role", cfg.Auth.OIDC.AdminRole).
|
||||
Strs("roles", userInfo.Roles).
|
||||
Msg("OIDC login denied: user does not have required admin role")
|
||||
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{
|
||||
"error": "User does not have the required admin role",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Generate JWT session token
|
||||
sessionToken, err := authService.GenerateSessionToken(userInfo)
|
||||
if err != nil {
|
||||
@@ -292,7 +302,7 @@ func SetupRoutes(
|
||||
strings.HasPrefix(path, "/auth") ||
|
||||
strings.HasPrefix(path, "/health") ||
|
||||
strings.HasPrefix(path, "/docs") {
|
||||
fmt.Println("API or health check route, skipping SPA fallback:", path)
|
||||
logger.Debug().Str("path", path).Msg("API or health check route, skipping SPA fallback")
|
||||
return c.Next()
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
// testIssuer is a test-only fake OIDC provider. Fields with Fn suffix are
|
||||
// per-request hooks a test can swap; defaults implement a happy path.
|
||||
type testIssuer struct {
|
||||
Server *httptest.Server
|
||||
ClientID string
|
||||
Key *rsa.PrivateKey
|
||||
KeyID string
|
||||
|
||||
mu sync.Mutex
|
||||
|
||||
// TokenEndpointFn, if set, overrides the default /token handler.
|
||||
TokenEndpointFn func(w http.ResponseWriter, r *http.Request)
|
||||
// UserInfoFn, if set, overrides the default /userinfo handler.
|
||||
UserInfoFn func(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
// Default token response state — used by the default TokenEndpointFn.
|
||||
// Tests mutate these between requests rather than overriding the handler.
|
||||
DefaultAccessClaims map[string]any
|
||||
DefaultIDClaims map[string]any
|
||||
// When true the default /token handler omits id_token from the response.
|
||||
OmitIDToken bool
|
||||
// When non-empty, /token returns the specified HTTP status with a JSON
|
||||
// error payload — simulating code-exchange failures.
|
||||
TokenError string
|
||||
// When true the default /token handler returns an id_token signed with a
|
||||
// rogue key, forcing ID-token verification to fail.
|
||||
SignIDTokenWithWrongKey bool
|
||||
}
|
||||
|
||||
// newTestIssuer spins up the fake issuer and registers cleanup.
|
||||
func newTestIssuer(t *testing.T) *testIssuer {
|
||||
t.Helper()
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("rsa.GenerateKey: %v", err)
|
||||
}
|
||||
iss := &testIssuer{
|
||||
ClientID: "test-client",
|
||||
Key: key,
|
||||
KeyID: "test-key-1",
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
var srv *httptest.Server
|
||||
|
||||
mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) {
|
||||
doc := map[string]any{
|
||||
"issuer": srv.URL,
|
||||
"authorization_endpoint": srv.URL + "/authorize",
|
||||
"token_endpoint": srv.URL + "/token",
|
||||
"userinfo_endpoint": srv.URL + "/userinfo",
|
||||
"jwks_uri": srv.URL + "/jwks",
|
||||
"id_token_signing_alg_values_supported": []string{"RS256"},
|
||||
"response_types_supported": []string{"code"},
|
||||
"subject_types_supported": []string{"public"},
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(doc)
|
||||
})
|
||||
|
||||
mux.HandleFunc("/jwks", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(iss.jwks())
|
||||
})
|
||||
|
||||
mux.HandleFunc("/authorize", func(w http.ResponseWriter, r *http.Request) {
|
||||
// Tests exercise /auth/oidc/login which only reads the redirect URL;
|
||||
// no need to implement the full authorize flow here.
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) {
|
||||
if iss.TokenEndpointFn != nil {
|
||||
iss.TokenEndpointFn(w, r)
|
||||
return
|
||||
}
|
||||
iss.defaultTokenHandler(w, r)
|
||||
})
|
||||
|
||||
mux.HandleFunc("/userinfo", func(w http.ResponseWriter, r *http.Request) {
|
||||
if iss.UserInfoFn != nil {
|
||||
iss.UserInfoFn(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"sub": "user-1",
|
||||
"preferred_username": "alice",
|
||||
"email": "alice@example.com",
|
||||
})
|
||||
})
|
||||
|
||||
srv = httptest.NewServer(mux)
|
||||
iss.Server = srv
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
// Happy-path defaults; tests override fields before exercising callback.
|
||||
iss.DefaultIDClaims = map[string]any{
|
||||
"iss": srv.URL,
|
||||
"sub": "user-1",
|
||||
"aud": iss.ClientID,
|
||||
"exp": time.Now().Add(10 * time.Minute).Unix(),
|
||||
"iat": time.Now().Unix(),
|
||||
"preferred_username": "alice",
|
||||
"email": "alice@example.com",
|
||||
}
|
||||
iss.DefaultAccessClaims = map[string]any{
|
||||
"iss": srv.URL,
|
||||
"sub": "user-1",
|
||||
"exp": time.Now().Add(10 * time.Minute).Unix(),
|
||||
}
|
||||
return iss
|
||||
}
|
||||
|
||||
func (iss *testIssuer) defaultTokenHandler(w http.ResponseWriter, r *http.Request) {
|
||||
iss.mu.Lock()
|
||||
defer iss.mu.Unlock()
|
||||
|
||||
if iss.TokenError != "" {
|
||||
http.Error(w, iss.TokenError, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
access := iss.signJWT(iss.DefaultAccessClaims, iss.Key)
|
||||
resp := map[string]any{
|
||||
"access_token": access,
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 600,
|
||||
}
|
||||
if !iss.OmitIDToken {
|
||||
key := iss.Key
|
||||
if iss.SignIDTokenWithWrongKey {
|
||||
rogue, _ := rsa.GenerateKey(rand.Reader, 2048)
|
||||
key = rogue
|
||||
}
|
||||
resp["id_token"] = iss.signJWT(iss.DefaultIDClaims, key)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
// signJWT signs the given claims with the given RSA key using RS256.
|
||||
func (iss *testIssuer) signJWT(claims map[string]any, key *rsa.PrivateKey) string {
|
||||
tok := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims(claims))
|
||||
tok.Header["kid"] = iss.KeyID
|
||||
signed, err := tok.SignedString(key)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("signJWT: %v", err))
|
||||
}
|
||||
return signed
|
||||
}
|
||||
|
||||
// jwks returns a JWKS document exposing the issuer's RSA public key.
|
||||
func (iss *testIssuer) jwks() map[string]any {
|
||||
pub := iss.Key.PublicKey
|
||||
return map[string]any{
|
||||
"keys": []map[string]any{
|
||||
{
|
||||
"kty": "RSA",
|
||||
"alg": "RS256",
|
||||
"use": "sig",
|
||||
"kid": iss.KeyID,
|
||||
"n": base64.RawURLEncoding.EncodeToString(pub.N.Bytes()),
|
||||
"e": base64.RawURLEncoding.EncodeToString(big.NewInt(int64(pub.E)).Bytes()),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// IDToken signs an ID token with the issuer key. Tests use this to mint
|
||||
// access tokens carrying nested role claims for fallback-path assertions.
|
||||
func (iss *testIssuer) IDToken(claims map[string]any) string {
|
||||
return iss.signJWT(claims, iss.Key)
|
||||
}
|
||||
|
||||
// AccessToken signs an access token carrying Keycloak-shaped resource_access
|
||||
// roles at resource_access.test-client.roles.
|
||||
func (iss *testIssuer) AccessToken(roles []string) string {
|
||||
rolesAny := make([]any, 0, len(roles))
|
||||
for _, r := range roles {
|
||||
rolesAny = append(rolesAny, r)
|
||||
}
|
||||
return iss.signJWT(map[string]any{
|
||||
"iss": iss.Server.URL,
|
||||
"sub": "user-1",
|
||||
"exp": time.Now().Add(10 * time.Minute).Unix(),
|
||||
"resource_access": map[string]any{
|
||||
"test-client": map[string]any{"roles": rolesAny},
|
||||
},
|
||||
}, iss.Key)
|
||||
}
|
||||
@@ -0,0 +1,604 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"Noooste/garage-ui/internal/auth"
|
||||
"Noooste/garage-ui/internal/config"
|
||||
"Noooste/garage-ui/internal/handlers"
|
||||
"Noooste/garage-ui/internal/services/mocks"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
// routeFixture bundles everything a routes test needs.
|
||||
type routeFixture struct {
|
||||
App *fiber.App
|
||||
Admin *mocks.AdminMock
|
||||
S3 *mocks.S3Mock
|
||||
Auth *auth.Service
|
||||
Cfg *config.Config
|
||||
}
|
||||
|
||||
// newTestApp builds a fully-wired fiber.App via SetupRoutes. The cfgMutator
|
||||
// lets each test flip Admin/OIDC/CORS flags before the auth.Service is
|
||||
// constructed. If the mutator sets OIDC.Enabled=true it MUST set
|
||||
// OIDC.IssuerURL + Scopes + AdminRole + ClientID so NewAuthService can dial
|
||||
// the issuer — typically via the testIssuer fixture.
|
||||
func newTestApp(t *testing.T, cfgMutator func(*config.Config)) *routeFixture {
|
||||
t.Helper()
|
||||
|
||||
cfg := &config.Config{
|
||||
Server: config.ServerConfig{
|
||||
Port: 8080,
|
||||
Environment: "test",
|
||||
},
|
||||
Auth: config.AuthConfig{},
|
||||
CORS: config.CORSConfig{},
|
||||
}
|
||||
if cfgMutator != nil {
|
||||
cfgMutator(cfg)
|
||||
}
|
||||
|
||||
svc, err := auth.NewAuthService(&cfg.Auth, &cfg.Server)
|
||||
if err != nil {
|
||||
t.Fatalf("NewAuthService: %v", err)
|
||||
}
|
||||
|
||||
admin := &mocks.AdminMock{}
|
||||
s3 := &mocks.S3Mock{}
|
||||
|
||||
app := fiber.New()
|
||||
SetupRoutes(
|
||||
app,
|
||||
cfg,
|
||||
svc,
|
||||
handlers.NewHealthHandler("test"),
|
||||
handlers.NewBucketHandler(admin, s3),
|
||||
handlers.NewObjectHandler(s3),
|
||||
handlers.NewUserHandler(admin),
|
||||
handlers.NewClusterHandler(admin),
|
||||
handlers.NewMonitoringHandler(admin, s3),
|
||||
)
|
||||
|
||||
return &routeFixture{App: app, Admin: admin, S3: s3, Auth: svc, Cfg: cfg}
|
||||
}
|
||||
|
||||
// expectStatus sends req and asserts the status code.
|
||||
func expectStatus(t *testing.T, app *fiber.App, req *http.Request, want int) *http.Response {
|
||||
t.Helper()
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test(%s %s): %v", req.Method, req.URL.Path, err)
|
||||
}
|
||||
if resp.StatusCode != want {
|
||||
t.Fatalf("%s %s: status = %d, want %d", req.Method, req.URL.Path, resp.StatusCode, want)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func TestRoutes_Registered_NoAuth(t *testing.T) {
|
||||
// No auth: every route resolves; auth-specific routes return 404.
|
||||
f := newTestApp(t, func(c *config.Config) {
|
||||
c.Auth.Admin.Enabled = false
|
||||
c.Auth.OIDC.Enabled = false
|
||||
})
|
||||
|
||||
// Public routes reachable → not 404. Status may be anything except 404.
|
||||
for _, tc := range []struct {
|
||||
method, path string
|
||||
}{
|
||||
{"GET", "/health"},
|
||||
{"GET", "/api/v1/health"},
|
||||
{"GET", "/auth/config"},
|
||||
} {
|
||||
req := httptest.NewRequest(tc.method, tc.path, nil)
|
||||
resp, err := f.App.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("%s %s: %v", tc.method, tc.path, err)
|
||||
}
|
||||
if resp.StatusCode == 404 {
|
||||
t.Errorf("%s %s returned 404 — route not registered", tc.method, tc.path)
|
||||
}
|
||||
}
|
||||
|
||||
// Auth-specific routes must 404 when both auth methods disabled.
|
||||
for _, tc := range []struct {
|
||||
method, path string
|
||||
}{
|
||||
{"POST", "/auth/login"},
|
||||
{"GET", "/auth/me"},
|
||||
{"GET", "/auth/oidc/login"},
|
||||
{"GET", "/auth/oidc/callback"},
|
||||
{"POST", "/auth/oidc/logout"},
|
||||
} {
|
||||
req := httptest.NewRequest(tc.method, tc.path, nil)
|
||||
expectStatus(t, f.App, req, 404)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoutes_Registered_AdminOnly(t *testing.T) {
|
||||
f := newTestApp(t, func(c *config.Config) {
|
||||
c.Auth.Admin.Enabled = true
|
||||
c.Auth.Admin.Username = "admin"
|
||||
c.Auth.Admin.Password = "pw"
|
||||
})
|
||||
|
||||
// /auth/login and /auth/me present; /auth/oidc/* not.
|
||||
for _, tc := range []struct {
|
||||
method, path string
|
||||
}{
|
||||
{"POST", "/auth/login"},
|
||||
{"GET", "/auth/me"},
|
||||
} {
|
||||
req := httptest.NewRequest(tc.method, tc.path, nil)
|
||||
resp, err := f.App.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
if resp.StatusCode == 404 {
|
||||
t.Errorf("%s %s returned 404", tc.method, tc.path)
|
||||
}
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
method, path string
|
||||
}{
|
||||
{"GET", "/auth/oidc/login"},
|
||||
{"GET", "/auth/oidc/callback"},
|
||||
{"POST", "/auth/oidc/logout"},
|
||||
} {
|
||||
req := httptest.NewRequest(tc.method, tc.path, nil)
|
||||
expectStatus(t, f.App, req, 404)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoutes_UnknownPath_Returns404(t *testing.T) {
|
||||
f := newTestApp(t, nil)
|
||||
req := httptest.NewRequest("GET", "/this/does/not/exist", nil)
|
||||
expectStatus(t, f.App, req, 404)
|
||||
}
|
||||
|
||||
func TestRoutes_AllAPIRoutesRegistered(t *testing.T) {
|
||||
// With Admin enabled, hit every path/method declared in SetupRoutes.
|
||||
// A route being "registered" = status != 404. Specific behavior depends
|
||||
// on auth (401) or mock setup (500 from errNotConfigured) — we only
|
||||
// care that fiber routed the request.
|
||||
f := newTestApp(t, func(c *config.Config) {
|
||||
c.Auth.Admin.Enabled = true
|
||||
c.Auth.Admin.Username = "admin"
|
||||
c.Auth.Admin.Password = "pw"
|
||||
})
|
||||
|
||||
cases := []struct {
|
||||
method, path string
|
||||
}{
|
||||
// Buckets
|
||||
{"GET", "/api/v1/buckets/"},
|
||||
{"POST", "/api/v1/buckets/"},
|
||||
{"GET", "/api/v1/buckets/b1"},
|
||||
{"DELETE", "/api/v1/buckets/b1"},
|
||||
{"POST", "/api/v1/buckets/b1/permissions"},
|
||||
{"PUT", "/api/v1/buckets/b1/website"},
|
||||
// Objects (listing + uploads)
|
||||
{"GET", "/api/v1/buckets/b1/objects/"},
|
||||
{"POST", "/api/v1/buckets/b1/objects/"},
|
||||
{"POST", "/api/v1/buckets/b1/objects/upload-multiple"},
|
||||
{"POST", "/api/v1/buckets/b1/objects/delete-multiple"},
|
||||
// Object wildcard routes
|
||||
{"GET", "/api/v1/buckets/b1/objects/folder/file.txt"},
|
||||
{"GET", "/api/v1/buckets/b1/objects/folder/file.txt/metadata"},
|
||||
{"GET", "/api/v1/buckets/b1/objects/folder/file.txt/presign"},
|
||||
{"DELETE", "/api/v1/buckets/b1/objects/folder/file.txt"},
|
||||
{"HEAD", "/api/v1/buckets/b1/objects/folder/file.txt"},
|
||||
// Users
|
||||
{"GET", "/api/v1/users/"},
|
||||
{"POST", "/api/v1/users/"},
|
||||
{"GET", "/api/v1/users/AKIA"},
|
||||
{"GET", "/api/v1/users/AKIA/secret"},
|
||||
{"DELETE", "/api/v1/users/AKIA"},
|
||||
{"PATCH", "/api/v1/users/AKIA"},
|
||||
// Cluster
|
||||
{"GET", "/api/v1/cluster/health"},
|
||||
{"GET", "/api/v1/cluster/status"},
|
||||
{"GET", "/api/v1/cluster/statistics"},
|
||||
{"GET", "/api/v1/cluster/nodes/n1"},
|
||||
{"GET", "/api/v1/cluster/nodes/n1/statistics"},
|
||||
// Monitoring
|
||||
{"GET", "/api/v1/monitoring/metrics"},
|
||||
{"GET", "/api/v1/monitoring/admin-health"},
|
||||
{"GET", "/api/v1/monitoring/dashboard"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.method+" "+tc.path, func(t *testing.T) {
|
||||
req := httptest.NewRequest(tc.method, tc.path, nil)
|
||||
resp, err := f.App.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
if resp.StatusCode == 404 {
|
||||
t.Errorf("route not registered (404): %s %s", tc.method, tc.path)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoutes_AuthRequired_For_APIRoutes(t *testing.T) {
|
||||
f := newTestApp(t, func(c *config.Config) {
|
||||
c.Auth.Admin.Enabled = true
|
||||
c.Auth.Admin.Username = "admin"
|
||||
c.Auth.Admin.Password = "pw"
|
||||
})
|
||||
|
||||
// Unauthenticated requests to /api/v1/* must return 401 — auth
|
||||
// middleware must run before the handler.
|
||||
for _, tc := range []struct{ method, path string }{
|
||||
{"GET", "/api/v1/buckets/"},
|
||||
{"GET", "/api/v1/users/"},
|
||||
{"GET", "/api/v1/cluster/health"},
|
||||
{"GET", "/api/v1/monitoring/metrics"},
|
||||
// Object wildcard routes register AuthMiddleware separately — prove
|
||||
// that wiring isn't missed for any of GET/DELETE/HEAD.
|
||||
{"GET", "/api/v1/buckets/b/objects/k"},
|
||||
{"DELETE", "/api/v1/buckets/b/objects/k"},
|
||||
{"HEAD", "/api/v1/buckets/b/objects/k"},
|
||||
} {
|
||||
tc := tc
|
||||
t.Run(tc.method+" "+tc.path, func(t *testing.T) {
|
||||
req := httptest.NewRequest(tc.method, tc.path, nil)
|
||||
expectStatus(t, f.App, req, 401)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoutes_NoAuth_On_PublicRoutes(t *testing.T) {
|
||||
f := newTestApp(t, func(c *config.Config) {
|
||||
c.Auth.Admin.Enabled = true
|
||||
c.Auth.Admin.Username = "admin"
|
||||
c.Auth.Admin.Password = "pw"
|
||||
})
|
||||
for _, tc := range []struct{ method, path string }{
|
||||
{"GET", "/health"},
|
||||
{"GET", "/api/v1/health"},
|
||||
{"GET", "/auth/config"},
|
||||
} {
|
||||
tc := tc
|
||||
t.Run(tc.path, func(t *testing.T) {
|
||||
req := httptest.NewRequest(tc.method, tc.path, nil)
|
||||
resp, err := f.App.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
if resp.StatusCode == 401 {
|
||||
t.Errorf("public route unexpectedly returned 401: %s", tc.path)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoutes_CORS_Preflight_PassesBeforeAuth(t *testing.T) {
|
||||
f := newTestApp(t, func(c *config.Config) {
|
||||
c.Auth.Admin.Enabled = true
|
||||
c.Auth.Admin.Username = "admin"
|
||||
c.Auth.Admin.Password = "pw"
|
||||
c.CORS = config.CORSConfig{
|
||||
Enabled: true,
|
||||
AllowedOrigins: []string{"https://ui.example"},
|
||||
AllowedMethods: []string{"GET", "POST"},
|
||||
}
|
||||
})
|
||||
|
||||
req := httptest.NewRequest("OPTIONS", "/api/v1/buckets/", nil)
|
||||
req.Header.Set("Origin", "https://ui.example")
|
||||
resp := expectStatus(t, f.App, req, 204)
|
||||
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "https://ui.example" {
|
||||
t.Errorf("Allow-Origin = %q — CORS should have run before auth", got)
|
||||
}
|
||||
}
|
||||
|
||||
// newOIDCFixture builds a route fixture with OIDC enabled pointing at a
|
||||
// running testIssuer. The `adminRole` is applied to cfg.Auth.OIDC.AdminRole
|
||||
// (pass empty to disable the role gate).
|
||||
func newOIDCFixture(t *testing.T, adminRole string) (*routeFixture, *testIssuer) {
|
||||
t.Helper()
|
||||
iss := newTestIssuer(t)
|
||||
f := newTestApp(t, func(c *config.Config) {
|
||||
c.Server.RootURL = "https://app.example"
|
||||
c.Auth.OIDC = config.OIDCConfig{
|
||||
Enabled: true,
|
||||
ClientID: iss.ClientID,
|
||||
ClientSecret: "secret",
|
||||
IssuerURL: iss.Server.URL,
|
||||
Scopes: []string{"openid", "profile", "email"},
|
||||
AdminRole: adminRole,
|
||||
UsernameAttribute: "preferred_username",
|
||||
EmailAttribute: "email",
|
||||
NameAttribute: "name",
|
||||
RoleAttributePath: "resource_access.test-client.roles",
|
||||
CookieName: "session",
|
||||
CookieSecure: false,
|
||||
CookieHTTPOnly: true,
|
||||
CookieSameSite: "Lax",
|
||||
SessionMaxAge: 3600,
|
||||
}
|
||||
})
|
||||
return f, iss
|
||||
}
|
||||
|
||||
func TestRoutes_OIDCLogin_RedirectsToAuthorizeEndpoint(t *testing.T) {
|
||||
f, iss := newOIDCFixture(t, "admin")
|
||||
|
||||
req := httptest.NewRequest("GET", "/auth/oidc/login", nil)
|
||||
resp, err := f.App.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
if resp.StatusCode != 303 {
|
||||
t.Fatalf("status = %d, want 303", resp.StatusCode)
|
||||
}
|
||||
loc := resp.Header.Get("Location")
|
||||
if !strings.HasPrefix(loc, iss.Server.URL+"/authorize") {
|
||||
t.Errorf("Location = %q, want prefix %s/authorize", loc, iss.Server.URL)
|
||||
}
|
||||
if !strings.Contains(loc, "state=") {
|
||||
t.Errorf("Location missing state param: %s", loc)
|
||||
}
|
||||
if !strings.Contains(loc, "client_id=test-client") {
|
||||
t.Errorf("Location missing client_id: %s", loc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoutes_Registered_OIDCOnly(t *testing.T) {
|
||||
f, _ := newOIDCFixture(t, "admin")
|
||||
|
||||
// /auth/oidc/* registered
|
||||
for _, tc := range []struct{ method, path string }{
|
||||
{"GET", "/auth/oidc/login"},
|
||||
{"GET", "/auth/oidc/callback"},
|
||||
{"POST", "/auth/oidc/logout"},
|
||||
} {
|
||||
req := httptest.NewRequest(tc.method, tc.path, nil)
|
||||
resp, err := f.App.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
if resp.StatusCode == 404 {
|
||||
t.Errorf("%s %s not registered", tc.method, tc.path)
|
||||
}
|
||||
}
|
||||
|
||||
// /auth/login must be 404 — admin disabled.
|
||||
req := httptest.NewRequest("POST", "/auth/login", nil)
|
||||
expectStatus(t, f.App, req, 404)
|
||||
}
|
||||
|
||||
// oidcState returns a fresh state token minted by the fixture's auth service.
|
||||
func oidcState(t *testing.T, f *routeFixture) string {
|
||||
t.Helper()
|
||||
s, err := f.Auth.GenerateStateToken()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateStateToken: %v", err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func TestRoutes_OIDCCallback_MissingState_Returns400(t *testing.T) {
|
||||
f, _ := newOIDCFixture(t, "admin")
|
||||
req := httptest.NewRequest("GET", "/auth/oidc/callback", nil)
|
||||
expectStatus(t, f.App, req, 400)
|
||||
}
|
||||
|
||||
func TestRoutes_OIDCCallback_InvalidState_Returns400(t *testing.T) {
|
||||
f, _ := newOIDCFixture(t, "admin")
|
||||
req := httptest.NewRequest("GET", "/auth/oidc/callback?state=not-a-valid-state&code=c", nil)
|
||||
expectStatus(t, f.App, req, 400)
|
||||
}
|
||||
|
||||
func TestRoutes_OIDCCallback_MissingCode_Returns400(t *testing.T) {
|
||||
f, _ := newOIDCFixture(t, "admin")
|
||||
state := oidcState(t, f)
|
||||
req := httptest.NewRequest("GET", "/auth/oidc/callback?state="+state, nil)
|
||||
expectStatus(t, f.App, req, 400)
|
||||
}
|
||||
|
||||
func TestRoutes_OIDCCallback_TokenExchangeFails_Returns401(t *testing.T) {
|
||||
f, iss := newOIDCFixture(t, "admin")
|
||||
iss.TokenError = "invalid_grant"
|
||||
defer func() { iss.TokenError = "" }()
|
||||
|
||||
state := oidcState(t, f)
|
||||
req := httptest.NewRequest("GET", "/auth/oidc/callback?state="+state+"&code=c", nil)
|
||||
expectStatus(t, f.App, req, 401)
|
||||
}
|
||||
|
||||
func TestRoutes_OIDCCallback_MissingIDToken_Returns401(t *testing.T) {
|
||||
f, iss := newOIDCFixture(t, "admin")
|
||||
iss.OmitIDToken = true
|
||||
defer func() { iss.OmitIDToken = false }()
|
||||
|
||||
state := oidcState(t, f)
|
||||
req := httptest.NewRequest("GET", "/auth/oidc/callback?state="+state+"&code=c", nil)
|
||||
expectStatus(t, f.App, req, 401)
|
||||
}
|
||||
|
||||
func TestRoutes_OIDCCallback_BadIDTokenSignature_Returns401(t *testing.T) {
|
||||
f, iss := newOIDCFixture(t, "admin")
|
||||
iss.SignIDTokenWithWrongKey = true
|
||||
defer func() { iss.SignIDTokenWithWrongKey = false }()
|
||||
|
||||
state := oidcState(t, f)
|
||||
req := httptest.NewRequest("GET", "/auth/oidc/callback?state="+state+"&code=c", nil)
|
||||
expectStatus(t, f.App, req, 401)
|
||||
}
|
||||
|
||||
func TestRoutes_OIDCCallback_RoleGateDenies_Returns403(t *testing.T) {
|
||||
f, _ := newOIDCFixture(t, "admin") // AdminRole set; no roles anywhere
|
||||
state := oidcState(t, f)
|
||||
req := httptest.NewRequest("GET", "/auth/oidc/callback?state="+state+"&code=c", nil)
|
||||
expectStatus(t, f.App, req, 403)
|
||||
}
|
||||
|
||||
func TestRoutes_OIDCCallback_NoRoleGate_HappyPathSetsCookieAndRedirects(t *testing.T) {
|
||||
f, _ := newOIDCFixture(t, "") // AdminRole empty → role gate skipped
|
||||
state := oidcState(t, f)
|
||||
|
||||
req := httptest.NewRequest("GET", "/auth/oidc/callback?state="+state+"&code=c", nil)
|
||||
resp, err := f.App.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
if resp.StatusCode != 303 {
|
||||
t.Fatalf("status = %d, want 303", resp.StatusCode)
|
||||
}
|
||||
if loc := resp.Header.Get("Location"); loc != "/login?login=success" {
|
||||
t.Errorf("Location = %q", loc)
|
||||
}
|
||||
|
||||
cookies := resp.Cookies()
|
||||
var sess *http.Cookie
|
||||
for _, c := range cookies {
|
||||
if c.Name == "session" {
|
||||
sess = c
|
||||
break
|
||||
}
|
||||
}
|
||||
if sess == nil {
|
||||
t.Fatalf("no session cookie set: %+v", cookies)
|
||||
}
|
||||
if sess.Value == "" {
|
||||
t.Error("session cookie value empty")
|
||||
}
|
||||
if !sess.HttpOnly {
|
||||
t.Error("session cookie should be HttpOnly")
|
||||
}
|
||||
if sess.MaxAge != 3600 {
|
||||
t.Errorf("MaxAge = %d, want 3600", sess.MaxAge)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoutes_OIDCCallback_RoleMatchedViaAccessTokenFallback_Succeeds(t *testing.T) {
|
||||
f, iss := newOIDCFixture(t, "admin")
|
||||
// Inject role into the access token so ExtractRolesFromAccessToken returns [admin].
|
||||
iss.DefaultAccessClaims = map[string]any{
|
||||
"iss": iss.Server.URL,
|
||||
"sub": "user-1",
|
||||
"exp": time.Now().Add(10 * time.Minute).Unix(),
|
||||
"resource_access": map[string]any{
|
||||
"test-client": map[string]any{"roles": []any{"admin"}},
|
||||
},
|
||||
}
|
||||
|
||||
state := oidcState(t, f)
|
||||
req := httptest.NewRequest("GET", "/auth/oidc/callback?state="+state+"&code=c", nil)
|
||||
resp, err := f.App.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
if resp.StatusCode != 303 {
|
||||
t.Fatalf("status = %d, want 303 (access-token role fallback should match)", resp.StatusCode)
|
||||
}
|
||||
if loc := resp.Header.Get("Location"); loc != "/login?login=success" {
|
||||
t.Errorf("Location = %q", loc)
|
||||
}
|
||||
var sess *http.Cookie
|
||||
for _, c := range resp.Cookies() {
|
||||
if c.Name == "session" {
|
||||
sess = c
|
||||
}
|
||||
}
|
||||
if sess == nil || sess.Value == "" {
|
||||
t.Fatalf("expected session cookie with value, got %+v", sess)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoutes_OIDCLogout_ClearsCookieAndReturns200(t *testing.T) {
|
||||
f, _ := newOIDCFixture(t, "admin")
|
||||
|
||||
req := httptest.NewRequest("POST", "/auth/oidc/logout", nil)
|
||||
resp, err := f.App.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
|
||||
var body map[string]any
|
||||
_ = json.NewDecoder(resp.Body).Decode(&body)
|
||||
if body["success"] != true {
|
||||
t.Errorf("success = %v, want true", body["success"])
|
||||
}
|
||||
if body["message"] != "Logged out successfully" {
|
||||
t.Errorf("message = %v", body["message"])
|
||||
}
|
||||
|
||||
var sess *http.Cookie
|
||||
for _, c := range resp.Cookies() {
|
||||
if c.Name == "session" {
|
||||
sess = c
|
||||
}
|
||||
}
|
||||
if sess == nil {
|
||||
t.Fatalf("expected session cookie in response")
|
||||
}
|
||||
if sess.MaxAge != -1 {
|
||||
t.Errorf("MaxAge = %d, want -1 (cookie cleared)", sess.MaxAge)
|
||||
}
|
||||
if sess.Value != "" {
|
||||
t.Errorf("cookie Value = %q, want empty", sess.Value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoutes_SPAFallback_NoFrontendDir_DoesNotMount(t *testing.T) {
|
||||
// Chdir into an empty temp dir — frontend/dist does not exist, so the
|
||||
// SPA fallback is not registered.
|
||||
t.Chdir(t.TempDir())
|
||||
|
||||
f := newTestApp(t, nil)
|
||||
|
||||
req := httptest.NewRequest("GET", "/random/spa/path", nil)
|
||||
expectStatus(t, f.App, req, 404)
|
||||
}
|
||||
|
||||
func TestRoutes_SPAFallback_WithFrontend_ServesIndexForUnknownPath(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
// Fiber's c.SendFile holds a handle on the served file; Windows refuses
|
||||
// t.TempDir RemoveAll cleanup. Behavior itself is platform-agnostic and
|
||||
// covered on Linux in CI.
|
||||
t.Skip("SPA fallback test skipped on Windows due to file-handle cleanup race")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
t.Chdir(dir)
|
||||
|
||||
// Create ./frontend/dist/index.html
|
||||
if err := os.MkdirAll(filepath.Join(dir, "frontend", "dist"), 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
index := filepath.Join(dir, "frontend", "dist", "index.html")
|
||||
if err := os.WriteFile(index, []byte("<!doctype html><title>spa</title>"), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
|
||||
f := newTestApp(t, nil)
|
||||
|
||||
// Unknown SPA path → serves index.html.
|
||||
req := httptest.NewRequest("GET", "/deep/spa/route", nil)
|
||||
resp := expectStatus(t, f.App, req, 200)
|
||||
body := make([]byte, 64)
|
||||
n, _ := resp.Body.Read(body)
|
||||
if !strings.Contains(string(body[:n]), "spa") {
|
||||
t.Errorf("body = %q, want index.html content", string(body[:n]))
|
||||
}
|
||||
|
||||
// API prefix is skipped by the fallback → still 404 for unknown API path.
|
||||
req2 := httptest.NewRequest("GET", "/api/v1/definitely-not-a-route", nil)
|
||||
expectStatus(t, f.App, req2, 404)
|
||||
}
|
||||
@@ -4,11 +4,13 @@ import (
|
||||
"Noooste/garage-ui/internal/config"
|
||||
"Noooste/garage-ui/internal/models"
|
||||
"Noooste/garage-ui/pkg/utils"
|
||||
logpkg "Noooste/garage-ui/pkg/logger"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/Noooste/azuretls-client"
|
||||
)
|
||||
@@ -21,9 +23,12 @@ type GarageAdminService struct {
|
||||
}
|
||||
|
||||
// NewGarageAdminService creates a new Garage Admin API service
|
||||
func NewGarageAdminService(cfg *config.GarageConfig) *GarageAdminService {
|
||||
func NewGarageAdminService(cfg *config.GarageConfig, logLevel string) *GarageAdminService {
|
||||
session := azuretls.NewSession()
|
||||
session.Log()
|
||||
|
||||
if logLevel == "debug" {
|
||||
session.Log()
|
||||
}
|
||||
|
||||
return &GarageAdminService{
|
||||
baseURL: cfg.AdminEndpoint,
|
||||
@@ -76,10 +81,6 @@ func decodeResponse(resp *azuretls.Response, target interface{}) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ====================================
|
||||
// Access Key Operations
|
||||
// ====================================
|
||||
|
||||
// ListKeys returns all access keys in the cluster
|
||||
func (s *GarageAdminService) ListKeys(ctx context.Context) ([]models.ListKeysResponseItem, error) {
|
||||
resp, err := s.doRequest(ctx, http.MethodGet, "/v2/ListKeys", nil)
|
||||
@@ -178,111 +179,180 @@ func (s *GarageAdminService) ImportKey(ctx context.Context, req models.ImportKey
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// ====================================
|
||||
// Bucket Operations (Admin API)
|
||||
// ====================================
|
||||
|
||||
// ListBuckets returns all buckets in the cluster
|
||||
// ListBuckets returns all buckets in the cluster.
|
||||
func (s *GarageAdminService) ListBuckets(ctx context.Context) ([]models.ListBucketsResponseItem, error) {
|
||||
log := logpkg.FromCtx(ctx).With().
|
||||
Str("component", "admin").
|
||||
Str("operation", "list_buckets").
|
||||
Logger()
|
||||
|
||||
log.Debug().Msg("listing buckets")
|
||||
start := time.Now()
|
||||
|
||||
resp, err := s.doRequest(ctx, http.MethodGet, "/v2/ListBuckets", nil)
|
||||
if err != nil {
|
||||
log.Error().Err(err).
|
||||
Float64("duration_ms", msSince(start)).
|
||||
Str("outcome", "failure").
|
||||
Msg("garage list_buckets request failed")
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
|
||||
var result []models.ListBucketsResponseItem
|
||||
if err := decodeResponse(resp, &result); err != nil {
|
||||
log.Error().Err(err).
|
||||
Float64("duration_ms", msSince(start)).
|
||||
Str("outcome", "failure").
|
||||
Msg("garage list_buckets decode failed")
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
log.Debug().
|
||||
Float64("duration_ms", msSince(start)).
|
||||
Str("outcome", "success").
|
||||
Int("count", len(result)).
|
||||
Msg("listed buckets")
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetBucketInfo returns detailed information about a bucket by ID
|
||||
// GetBucketInfo returns detailed information about a bucket by ID.
|
||||
func (s *GarageAdminService) GetBucketInfo(ctx context.Context, bucketID string) (*models.GarageBucketInfo, error) {
|
||||
path := fmt.Sprintf("/v2/GetBucketInfo?id=%s", bucketID)
|
||||
log := logpkg.FromCtx(ctx).With().
|
||||
Str("component", "admin").
|
||||
Str("operation", "get_bucket_info").
|
||||
Str("bucket_id", bucketID).
|
||||
Logger()
|
||||
|
||||
resp, err := s.doRequest(ctx, http.MethodGet, path, nil)
|
||||
log.Debug().Msg("getting bucket info")
|
||||
start := time.Now()
|
||||
|
||||
resp, err := s.doRequest(ctx, http.MethodGet, fmt.Sprintf("/v2/GetBucketInfo?id=%s", bucketID), nil)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Float64("duration_ms", msSince(start)).Str("outcome", "failure").Msg("garage get_bucket_info request failed")
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
log.Debug().Float64("duration_ms", msSince(start)).Str("outcome", "success").Msg("got bucket info")
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// GetBucketInfoByAlias returns detailed information about a bucket by its global alias
|
||||
// GetBucketInfoByAlias returns detailed information about a bucket by its global alias.
|
||||
func (s *GarageAdminService) GetBucketInfoByAlias(ctx context.Context, globalAlias string) (*models.GarageBucketInfo, error) {
|
||||
path := fmt.Sprintf("/v2/GetBucketInfo?globalAlias=%s", globalAlias)
|
||||
log := logpkg.FromCtx(ctx).With().
|
||||
Str("component", "admin").
|
||||
Str("operation", "get_bucket_info_by_alias").
|
||||
Str("bucket", globalAlias).
|
||||
Logger()
|
||||
|
||||
resp, err := s.doRequest(ctx, http.MethodGet, path, nil)
|
||||
log.Debug().Msg("getting bucket info by alias")
|
||||
start := time.Now()
|
||||
|
||||
resp, err := s.doRequest(ctx, http.MethodGet, fmt.Sprintf("/v2/GetBucketInfo?globalAlias=%s", globalAlias), nil)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Float64("duration_ms", msSince(start)).Str("outcome", "failure").Msg("garage get_bucket_info_by_alias request failed")
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
log.Debug().Float64("duration_ms", msSince(start)).Str("outcome", "success").Str("bucket_id", result.ID).Msg("got bucket info by alias")
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// CreateBucket creates a new bucket via the Admin API
|
||||
// CreateBucket creates a new bucket via the Admin API.
|
||||
func (s *GarageAdminService) CreateBucket(ctx context.Context, req models.CreateBucketAdminRequest) (*models.GarageBucketInfo, error) {
|
||||
var alias string
|
||||
if req.GlobalAlias != nil {
|
||||
alias = *req.GlobalAlias
|
||||
}
|
||||
log := logpkg.FromCtx(ctx).With().
|
||||
Str("component", "admin").
|
||||
Str("operation", "create_bucket").
|
||||
Str("bucket", alias).
|
||||
Logger()
|
||||
|
||||
log.Info().Msg("creating bucket")
|
||||
start := time.Now()
|
||||
|
||||
resp, err := s.doRequest(ctx, http.MethodPost, "/v2/CreateBucket", req)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Float64("duration_ms", msSince(start)).Str("outcome", "failure").Msg("garage create_bucket request failed")
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
log.Info().Float64("duration_ms", msSince(start)).Str("outcome", "success").Str("bucket_id", result.ID).Msg("bucket created")
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// UpdateBucket updates bucket settings
|
||||
// UpdateBucket updates bucket settings.
|
||||
func (s *GarageAdminService) UpdateBucket(ctx context.Context, bucketID string, req models.UpdateBucketRequest) (*models.GarageBucketInfo, error) {
|
||||
path := fmt.Sprintf("/v2/UpdateBucket?id=%s", bucketID)
|
||||
log := logpkg.FromCtx(ctx).With().
|
||||
Str("component", "admin").
|
||||
Str("operation", "update_bucket").
|
||||
Str("bucket_id", bucketID).
|
||||
Logger()
|
||||
|
||||
resp, err := s.doRequest(ctx, http.MethodPost, path, req)
|
||||
log.Info().Msg("updating bucket")
|
||||
start := time.Now()
|
||||
|
||||
resp, err := s.doRequest(ctx, http.MethodPost, fmt.Sprintf("/v2/UpdateBucket?id=%s", bucketID), req)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Float64("duration_ms", msSince(start)).Str("outcome", "failure").Msg("garage update_bucket request failed")
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
log.Info().Float64("duration_ms", msSince(start)).Str("outcome", "success").Msg("bucket updated")
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// DeleteBucket deletes a bucket
|
||||
// DeleteBucket deletes a bucket.
|
||||
func (s *GarageAdminService) DeleteBucket(ctx context.Context, bucketID string) error {
|
||||
path := fmt.Sprintf("/v2/DeleteBucket?id=%s", bucketID)
|
||||
log := logpkg.FromCtx(ctx).With().
|
||||
Str("component", "admin").
|
||||
Str("operation", "delete_bucket").
|
||||
Str("bucket_id", bucketID).
|
||||
Logger()
|
||||
|
||||
resp, err := s.doRequest(ctx, http.MethodPost, path, nil)
|
||||
log.Info().Msg("deleting bucket")
|
||||
start := time.Now()
|
||||
|
||||
resp, err := s.doRequest(ctx, http.MethodPost, fmt.Sprintf("/v2/DeleteBucket?id=%s", bucketID), nil)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Float64("duration_ms", msSince(start)).Str("outcome", "failure").Msg("garage delete_bucket request failed")
|
||||
return fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
log.Info().Float64("duration_ms", msSince(start)).Str("outcome", "success").Msg("bucket deleted")
|
||||
return nil
|
||||
}
|
||||
|
||||
// ====================================
|
||||
// Bucket Alias Operations
|
||||
// ====================================
|
||||
|
||||
// AddBucketAlias adds an alias to a bucket
|
||||
func (s *GarageAdminService) AddBucketAlias(ctx context.Context, req models.AddBucketAliasRequest) (*models.GarageBucketInfo, error) {
|
||||
resp, err := s.doRequest(ctx, http.MethodPost, "/v2/AddBucketAlias", req)
|
||||
@@ -313,22 +383,34 @@ func (s *GarageAdminService) RemoveBucketAlias(ctx context.Context, req models.R
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// ====================================
|
||||
// Permission Operations
|
||||
// ====================================
|
||||
|
||||
// AllowBucketKey grants permissions for a key on a bucket
|
||||
// AllowBucketKey grants permissions for a key on a bucket.
|
||||
func (s *GarageAdminService) AllowBucketKey(ctx context.Context, req models.BucketKeyPermRequest) (*models.GarageBucketInfo, error) {
|
||||
log := logpkg.FromCtx(ctx).With().
|
||||
Str("component", "admin").
|
||||
Str("operation", "allow_bucket_key").
|
||||
Str("bucket_id", req.BucketID).
|
||||
Str("access_key_id", logpkg.RedactKey(req.AccessKeyID)).
|
||||
Bool("perm_read", req.Permissions.Read).
|
||||
Bool("perm_write", req.Permissions.Write).
|
||||
Bool("perm_owner", req.Permissions.Owner).
|
||||
Logger()
|
||||
|
||||
log.Info().Msg("granting bucket key permissions")
|
||||
start := time.Now()
|
||||
|
||||
resp, err := s.doRequest(ctx, http.MethodPost, "/v2/AllowBucketKey", req)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Float64("duration_ms", msSince(start)).Str("outcome", "failure").Msg("garage allow_bucket_key request failed")
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
log.Info().Float64("duration_ms", msSince(start)).Str("outcome", "success").Msg("bucket key permissions granted")
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
@@ -347,10 +429,6 @@ func (s *GarageAdminService) DenyBucketKey(ctx context.Context, req models.Bucke
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// ====================================
|
||||
// Cluster Operations
|
||||
// ====================================
|
||||
|
||||
// GetClusterHealth returns the health status of the cluster
|
||||
func (s *GarageAdminService) GetClusterHealth(ctx context.Context) (*models.ClusterHealth, error) {
|
||||
resp, err := s.doRequest(ctx, http.MethodGet, "/v2/GetClusterHealth", nil)
|
||||
@@ -396,10 +474,6 @@ func (s *GarageAdminService) GetClusterStatistics(ctx context.Context) (*models.
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// ====================================
|
||||
// Node Operations
|
||||
// ====================================
|
||||
|
||||
// GetNodeInfo returns information about a specific node
|
||||
func (s *GarageAdminService) GetNodeInfo(ctx context.Context, nodeID string) (*models.MultiNodeResponse, error) {
|
||||
path := fmt.Sprintf("/v2/GetNodeInfo?node=%s", nodeID)
|
||||
@@ -434,10 +508,6 @@ func (s *GarageAdminService) GetNodeStatistics(ctx context.Context, nodeID strin
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// ====================================
|
||||
// Monitoring Operations
|
||||
// ====================================
|
||||
|
||||
// HealthCheck checks if the Admin API is reachable
|
||||
func (s *GarageAdminService) HealthCheck(ctx context.Context) error {
|
||||
resp, err := s.doRequest(ctx, http.MethodGet, "/health", nil)
|
||||
@@ -452,6 +522,11 @@ func (s *GarageAdminService) HealthCheck(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// msSince returns duration since t in milliseconds as a float64.
|
||||
func msSince(t time.Time) float64 {
|
||||
return float64(time.Since(t).Microseconds()) / 1000.0
|
||||
}
|
||||
|
||||
// GetMetrics returns Prometheus metrics from the Admin API
|
||||
func (s *GarageAdminService) GetMetrics(ctx context.Context) (string, error) {
|
||||
resp, err := s.doRequest(ctx, http.MethodGet, "/metrics", nil)
|
||||
@@ -0,0 +1,134 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"Noooste/garage-ui/internal/config"
|
||||
"Noooste/garage-ui/internal/models"
|
||||
logpkg "Noooste/garage-ui/pkg/logger"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
// newAdminWithServer creates a GarageAdminService pointed at a test server
|
||||
// and returns it with a cleanup hook.
|
||||
func newAdminWithServer(t *testing.T, handler http.HandlerFunc) *GarageAdminService {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(handler)
|
||||
t.Cleanup(srv.Close)
|
||||
cfg := &config.GarageConfig{
|
||||
AdminEndpoint: srv.URL,
|
||||
AdminToken: "test-token",
|
||||
}
|
||||
return NewGarageAdminService(cfg, "info")
|
||||
}
|
||||
|
||||
// ctxWithBufferLogger attaches a zerolog.Logger writing to buf onto ctx.
|
||||
func ctxWithBufferLogger(buf *bytes.Buffer) context.Context {
|
||||
l := zerolog.New(buf)
|
||||
return logpkg.IntoCtx(context.Background(), l)
|
||||
}
|
||||
|
||||
func parseJSONLines(t *testing.T, s string) []map[string]any {
|
||||
t.Helper()
|
||||
out := []map[string]any{}
|
||||
for _, line := range strings.Split(s, "\n") {
|
||||
if strings.TrimSpace(line) == "" {
|
||||
continue
|
||||
}
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal([]byte(line), &m); err != nil {
|
||||
t.Fatalf("not JSON: %v — %s", err, line)
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestCreateBucket_SuccessLogsInfoWithFields(t *testing.T) {
|
||||
admin := newAdminWithServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(models.GarageBucketInfo{ID: "bkt-123"})
|
||||
})
|
||||
|
||||
var buf bytes.Buffer
|
||||
ctx := ctxWithBufferLogger(&buf)
|
||||
|
||||
alias := "my-bucket"
|
||||
if _, err := admin.CreateBucket(ctx, models.CreateBucketAdminRequest{GlobalAlias: &alias}); err != nil {
|
||||
t.Fatalf("CreateBucket: %v", err)
|
||||
}
|
||||
|
||||
lines := parseJSONLines(t, buf.String())
|
||||
if len(lines) < 2 {
|
||||
t.Fatalf("expected >=2 lines (start + success), got %d: %s", len(lines), buf.String())
|
||||
}
|
||||
|
||||
final := lines[len(lines)-1]
|
||||
if final["component"] != "admin" {
|
||||
t.Errorf("component = %v, want admin", final["component"])
|
||||
}
|
||||
if final["operation"] != "create_bucket" {
|
||||
t.Errorf("operation = %v, want create_bucket", final["operation"])
|
||||
}
|
||||
if final["outcome"] != "success" {
|
||||
t.Errorf("outcome = %v, want success", final["outcome"])
|
||||
}
|
||||
if final["level"] != "info" {
|
||||
t.Errorf("level = %v, want info", final["level"])
|
||||
}
|
||||
if _, ok := final["duration_ms"].(float64); !ok {
|
||||
t.Errorf("missing duration_ms: %v", final)
|
||||
}
|
||||
if final["bucket_id"] != "bkt-123" {
|
||||
t.Errorf("bucket_id = %v, want bkt-123", final["bucket_id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateBucket_FailureLogsError(t *testing.T) {
|
||||
admin := newAdminWithServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusConflict)
|
||||
_, _ = w.Write([]byte(`{"error":"already exists"}`))
|
||||
})
|
||||
|
||||
var buf bytes.Buffer
|
||||
ctx := ctxWithBufferLogger(&buf)
|
||||
|
||||
alias := "duplicate"
|
||||
_, err := admin.CreateBucket(ctx, models.CreateBucketAdminRequest{GlobalAlias: &alias})
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
|
||||
lines := parseJSONLines(t, buf.String())
|
||||
var failure map[string]any
|
||||
for _, line := range lines {
|
||||
if line["level"] == "error" {
|
||||
failure = line
|
||||
break
|
||||
}
|
||||
}
|
||||
if failure == nil {
|
||||
t.Fatalf("no error-level log line: %s", buf.String())
|
||||
}
|
||||
|
||||
if failure["outcome"] != "failure" {
|
||||
t.Errorf("outcome = %v, want failure", failure["outcome"])
|
||||
}
|
||||
if failure["operation"] != "create_bucket" {
|
||||
t.Errorf("operation = %v, want create_bucket", failure["operation"])
|
||||
}
|
||||
if _, ok := failure["error"].(string); !ok {
|
||||
t.Errorf("missing error field: %v", failure)
|
||||
}
|
||||
if _, ok := failure["duration_ms"].(float64); !ok {
|
||||
t.Errorf("missing duration_ms: %v", failure)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,565 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"Noooste/garage-ui/internal/config"
|
||||
"Noooste/garage-ui/internal/models"
|
||||
)
|
||||
|
||||
// newAdminTestServer wires an httptest.Server (with the supplied handler) to a
|
||||
// fresh *GarageAdminService configured with a known bearer token.
|
||||
func newAdminTestServer(t *testing.T, handler http.Handler) (*GarageAdminService, *httptest.Server) {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(handler)
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
svc := NewGarageAdminService(&config.GarageConfig{
|
||||
AdminEndpoint: srv.URL,
|
||||
AdminToken: "test-token-xyz",
|
||||
}, "")
|
||||
return svc, srv
|
||||
}
|
||||
|
||||
// jsonHandler returns an http.Handler that writes the supplied status code and
|
||||
// JSON-encoded body.
|
||||
func jsonHandler(t *testing.T, status int, body any) http.Handler {
|
||||
t.Helper()
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
if body == nil {
|
||||
return
|
||||
}
|
||||
if err := json.NewEncoder(w).Encode(body); err != nil {
|
||||
t.Errorf("server: encode response: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type recordedRequest struct {
|
||||
method string
|
||||
path string
|
||||
rawURL string
|
||||
auth string
|
||||
body []byte
|
||||
}
|
||||
|
||||
func recordingHandler(t *testing.T, status int, respBody any) (http.Handler, *recordedRequest) {
|
||||
t.Helper()
|
||||
rec := &recordedRequest{}
|
||||
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
rec.method = r.Method
|
||||
rec.path = r.URL.Path
|
||||
rec.rawURL = r.URL.RequestURI()
|
||||
rec.auth = r.Header.Get("Authorization")
|
||||
if r.Body != nil {
|
||||
b, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
t.Errorf("server: read body: %v", err)
|
||||
}
|
||||
rec.body = b
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
if respBody != nil {
|
||||
_ = json.NewEncoder(w).Encode(respBody)
|
||||
}
|
||||
})
|
||||
return h, rec
|
||||
}
|
||||
|
||||
func TestHealthCheck_Success(t *testing.T) {
|
||||
h, rec := recordingHandler(t, http.StatusOK, nil)
|
||||
svc, _ := newAdminTestServer(t, h)
|
||||
|
||||
if err := svc.HealthCheck(context.Background()); err != nil {
|
||||
t.Fatalf("HealthCheck: %v", err)
|
||||
}
|
||||
if rec.method != http.MethodGet {
|
||||
t.Errorf("method = %q, want GET", rec.method)
|
||||
}
|
||||
if rec.path != "/health" {
|
||||
t.Errorf("path = %q, want /health", rec.path)
|
||||
}
|
||||
if rec.auth != "Bearer test-token-xyz" {
|
||||
t.Errorf("Authorization = %q, want Bearer test-token-xyz", rec.auth)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthCheck_Non2xxReturnsError(t *testing.T) {
|
||||
h := jsonHandler(t, http.StatusServiceUnavailable, map[string]string{"error": "down"})
|
||||
svc, _ := newAdminTestServer(t, h)
|
||||
|
||||
err := svc.HealthCheck(context.Background())
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListKeys_Success(t *testing.T) {
|
||||
want := []models.ListKeysResponseItem{
|
||||
{ID: "k1", Name: "alice", Expired: false},
|
||||
{ID: "k2", Name: "bob", Expired: true},
|
||||
}
|
||||
h, rec := recordingHandler(t, http.StatusOK, want)
|
||||
svc, _ := newAdminTestServer(t, h)
|
||||
|
||||
got, err := svc.ListKeys(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("ListKeys: %v", err)
|
||||
}
|
||||
if rec.method != http.MethodGet || rec.path != "/v2/ListKeys" {
|
||||
t.Errorf("request = %s %s, want GET /v2/ListKeys", rec.method, rec.path)
|
||||
}
|
||||
if len(got) != 2 || got[0].ID != "k1" || got[1].Name != "bob" {
|
||||
t.Errorf("got %+v, want %+v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateKey_PostsBodyAndDecodesResponse(t *testing.T) {
|
||||
name := "service-account"
|
||||
want := &models.GarageKeyInfo{
|
||||
AccessKeyID: "GK123",
|
||||
Name: name,
|
||||
}
|
||||
h, rec := recordingHandler(t, http.StatusOK, want)
|
||||
svc, _ := newAdminTestServer(t, h)
|
||||
|
||||
req := models.CreateKeyRequest{Name: &name, NeverExpires: true}
|
||||
got, err := svc.CreateKey(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateKey: %v", err)
|
||||
}
|
||||
if rec.method != http.MethodPost || rec.path != "/v2/CreateKey" {
|
||||
t.Errorf("request = %s %s", rec.method, rec.path)
|
||||
}
|
||||
var sent models.CreateKeyRequest
|
||||
if err := json.Unmarshal(rec.body, &sent); err != nil {
|
||||
t.Fatalf("unmarshal sent body: %v (raw=%q)", err, rec.body)
|
||||
}
|
||||
if sent.Name == nil || *sent.Name != name {
|
||||
t.Errorf("sent name = %v, want %q", sent.Name, name)
|
||||
}
|
||||
if !sent.NeverExpires {
|
||||
t.Errorf("NeverExpires should round-trip true")
|
||||
}
|
||||
if got.AccessKeyID != want.AccessKeyID {
|
||||
t.Errorf("AccessKeyID = %q, want %q", got.AccessKeyID, want.AccessKeyID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetKeyInfo_PassesShowSecretQueryParam(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
showSecret bool
|
||||
wantInRawURL string
|
||||
}{
|
||||
{"without secret", false, "?id=ABC"},
|
||||
{"with secret", true, "?id=ABC&showSecretKey=true"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
h, rec := recordingHandler(t, http.StatusOK, &models.GarageKeyInfo{AccessKeyID: "ABC"})
|
||||
svc, _ := newAdminTestServer(t, h)
|
||||
|
||||
_, err := svc.GetKeyInfo(context.Background(), "ABC", tc.showSecret)
|
||||
if err != nil {
|
||||
t.Fatalf("GetKeyInfo: %v", err)
|
||||
}
|
||||
if rec.method != http.MethodGet {
|
||||
t.Errorf("method = %q, want GET", rec.method)
|
||||
}
|
||||
if !strings.Contains(rec.rawURL, tc.wantInRawURL) {
|
||||
t.Errorf("raw URL = %q, want substring %q", rec.rawURL, tc.wantInRawURL)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateKey_UsesIDInQuery(t *testing.T) {
|
||||
h, rec := recordingHandler(t, http.StatusOK, &models.GarageKeyInfo{AccessKeyID: "K"})
|
||||
svc, _ := newAdminTestServer(t, h)
|
||||
|
||||
if _, err := svc.UpdateKey(context.Background(), "K", models.UpdateKeyRequest{}); err != nil {
|
||||
t.Fatalf("UpdateKey: %v", err)
|
||||
}
|
||||
if rec.method != http.MethodPost {
|
||||
t.Errorf("method = %q, want POST", rec.method)
|
||||
}
|
||||
if !strings.Contains(rec.rawURL, "id=K") {
|
||||
t.Errorf("raw URL = %q, want substring id=K", rec.rawURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteKey_NoBodyOnSuccess(t *testing.T) {
|
||||
h, rec := recordingHandler(t, http.StatusOK, nil)
|
||||
svc, _ := newAdminTestServer(t, h)
|
||||
|
||||
if err := svc.DeleteKey(context.Background(), "K"); err != nil {
|
||||
t.Fatalf("DeleteKey: %v", err)
|
||||
}
|
||||
if rec.method != http.MethodPost {
|
||||
t.Errorf("method = %q, want POST", rec.method)
|
||||
}
|
||||
if !strings.Contains(rec.rawURL, "id=K") {
|
||||
t.Errorf("raw URL = %q", rec.rawURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportKey_PostsToImportEndpoint(t *testing.T) {
|
||||
want := &models.GarageKeyInfo{AccessKeyID: "imported"}
|
||||
h, rec := recordingHandler(t, http.StatusOK, want)
|
||||
svc, _ := newAdminTestServer(t, h)
|
||||
|
||||
req := models.ImportKeyRequest{
|
||||
AccessKeyID: "imported",
|
||||
SecretAccessKey: "shhh",
|
||||
}
|
||||
got, err := svc.ImportKey(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatalf("ImportKey: %v", err)
|
||||
}
|
||||
if rec.path != "/v2/ImportKey" {
|
||||
t.Errorf("path = %q", rec.path)
|
||||
}
|
||||
if got.AccessKeyID != "imported" {
|
||||
t.Errorf("AccessKeyID = %q, want imported", got.AccessKeyID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListBuckets_Success(t *testing.T) {
|
||||
want := []models.ListBucketsResponseItem{{ID: "b1"}, {ID: "b2"}}
|
||||
h, rec := recordingHandler(t, http.StatusOK, want)
|
||||
svc, _ := newAdminTestServer(t, h)
|
||||
|
||||
got, err := svc.ListBuckets(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("ListBuckets: %v", err)
|
||||
}
|
||||
if rec.path != "/v2/ListBuckets" {
|
||||
t.Errorf("path = %q", rec.path)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Errorf("len = %d, want 2", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetBucketInfo_UsesIDQuery(t *testing.T) {
|
||||
h, rec := recordingHandler(t, http.StatusOK, &models.GarageBucketInfo{ID: "B"})
|
||||
svc, _ := newAdminTestServer(t, h)
|
||||
if _, err := svc.GetBucketInfo(context.Background(), "B"); err != nil {
|
||||
t.Fatalf("GetBucketInfo: %v", err)
|
||||
}
|
||||
if !strings.Contains(rec.rawURL, "id=B") {
|
||||
t.Errorf("raw URL = %q", rec.rawURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetBucketInfoByAlias_UsesGlobalAliasQuery(t *testing.T) {
|
||||
h, rec := recordingHandler(t, http.StatusOK, &models.GarageBucketInfo{ID: "B", GlobalAliases: []string{"my-bucket"}})
|
||||
svc, _ := newAdminTestServer(t, h)
|
||||
got, err := svc.GetBucketInfoByAlias(context.Background(), "my-bucket")
|
||||
if err != nil {
|
||||
t.Fatalf("GetBucketInfoByAlias: %v", err)
|
||||
}
|
||||
if !strings.Contains(rec.rawURL, "globalAlias=my-bucket") {
|
||||
t.Errorf("raw URL = %q", rec.rawURL)
|
||||
}
|
||||
if got.ID != "B" {
|
||||
t.Errorf("ID = %q, want B", got.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateBucket_PostsBody(t *testing.T) {
|
||||
want := &models.GarageBucketInfo{ID: "new"}
|
||||
h, rec := recordingHandler(t, http.StatusOK, want)
|
||||
svc, _ := newAdminTestServer(t, h)
|
||||
|
||||
if _, err := svc.CreateBucket(context.Background(), models.CreateBucketAdminRequest{}); err != nil {
|
||||
t.Fatalf("CreateBucket: %v", err)
|
||||
}
|
||||
if rec.method != http.MethodPost || rec.path != "/v2/CreateBucket" {
|
||||
t.Errorf("request = %s %s", rec.method, rec.path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateBucket_UsesIDQueryAndPostsBody(t *testing.T) {
|
||||
h, rec := recordingHandler(t, http.StatusOK, &models.GarageBucketInfo{ID: "B"})
|
||||
svc, _ := newAdminTestServer(t, h)
|
||||
if _, err := svc.UpdateBucket(context.Background(), "B", models.UpdateBucketRequest{}); err != nil {
|
||||
t.Fatalf("UpdateBucket: %v", err)
|
||||
}
|
||||
if rec.method != http.MethodPost {
|
||||
t.Errorf("method = %q", rec.method)
|
||||
}
|
||||
if !strings.Contains(rec.rawURL, "id=B") {
|
||||
t.Errorf("raw URL = %q", rec.rawURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteBucket_PostWithIDQuery(t *testing.T) {
|
||||
h, rec := recordingHandler(t, http.StatusOK, nil)
|
||||
svc, _ := newAdminTestServer(t, h)
|
||||
if err := svc.DeleteBucket(context.Background(), "B"); err != nil {
|
||||
t.Fatalf("DeleteBucket: %v", err)
|
||||
}
|
||||
if rec.method != http.MethodPost {
|
||||
t.Errorf("method = %q", rec.method)
|
||||
}
|
||||
if !strings.Contains(rec.rawURL, "id=B") {
|
||||
t.Errorf("raw URL = %q", rec.rawURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBucketAliasAndPermissionEndpoints(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
fn func(s *GarageAdminService) error
|
||||
path string
|
||||
}{
|
||||
{
|
||||
name: "AddBucketAlias",
|
||||
fn: func(s *GarageAdminService) error {
|
||||
_, err := s.AddBucketAlias(context.Background(), models.AddBucketAliasRequest{})
|
||||
return err
|
||||
},
|
||||
path: "/v2/AddBucketAlias",
|
||||
},
|
||||
{
|
||||
name: "RemoveBucketAlias",
|
||||
fn: func(s *GarageAdminService) error {
|
||||
_, err := s.RemoveBucketAlias(context.Background(), models.RemoveBucketAliasRequest{})
|
||||
return err
|
||||
},
|
||||
path: "/v2/RemoveBucketAlias",
|
||||
},
|
||||
{
|
||||
name: "AllowBucketKey",
|
||||
fn: func(s *GarageAdminService) error {
|
||||
_, err := s.AllowBucketKey(context.Background(), models.BucketKeyPermRequest{})
|
||||
return err
|
||||
},
|
||||
path: "/v2/AllowBucketKey",
|
||||
},
|
||||
{
|
||||
name: "DenyBucketKey",
|
||||
fn: func(s *GarageAdminService) error {
|
||||
_, err := s.DenyBucketKey(context.Background(), models.BucketKeyPermRequest{})
|
||||
return err
|
||||
},
|
||||
path: "/v2/DenyBucketKey",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
h, rec := recordingHandler(t, http.StatusOK, &models.GarageBucketInfo{ID: "B"})
|
||||
svc, _ := newAdminTestServer(t, h)
|
||||
if err := tc.fn(svc); err != nil {
|
||||
t.Fatalf("%s: %v", tc.name, err)
|
||||
}
|
||||
if rec.path != tc.path || rec.method != http.MethodPost {
|
||||
t.Errorf("request = %s %s, want POST %s", rec.method, rec.path, tc.path)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClusterEndpoints(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
fn func(s *GarageAdminService) error
|
||||
path string
|
||||
}{
|
||||
{
|
||||
name: "GetClusterHealth",
|
||||
fn: func(s *GarageAdminService) error {
|
||||
_, err := s.GetClusterHealth(context.Background())
|
||||
return err
|
||||
},
|
||||
path: "/v2/GetClusterHealth",
|
||||
},
|
||||
{
|
||||
name: "GetClusterStatus",
|
||||
fn: func(s *GarageAdminService) error {
|
||||
_, err := s.GetClusterStatus(context.Background())
|
||||
return err
|
||||
},
|
||||
path: "/v2/GetClusterStatus",
|
||||
},
|
||||
{
|
||||
name: "GetClusterStatistics",
|
||||
fn: func(s *GarageAdminService) error {
|
||||
_, err := s.GetClusterStatistics(context.Background())
|
||||
return err
|
||||
},
|
||||
path: "/v2/GetClusterStatistics",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
h, rec := recordingHandler(t, http.StatusOK, struct{}{})
|
||||
svc, _ := newAdminTestServer(t, h)
|
||||
if err := tc.fn(svc); err != nil {
|
||||
t.Fatalf("%s: %v", tc.name, err)
|
||||
}
|
||||
if rec.method != http.MethodGet {
|
||||
t.Errorf("method = %q, want GET", rec.method)
|
||||
}
|
||||
if rec.path != tc.path {
|
||||
t.Errorf("path = %q, want %q", rec.path, tc.path)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeEndpoints_NodeIDInQuery(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
fn func(s *GarageAdminService, id string) error
|
||||
path string
|
||||
}{
|
||||
{
|
||||
name: "GetNodeInfo",
|
||||
fn: func(s *GarageAdminService, id string) error {
|
||||
_, err := s.GetNodeInfo(context.Background(), id)
|
||||
return err
|
||||
},
|
||||
path: "/v2/GetNodeInfo",
|
||||
},
|
||||
{
|
||||
name: "GetNodeStatistics",
|
||||
fn: func(s *GarageAdminService, id string) error {
|
||||
_, err := s.GetNodeStatistics(context.Background(), id)
|
||||
return err
|
||||
},
|
||||
path: "/v2/GetNodeStatistics",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
h, rec := recordingHandler(t, http.StatusOK, struct{}{})
|
||||
svc, _ := newAdminTestServer(t, h)
|
||||
if err := tc.fn(svc, "node-7"); err != nil {
|
||||
t.Fatalf("%s: %v", tc.name, err)
|
||||
}
|
||||
if rec.path != tc.path {
|
||||
t.Errorf("path = %q, want %q", rec.path, tc.path)
|
||||
}
|
||||
if !strings.Contains(rec.rawURL, "node=node-7") {
|
||||
t.Errorf("raw URL = %q, want substring node=node-7", rec.rawURL)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetMetrics_ReturnsRawBodyOn2xx(t *testing.T) {
|
||||
body := "# HELP garage_up Number of running nodes\ngarage_up 3\n"
|
||||
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = io.WriteString(w, body)
|
||||
})
|
||||
svc, _ := newAdminTestServer(t, h)
|
||||
|
||||
got, err := svc.GetMetrics(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("GetMetrics: %v", err)
|
||||
}
|
||||
if got != body {
|
||||
t.Errorf("GetMetrics body mismatch:\n got %q\nwant %q", got, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetMetrics_Non2xxReturnsErrorWithStatus(t *testing.T) {
|
||||
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
_, _ = io.WriteString(w, "forbidden")
|
||||
})
|
||||
svc, _ := newAdminTestServer(t, h)
|
||||
|
||||
_, err := svc.GetMetrics(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("expected error for 403, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "403") {
|
||||
t.Errorf("error should mention 403, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoRequest_Non2xxBodyEchoedInError(t *testing.T) {
|
||||
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = io.WriteString(w, `{"error":"bad request"}`)
|
||||
})
|
||||
svc, _ := newAdminTestServer(t, h)
|
||||
|
||||
_, err := svc.ListKeys(context.Background())
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoRequest_MalformedJSONReturnsDecodeError(t *testing.T) {
|
||||
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = io.WriteString(w, "not-json-at-all")
|
||||
})
|
||||
svc, _ := newAdminTestServer(t, h)
|
||||
|
||||
_, err := svc.ListKeys(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("expected decode error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "decode") {
|
||||
t.Errorf("error %v should mention decoding", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoRequest_RetriesExhaustOnConnectionRefused(t *testing.T) {
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
addr := listener.Addr().String()
|
||||
if err := listener.Close(); err != nil {
|
||||
t.Fatalf("close listener: %v", err)
|
||||
}
|
||||
|
||||
svc := NewGarageAdminService(&config.GarageConfig{
|
||||
AdminEndpoint: "http://" + addr,
|
||||
AdminToken: "irrelevant",
|
||||
}, "")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
start := time.Now()
|
||||
_, err = svc.ListKeys(ctx)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected error after retries exhaust, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "max retries") {
|
||||
t.Errorf("expected max-retries error, got %v", err)
|
||||
}
|
||||
if elapsed < 200*time.Millisecond {
|
||||
t.Errorf("retries returned in %v — backoff loop not engaged", elapsed)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"Noooste/garage-ui/internal/models"
|
||||
)
|
||||
|
||||
// AdminService is the set of Garage Admin API operations used by HTTP handlers.
|
||||
// It is implemented by *GarageAdminService in admin.go. Kept narrow so that
|
||||
// hand-rolled mocks in tests don't need to cover admin methods the handlers
|
||||
// never call.
|
||||
type AdminService interface {
|
||||
// Access keys
|
||||
ListKeys(ctx context.Context) ([]models.ListKeysResponseItem, error)
|
||||
CreateKey(ctx context.Context, req models.CreateKeyRequest) (*models.GarageKeyInfo, error)
|
||||
GetKeyInfo(ctx context.Context, keyID string, showSecret bool) (*models.GarageKeyInfo, error)
|
||||
UpdateKey(ctx context.Context, keyID string, req models.UpdateKeyRequest) (*models.GarageKeyInfo, error)
|
||||
DeleteKey(ctx context.Context, keyID string) error
|
||||
|
||||
// Buckets
|
||||
ListBuckets(ctx context.Context) ([]models.ListBucketsResponseItem, error)
|
||||
GetBucketInfo(ctx context.Context, bucketID string) (*models.GarageBucketInfo, error)
|
||||
GetBucketInfoByAlias(ctx context.Context, globalAlias string) (*models.GarageBucketInfo, error)
|
||||
CreateBucket(ctx context.Context, req models.CreateBucketAdminRequest) (*models.GarageBucketInfo, error)
|
||||
UpdateBucket(ctx context.Context, bucketID string, req models.UpdateBucketRequest) (*models.GarageBucketInfo, error)
|
||||
DeleteBucket(ctx context.Context, bucketID string) error
|
||||
AllowBucketKey(ctx context.Context, req models.BucketKeyPermRequest) (*models.GarageBucketInfo, error)
|
||||
|
||||
// Cluster
|
||||
GetClusterHealth(ctx context.Context) (*models.ClusterHealth, error)
|
||||
GetClusterStatus(ctx context.Context) (*models.ClusterStatus, error)
|
||||
GetClusterStatistics(ctx context.Context) (*models.ClusterStatistics, error)
|
||||
GetNodeInfo(ctx context.Context, nodeID string) (*models.MultiNodeResponse, error)
|
||||
GetNodeStatistics(ctx context.Context, nodeID string) (*models.MultiNodeResponse, error)
|
||||
|
||||
// Monitoring
|
||||
HealthCheck(ctx context.Context) error
|
||||
GetMetrics(ctx context.Context) (string, error)
|
||||
}
|
||||
|
||||
// S3Storage is the set of S3 operations used by HTTP handlers. It is
|
||||
// implemented by *S3Service in s3.go. Methods on *S3Service that are not
|
||||
// called by handlers (ListBuckets, CreateBucket, DeleteBucket,
|
||||
// GetBucketStatistics) are intentionally excluded.
|
||||
type S3Storage interface {
|
||||
ListObjects(ctx context.Context, bucketName, prefix string, maxKeys int, continuationToken string) (*models.ObjectListResponse, error)
|
||||
UploadObject(ctx context.Context, bucketName, key string, body io.Reader, contentType string) (*models.ObjectUploadResponse, error)
|
||||
CreateDirectoryMarker(ctx context.Context, bucketName, key string) (*models.ObjectUploadResponse, error)
|
||||
GetObject(ctx context.Context, bucketName, key string) (io.ReadCloser, *models.ObjectInfo, error)
|
||||
ObjectExists(ctx context.Context, bucketName, key string) (bool, error)
|
||||
DeleteObject(ctx context.Context, bucketName, key string) error
|
||||
GetObjectMetadata(ctx context.Context, bucketName, key string) (*models.ObjectInfo, error)
|
||||
GetPresignedURL(ctx context.Context, bucketName, key string, expiresIn time.Duration) (string, error)
|
||||
DeleteMultipleObjects(ctx context.Context, bucketName string, keys []string) error
|
||||
UploadMultipleObjects(ctx context.Context, bucketName string, files []struct {
|
||||
Key string
|
||||
Body io.Reader
|
||||
ContentType string
|
||||
}) []UploadResult
|
||||
}
|
||||
|
||||
// Compile-time guarantees that the concrete services implement the interfaces.
|
||||
var (
|
||||
_ AdminService = (*GarageAdminService)(nil)
|
||||
_ S3Storage = (*S3Service)(nil)
|
||||
)
|
||||
@@ -0,0 +1,229 @@
|
||||
// Package mocks provides hand-rolled test doubles for the interfaces declared
|
||||
// in package services. Each mock is configured per-test by overriding the
|
||||
// function fields; unset fields return a sentinel error so missing setup is
|
||||
// surfaced loudly.
|
||||
package mocks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"Noooste/garage-ui/internal/models"
|
||||
"Noooste/garage-ui/internal/services"
|
||||
)
|
||||
|
||||
// errNotConfigured is returned by any AdminMock method whose function field
|
||||
// was not set by the test. The message names the missing method so test
|
||||
// failures point at the gap instead of looking like a real service error.
|
||||
func errNotConfigured(method string) error {
|
||||
return fmt.Errorf("AdminMock.%s: not configured by test", method)
|
||||
}
|
||||
|
||||
// Compile-time guarantee that AdminMock satisfies services.AdminService.
|
||||
var _ services.AdminService = (*AdminMock)(nil)
|
||||
|
||||
// AdminMock is a hand-rolled mock of services.AdminService. Tests assign the
|
||||
// per-method function fields they care about; unset methods return
|
||||
// errNotConfigured.
|
||||
type AdminMock struct {
|
||||
// Access keys
|
||||
ListKeysFn func(ctx context.Context) ([]models.ListKeysResponseItem, error)
|
||||
CreateKeyFn func(ctx context.Context, req models.CreateKeyRequest) (*models.GarageKeyInfo, error)
|
||||
GetKeyInfoFn func(ctx context.Context, keyID string, showSecret bool) (*models.GarageKeyInfo, error)
|
||||
UpdateKeyFn func(ctx context.Context, keyID string, req models.UpdateKeyRequest) (*models.GarageKeyInfo, error)
|
||||
DeleteKeyFn func(ctx context.Context, keyID string) error
|
||||
|
||||
// Buckets
|
||||
ListBucketsFn func(ctx context.Context) ([]models.ListBucketsResponseItem, error)
|
||||
GetBucketInfoFn func(ctx context.Context, bucketID string) (*models.GarageBucketInfo, error)
|
||||
GetBucketInfoByAliasFn func(ctx context.Context, alias string) (*models.GarageBucketInfo, error)
|
||||
CreateBucketFn func(ctx context.Context, req models.CreateBucketAdminRequest) (*models.GarageBucketInfo, error)
|
||||
UpdateBucketFn func(ctx context.Context, bucketID string, req models.UpdateBucketRequest) (*models.GarageBucketInfo, error)
|
||||
DeleteBucketFn func(ctx context.Context, bucketID string) error
|
||||
AllowBucketKeyFn func(ctx context.Context, req models.BucketKeyPermRequest) (*models.GarageBucketInfo, error)
|
||||
|
||||
// Cluster
|
||||
GetClusterHealthFn func(ctx context.Context) (*models.ClusterHealth, error)
|
||||
GetClusterStatusFn func(ctx context.Context) (*models.ClusterStatus, error)
|
||||
GetClusterStatisticsFn func(ctx context.Context) (*models.ClusterStatistics, error)
|
||||
GetNodeInfoFn func(ctx context.Context, nodeID string) (*models.MultiNodeResponse, error)
|
||||
GetNodeStatisticsFn func(ctx context.Context, nodeID string) (*models.MultiNodeResponse, error)
|
||||
|
||||
// Monitoring
|
||||
HealthCheckFn func(ctx context.Context) error
|
||||
GetMetricsFn func(ctx context.Context) (string, error)
|
||||
|
||||
// Calls records every invocation in order. Tests can inspect this slice to
|
||||
// assert call sequence, argument values, or call count.
|
||||
Calls []Call
|
||||
}
|
||||
|
||||
// Call captures a single invocation of an AdminMock method.
|
||||
type Call struct {
|
||||
Method string
|
||||
Args []any
|
||||
}
|
||||
|
||||
func (m *AdminMock) record(method string, args ...any) {
|
||||
m.Calls = append(m.Calls, Call{Method: method, Args: args})
|
||||
}
|
||||
|
||||
// --- Access keys ---
|
||||
|
||||
func (m *AdminMock) ListKeys(ctx context.Context) ([]models.ListKeysResponseItem, error) {
|
||||
m.record("ListKeys")
|
||||
if m.ListKeysFn == nil {
|
||||
return nil, errNotConfigured("ListKeys")
|
||||
}
|
||||
return m.ListKeysFn(ctx)
|
||||
}
|
||||
|
||||
func (m *AdminMock) CreateKey(ctx context.Context, req models.CreateKeyRequest) (*models.GarageKeyInfo, error) {
|
||||
m.record("CreateKey", req)
|
||||
if m.CreateKeyFn == nil {
|
||||
return nil, errNotConfigured("CreateKey")
|
||||
}
|
||||
return m.CreateKeyFn(ctx, req)
|
||||
}
|
||||
|
||||
func (m *AdminMock) GetKeyInfo(ctx context.Context, keyID string, showSecret bool) (*models.GarageKeyInfo, error) {
|
||||
m.record("GetKeyInfo", keyID, showSecret)
|
||||
if m.GetKeyInfoFn == nil {
|
||||
return nil, errNotConfigured("GetKeyInfo")
|
||||
}
|
||||
return m.GetKeyInfoFn(ctx, keyID, showSecret)
|
||||
}
|
||||
|
||||
func (m *AdminMock) UpdateKey(ctx context.Context, keyID string, req models.UpdateKeyRequest) (*models.GarageKeyInfo, error) {
|
||||
m.record("UpdateKey", keyID, req)
|
||||
if m.UpdateKeyFn == nil {
|
||||
return nil, errNotConfigured("UpdateKey")
|
||||
}
|
||||
return m.UpdateKeyFn(ctx, keyID, req)
|
||||
}
|
||||
|
||||
func (m *AdminMock) DeleteKey(ctx context.Context, keyID string) error {
|
||||
m.record("DeleteKey", keyID)
|
||||
if m.DeleteKeyFn == nil {
|
||||
return errNotConfigured("DeleteKey")
|
||||
}
|
||||
return m.DeleteKeyFn(ctx, keyID)
|
||||
}
|
||||
|
||||
// --- Buckets ---
|
||||
|
||||
func (m *AdminMock) ListBuckets(ctx context.Context) ([]models.ListBucketsResponseItem, error) {
|
||||
m.record("ListBuckets")
|
||||
if m.ListBucketsFn == nil {
|
||||
return nil, errNotConfigured("ListBuckets")
|
||||
}
|
||||
return m.ListBucketsFn(ctx)
|
||||
}
|
||||
|
||||
func (m *AdminMock) GetBucketInfo(ctx context.Context, bucketID string) (*models.GarageBucketInfo, error) {
|
||||
m.record("GetBucketInfo", bucketID)
|
||||
if m.GetBucketInfoFn == nil {
|
||||
return nil, errNotConfigured("GetBucketInfo")
|
||||
}
|
||||
return m.GetBucketInfoFn(ctx, bucketID)
|
||||
}
|
||||
|
||||
func (m *AdminMock) GetBucketInfoByAlias(ctx context.Context, alias string) (*models.GarageBucketInfo, error) {
|
||||
m.record("GetBucketInfoByAlias", alias)
|
||||
if m.GetBucketInfoByAliasFn == nil {
|
||||
return nil, errNotConfigured("GetBucketInfoByAlias")
|
||||
}
|
||||
return m.GetBucketInfoByAliasFn(ctx, alias)
|
||||
}
|
||||
|
||||
func (m *AdminMock) CreateBucket(ctx context.Context, req models.CreateBucketAdminRequest) (*models.GarageBucketInfo, error) {
|
||||
m.record("CreateBucket", req)
|
||||
if m.CreateBucketFn == nil {
|
||||
return nil, errNotConfigured("CreateBucket")
|
||||
}
|
||||
return m.CreateBucketFn(ctx, req)
|
||||
}
|
||||
|
||||
func (m *AdminMock) UpdateBucket(ctx context.Context, bucketID string, req models.UpdateBucketRequest) (*models.GarageBucketInfo, error) {
|
||||
m.record("UpdateBucket", bucketID, req)
|
||||
if m.UpdateBucketFn == nil {
|
||||
return nil, errNotConfigured("UpdateBucket")
|
||||
}
|
||||
return m.UpdateBucketFn(ctx, bucketID, req)
|
||||
}
|
||||
|
||||
func (m *AdminMock) DeleteBucket(ctx context.Context, bucketID string) error {
|
||||
m.record("DeleteBucket", bucketID)
|
||||
if m.DeleteBucketFn == nil {
|
||||
return errNotConfigured("DeleteBucket")
|
||||
}
|
||||
return m.DeleteBucketFn(ctx, bucketID)
|
||||
}
|
||||
|
||||
func (m *AdminMock) AllowBucketKey(ctx context.Context, req models.BucketKeyPermRequest) (*models.GarageBucketInfo, error) {
|
||||
m.record("AllowBucketKey", req)
|
||||
if m.AllowBucketKeyFn == nil {
|
||||
return nil, errNotConfigured("AllowBucketKey")
|
||||
}
|
||||
return m.AllowBucketKeyFn(ctx, req)
|
||||
}
|
||||
|
||||
// --- Cluster ---
|
||||
|
||||
func (m *AdminMock) GetClusterHealth(ctx context.Context) (*models.ClusterHealth, error) {
|
||||
m.record("GetClusterHealth")
|
||||
if m.GetClusterHealthFn == nil {
|
||||
return nil, errNotConfigured("GetClusterHealth")
|
||||
}
|
||||
return m.GetClusterHealthFn(ctx)
|
||||
}
|
||||
|
||||
func (m *AdminMock) GetClusterStatus(ctx context.Context) (*models.ClusterStatus, error) {
|
||||
m.record("GetClusterStatus")
|
||||
if m.GetClusterStatusFn == nil {
|
||||
return nil, errNotConfigured("GetClusterStatus")
|
||||
}
|
||||
return m.GetClusterStatusFn(ctx)
|
||||
}
|
||||
|
||||
func (m *AdminMock) GetClusterStatistics(ctx context.Context) (*models.ClusterStatistics, error) {
|
||||
m.record("GetClusterStatistics")
|
||||
if m.GetClusterStatisticsFn == nil {
|
||||
return nil, errNotConfigured("GetClusterStatistics")
|
||||
}
|
||||
return m.GetClusterStatisticsFn(ctx)
|
||||
}
|
||||
|
||||
func (m *AdminMock) GetNodeInfo(ctx context.Context, nodeID string) (*models.MultiNodeResponse, error) {
|
||||
m.record("GetNodeInfo", nodeID)
|
||||
if m.GetNodeInfoFn == nil {
|
||||
return nil, errNotConfigured("GetNodeInfo")
|
||||
}
|
||||
return m.GetNodeInfoFn(ctx, nodeID)
|
||||
}
|
||||
|
||||
func (m *AdminMock) GetNodeStatistics(ctx context.Context, nodeID string) (*models.MultiNodeResponse, error) {
|
||||
m.record("GetNodeStatistics", nodeID)
|
||||
if m.GetNodeStatisticsFn == nil {
|
||||
return nil, errNotConfigured("GetNodeStatistics")
|
||||
}
|
||||
return m.GetNodeStatisticsFn(ctx, nodeID)
|
||||
}
|
||||
|
||||
// --- Monitoring ---
|
||||
|
||||
func (m *AdminMock) HealthCheck(ctx context.Context) error {
|
||||
m.record("HealthCheck")
|
||||
if m.HealthCheckFn == nil {
|
||||
return errNotConfigured("HealthCheck")
|
||||
}
|
||||
return m.HealthCheckFn(ctx)
|
||||
}
|
||||
|
||||
func (m *AdminMock) GetMetrics(ctx context.Context) (string, error) {
|
||||
m.record("GetMetrics")
|
||||
if m.GetMetricsFn == nil {
|
||||
return "", errNotConfigured("GetMetrics")
|
||||
}
|
||||
return m.GetMetricsFn(ctx)
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package mocks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"Noooste/garage-ui/internal/models"
|
||||
"Noooste/garage-ui/internal/services"
|
||||
)
|
||||
|
||||
// s3NotConfigured mirrors errNotConfigured but keys messages on S3Mock so
|
||||
// failures from unset S3 methods are distinguishable from unset admin methods.
|
||||
func s3NotConfigured(method string) error {
|
||||
return fmt.Errorf("S3Mock.%s: not configured by test", method)
|
||||
}
|
||||
|
||||
// Compile-time guarantee that S3Mock satisfies services.S3Storage.
|
||||
var _ services.S3Storage = (*S3Mock)(nil)
|
||||
|
||||
// S3Mock is a hand-rolled mock of services.S3Storage. Tests assign the
|
||||
// per-method function fields they care about; unset methods return
|
||||
// s3NotConfigured.
|
||||
type S3Mock struct {
|
||||
ListObjectsFn func(ctx context.Context, bucketName, prefix string, maxKeys int, continuationToken string) (*models.ObjectListResponse, error)
|
||||
UploadObjectFn func(ctx context.Context, bucketName, key string, body io.Reader, contentType string) (*models.ObjectUploadResponse, error)
|
||||
CreateDirectoryMarkerFn func(ctx context.Context, bucketName, key string) (*models.ObjectUploadResponse, error)
|
||||
GetObjectFn func(ctx context.Context, bucketName, key string) (io.ReadCloser, *models.ObjectInfo, error)
|
||||
ObjectExistsFn func(ctx context.Context, bucketName, key string) (bool, error)
|
||||
DeleteObjectFn func(ctx context.Context, bucketName, key string) error
|
||||
GetObjectMetadataFn func(ctx context.Context, bucketName, key string) (*models.ObjectInfo, error)
|
||||
GetPresignedURLFn func(ctx context.Context, bucketName, key string, expiresIn time.Duration) (string, error)
|
||||
DeleteMultipleObjectsFn func(ctx context.Context, bucketName string, keys []string) error
|
||||
UploadMultipleObjectsFn func(ctx context.Context, bucketName string, files []struct {
|
||||
Key string
|
||||
Body io.Reader
|
||||
ContentType string
|
||||
}) []services.UploadResult
|
||||
|
||||
// Calls records every invocation in order. Shares the Call type with
|
||||
// AdminMock via the same package.
|
||||
Calls []Call
|
||||
}
|
||||
|
||||
func (m *S3Mock) record(method string, args ...any) {
|
||||
m.Calls = append(m.Calls, Call{Method: method, Args: args})
|
||||
}
|
||||
|
||||
func (m *S3Mock) ListObjects(ctx context.Context, bucketName, prefix string, maxKeys int, continuationToken string) (*models.ObjectListResponse, error) {
|
||||
m.record("ListObjects", bucketName, prefix, maxKeys, continuationToken)
|
||||
if m.ListObjectsFn == nil {
|
||||
return nil, s3NotConfigured("ListObjects")
|
||||
}
|
||||
return m.ListObjectsFn(ctx, bucketName, prefix, maxKeys, continuationToken)
|
||||
}
|
||||
|
||||
func (m *S3Mock) UploadObject(ctx context.Context, bucketName, key string, body io.Reader, contentType string) (*models.ObjectUploadResponse, error) {
|
||||
m.record("UploadObject", bucketName, key, contentType)
|
||||
if m.UploadObjectFn == nil {
|
||||
return nil, s3NotConfigured("UploadObject")
|
||||
}
|
||||
return m.UploadObjectFn(ctx, bucketName, key, body, contentType)
|
||||
}
|
||||
|
||||
func (m *S3Mock) CreateDirectoryMarker(ctx context.Context, bucketName, key string) (*models.ObjectUploadResponse, error) {
|
||||
m.record("CreateDirectoryMarker", bucketName, key)
|
||||
if m.CreateDirectoryMarkerFn == nil {
|
||||
return nil, s3NotConfigured("CreateDirectoryMarker")
|
||||
}
|
||||
return m.CreateDirectoryMarkerFn(ctx, bucketName, key)
|
||||
}
|
||||
|
||||
func (m *S3Mock) GetObject(ctx context.Context, bucketName, key string) (io.ReadCloser, *models.ObjectInfo, error) {
|
||||
m.record("GetObject", bucketName, key)
|
||||
if m.GetObjectFn == nil {
|
||||
return nil, nil, s3NotConfigured("GetObject")
|
||||
}
|
||||
return m.GetObjectFn(ctx, bucketName, key)
|
||||
}
|
||||
|
||||
func (m *S3Mock) ObjectExists(ctx context.Context, bucketName, key string) (bool, error) {
|
||||
m.record("ObjectExists", bucketName, key)
|
||||
if m.ObjectExistsFn == nil {
|
||||
return false, s3NotConfigured("ObjectExists")
|
||||
}
|
||||
return m.ObjectExistsFn(ctx, bucketName, key)
|
||||
}
|
||||
|
||||
func (m *S3Mock) DeleteObject(ctx context.Context, bucketName, key string) error {
|
||||
m.record("DeleteObject", bucketName, key)
|
||||
if m.DeleteObjectFn == nil {
|
||||
return s3NotConfigured("DeleteObject")
|
||||
}
|
||||
return m.DeleteObjectFn(ctx, bucketName, key)
|
||||
}
|
||||
|
||||
func (m *S3Mock) GetObjectMetadata(ctx context.Context, bucketName, key string) (*models.ObjectInfo, error) {
|
||||
m.record("GetObjectMetadata", bucketName, key)
|
||||
if m.GetObjectMetadataFn == nil {
|
||||
return nil, s3NotConfigured("GetObjectMetadata")
|
||||
}
|
||||
return m.GetObjectMetadataFn(ctx, bucketName, key)
|
||||
}
|
||||
|
||||
func (m *S3Mock) GetPresignedURL(ctx context.Context, bucketName, key string, expiresIn time.Duration) (string, error) {
|
||||
m.record("GetPresignedURL", bucketName, key, expiresIn)
|
||||
if m.GetPresignedURLFn == nil {
|
||||
return "", s3NotConfigured("GetPresignedURL")
|
||||
}
|
||||
return m.GetPresignedURLFn(ctx, bucketName, key, expiresIn)
|
||||
}
|
||||
|
||||
func (m *S3Mock) DeleteMultipleObjects(ctx context.Context, bucketName string, keys []string) error {
|
||||
m.record("DeleteMultipleObjects", bucketName, keys)
|
||||
if m.DeleteMultipleObjectsFn == nil {
|
||||
return s3NotConfigured("DeleteMultipleObjects")
|
||||
}
|
||||
return m.DeleteMultipleObjectsFn(ctx, bucketName, keys)
|
||||
}
|
||||
|
||||
func (m *S3Mock) UploadMultipleObjects(ctx context.Context, bucketName string, files []struct {
|
||||
Key string
|
||||
Body io.Reader
|
||||
ContentType string
|
||||
}) []services.UploadResult {
|
||||
m.record("UploadMultipleObjects", bucketName, len(files))
|
||||
if m.UploadMultipleObjectsFn == nil {
|
||||
// Can't return an error here — the interface has no error channel.
|
||||
// Return a single synthetic failure for every file so tests that
|
||||
// forget to configure this method see a clear red flag.
|
||||
results := make([]services.UploadResult, len(files))
|
||||
for i, f := range files {
|
||||
results[i] = services.UploadResult{
|
||||
Key: f.Key,
|
||||
Success: false,
|
||||
Error: s3NotConfigured("UploadMultipleObjects"),
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
return m.UploadMultipleObjectsFn(ctx, bucketName, files)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -37,7 +38,6 @@ func NewS3Service(cfg *config.GarageConfig, adminService *GarageAdminService) *S
|
||||
}
|
||||
|
||||
client, err := minio.New(cfg.Endpoint, &minio.Options{
|
||||
//Creds: credentials.NewStaticV4(cfg.AccessKey, cfg.SecretKey, ""),
|
||||
Secure: cfg.UseSSL,
|
||||
Region: cfg.Region,
|
||||
})
|
||||
@@ -219,9 +219,28 @@ func (s *S3Service) ListObjects(ctx context.Context, bucketName, prefix string,
|
||||
return nil, fmt.Errorf("failed to list objects in bucket %s: %w", bucketName, err)
|
||||
}
|
||||
|
||||
// Drop directory marker objects (zero-byte keys ending in "/"). Garage
|
||||
// returns them in Contents, but the UI renders folders from Prefixes — a
|
||||
// marker shown as both a folder and a file is confusing. Any marker not
|
||||
// already covered by a CommonPrefix is promoted to Prefixes below.
|
||||
contents := make([]minio.ObjectInfo, 0, len(result.Contents))
|
||||
markerKeys := make([]string, 0)
|
||||
for _, obj := range result.Contents {
|
||||
if strings.HasSuffix(obj.Key, "/") && obj.Size == 0 {
|
||||
// A marker whose key equals the current listing prefix is the
|
||||
// folder itself — drop it entirely so it doesn't render as a
|
||||
// nameless child of itself.
|
||||
if obj.Key != prefix {
|
||||
markerKeys = append(markerKeys, obj.Key)
|
||||
}
|
||||
continue
|
||||
}
|
||||
contents = append(contents, obj)
|
||||
}
|
||||
|
||||
// Process objects from result.Contents
|
||||
// Note: ListObjectsV2 doesn't return ContentType, so we need to fetch it separately
|
||||
objects := make([]models.ObjectInfo, len(result.Contents))
|
||||
objects := make([]models.ObjectInfo, len(contents))
|
||||
|
||||
// Use goroutines to fetch ContentType concurrently for better performance
|
||||
type statResult struct {
|
||||
@@ -232,7 +251,7 @@ func (s *S3Service) ListObjects(ctx context.Context, bucketName, prefix string,
|
||||
|
||||
statChan := make(chan statResult, len(result.Contents))
|
||||
|
||||
for i, obj := range result.Contents {
|
||||
for i, obj := range contents {
|
||||
go func(idx int, objKey string) {
|
||||
// Fetch object metadata to get ContentType
|
||||
stat, err := client.StatObject(ctx, bucketName, objKey, minio.StatObjectOptions{})
|
||||
@@ -255,7 +274,7 @@ func (s *S3Service) ListObjects(ctx context.Context, bucketName, prefix string,
|
||||
}
|
||||
|
||||
// Collect results from goroutines
|
||||
for range result.Contents {
|
||||
for range contents {
|
||||
res := <-statChan
|
||||
if res.err == nil {
|
||||
objects[res.index].ContentType = res.contentType
|
||||
@@ -265,9 +284,20 @@ func (s *S3Service) ListObjects(ctx context.Context, bucketName, prefix string,
|
||||
close(statChan)
|
||||
|
||||
// Process folders from result.CommonPrefixes
|
||||
prefixList := make([]string, 0, len(result.CommonPrefixes))
|
||||
prefixList := make([]string, 0, len(result.CommonPrefixes)+len(markerKeys))
|
||||
seen := make(map[string]struct{}, len(result.CommonPrefixes))
|
||||
for _, p := range result.CommonPrefixes {
|
||||
prefixList = append(prefixList, p.Prefix)
|
||||
seen[p.Prefix] = struct{}{}
|
||||
}
|
||||
// Promote filtered directory markers into Prefixes so empty folders still
|
||||
// appear in the listing.
|
||||
for _, k := range markerKeys {
|
||||
if _, ok := seen[k]; ok {
|
||||
continue
|
||||
}
|
||||
prefixList = append(prefixList, k)
|
||||
seen[k] = struct{}{}
|
||||
}
|
||||
|
||||
return &models.ObjectListResponse{
|
||||
@@ -315,15 +345,54 @@ func (s *S3Service) UploadObject(ctx context.Context, bucketName, key string, bo
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CreateDirectoryMarker creates a zero-byte object whose key ends with "/".
|
||||
// Garage rejects the streaming path (size=-1) with "Empty body" because the
|
||||
// MinIO client switches to multipart upload, which requires payload. Passing
|
||||
// size=0 forces a single PutObject request with Content-Length: 0, which
|
||||
// Garage accepts as a directory marker.
|
||||
func (s *S3Service) CreateDirectoryMarker(ctx context.Context, bucketName, key string) (*models.ObjectUploadResponse, error) {
|
||||
client, err := s.getMinioClient(ctx, bucketName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get MinIO client for bucket %s: %w", bucketName, err)
|
||||
}
|
||||
|
||||
opts := minio.PutObjectOptions{ContentType: "application/x-directory"}
|
||||
|
||||
var info minio.UploadInfo
|
||||
retryConfig := utils.DefaultRetryConfig()
|
||||
err = utils.RetryWithBackoff(ctx, retryConfig, func() error {
|
||||
var uploadErr error
|
||||
info, uploadErr = client.PutObject(ctx, bucketName, key, bytes.NewReader(nil), 0, opts)
|
||||
return uploadErr
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create directory %s in bucket %s: %w", key, bucketName, err)
|
||||
}
|
||||
|
||||
return &models.ObjectUploadResponse{
|
||||
Bucket: bucketName,
|
||||
Key: key,
|
||||
ETag: info.ETag,
|
||||
Size: info.Size,
|
||||
ContentType: opts.ContentType,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetObject retrieves an object from a bucket
|
||||
func (s *S3Service) GetObject(ctx context.Context, bucketName, key string) (io.ReadCloser, *models.ObjectInfo, error) {
|
||||
// Get bucket-specific MinIO client
|
||||
client, err := s.getMinioClient(ctx, bucketName)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to get MinIO client for bucket %s: %w", bucketName, err)
|
||||
}
|
||||
|
||||
var object *minio.Object
|
||||
|
||||
// Call MinIO GetObject API with retry logic
|
||||
retryConfig := utils.DefaultRetryConfig()
|
||||
err := utils.RetryWithBackoff(ctx, retryConfig, func() error {
|
||||
err = utils.RetryWithBackoff(ctx, retryConfig, func() error {
|
||||
var getErr error
|
||||
object, getErr = s.client.GetObject(ctx, bucketName, key, minio.GetObjectOptions{})
|
||||
object, getErr = client.GetObject(ctx, bucketName, key, minio.GetObjectOptions{})
|
||||
return getErr
|
||||
})
|
||||
if err != nil {
|
||||
@@ -351,10 +420,16 @@ func (s *S3Service) GetObject(ctx context.Context, bucketName, key string) (io.R
|
||||
|
||||
// DeleteObject deletes an object from a bucket
|
||||
func (s *S3Service) DeleteObject(ctx context.Context, bucketName, key string) error {
|
||||
// Get bucket-specific MinIO client
|
||||
client, err := s.getMinioClient(ctx, bucketName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get MinIO client for bucket %s: %w", bucketName, err)
|
||||
}
|
||||
|
||||
// Call MinIO RemoveObject API with retry logic
|
||||
retryConfig := utils.DefaultRetryConfig()
|
||||
err := utils.RetryWithBackoff(ctx, retryConfig, func() error {
|
||||
return s.client.RemoveObject(ctx, bucketName, key, minio.RemoveObjectOptions{})
|
||||
err = utils.RetryWithBackoff(ctx, retryConfig, func() error {
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"Noooste/garage-ui/internal/config"
|
||||
"Noooste/garage-ui/internal/models"
|
||||
"Noooste/garage-ui/pkg/utils"
|
||||
)
|
||||
|
||||
func TestNewS3Service_StripsHTTPPrefix(t *testing.T) {
|
||||
cfg := &config.GarageConfig{
|
||||
Endpoint: "http://garage:3900",
|
||||
Region: "garage",
|
||||
}
|
||||
_ = NewS3Service(cfg, nil)
|
||||
if cfg.Endpoint != "garage:3900" {
|
||||
t.Errorf("Endpoint = %q, want %q", cfg.Endpoint, "garage:3900")
|
||||
}
|
||||
if cfg.UseSSL {
|
||||
t.Error("UseSSL should remain false for http://")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewS3Service_StripsHTTPSPrefixAndEnablesSSL(t *testing.T) {
|
||||
cfg := &config.GarageConfig{
|
||||
Endpoint: "https://garage.example.com",
|
||||
Region: "garage",
|
||||
}
|
||||
_ = NewS3Service(cfg, nil)
|
||||
if cfg.Endpoint != "garage.example.com" {
|
||||
t.Errorf("Endpoint = %q", cfg.Endpoint)
|
||||
}
|
||||
if !cfg.UseSSL {
|
||||
t.Error("UseSSL should be flipped to true for https://")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewS3Service_LeavesBareHostUnchanged(t *testing.T) {
|
||||
cfg := &config.GarageConfig{
|
||||
Endpoint: "garage:3900",
|
||||
Region: "garage",
|
||||
UseSSL: true, // pre-set; should remain true
|
||||
}
|
||||
_ = NewS3Service(cfg, nil)
|
||||
if cfg.Endpoint != "garage:3900" {
|
||||
t.Errorf("Endpoint mutated unexpectedly: %q", cfg.Endpoint)
|
||||
}
|
||||
if !cfg.UseSSL {
|
||||
t.Error("pre-set UseSSL must be preserved")
|
||||
}
|
||||
}
|
||||
|
||||
// adminBackedS3 wires an S3Service to a fresh GarageAdminService that talks
|
||||
// to the supplied http.Handler.
|
||||
func adminBackedS3(t *testing.T, handler http.Handler) (*S3Service, *httptest.Server) {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(handler)
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
admin := NewGarageAdminService(&config.GarageConfig{
|
||||
AdminEndpoint: srv.URL,
|
||||
AdminToken: "test-token",
|
||||
}, "")
|
||||
s3 := NewS3Service(&config.GarageConfig{
|
||||
Endpoint: "garage:3900",
|
||||
Region: "garage",
|
||||
}, admin)
|
||||
return s3, srv
|
||||
}
|
||||
|
||||
// uniqueBucket returns a per-subtest bucket name so cached credentials from
|
||||
// one test don't leak into another via utils.GlobalCache.
|
||||
func uniqueBucket(t *testing.T) string {
|
||||
t.Helper()
|
||||
name := "test-bucket-" + t.Name()
|
||||
t.Cleanup(func() {
|
||||
utils.GlobalCache.Delete("key:" + name)
|
||||
})
|
||||
return name
|
||||
}
|
||||
|
||||
func TestGetBucketCredentials_HappyPath(t *testing.T) {
|
||||
bucket := uniqueBucket(t)
|
||||
secret := "the-secret"
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/v2/GetBucketInfo", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(&models.GarageBucketInfo{
|
||||
ID: "bid",
|
||||
Keys: []models.BucketKeyInfo{
|
||||
{
|
||||
AccessKeyID: "AK",
|
||||
Permissions: models.BucketKeyPermission{Read: true, Write: true},
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
mux.HandleFunc("/v2/GetKeyInfo", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(&models.GarageKeyInfo{
|
||||
AccessKeyID: "AK",
|
||||
SecretAccessKey: &secret,
|
||||
})
|
||||
})
|
||||
s3, _ := adminBackedS3(t, mux)
|
||||
|
||||
creds, err := s3.getBucketCredentials(context.Background(), bucket)
|
||||
if err != nil {
|
||||
t.Fatalf("getBucketCredentials: %v", err)
|
||||
}
|
||||
if creds == nil {
|
||||
t.Fatal("creds is nil")
|
||||
}
|
||||
v, err := creds.GetWithContext(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("creds.GetWithContext: %v", err)
|
||||
}
|
||||
if v.AccessKeyID != "AK" || v.SecretAccessKey != secret {
|
||||
t.Errorf("got AK=%q SK=%q, want AK SK=%q", v.AccessKeyID, v.SecretAccessKey, secret)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetBucketCredentials_CachesAcrossCalls(t *testing.T) {
|
||||
bucket := uniqueBucket(t)
|
||||
secret := "cache-secret"
|
||||
|
||||
var bucketCalls, keyCalls int
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/v2/GetBucketInfo", func(w http.ResponseWriter, r *http.Request) {
|
||||
bucketCalls++
|
||||
_ = json.NewEncoder(w).Encode(&models.GarageBucketInfo{
|
||||
ID: "bid",
|
||||
Keys: []models.BucketKeyInfo{
|
||||
{AccessKeyID: "AK", Permissions: models.BucketKeyPermission{Read: true, Write: true}},
|
||||
},
|
||||
})
|
||||
})
|
||||
mux.HandleFunc("/v2/GetKeyInfo", func(w http.ResponseWriter, r *http.Request) {
|
||||
keyCalls++
|
||||
_ = json.NewEncoder(w).Encode(&models.GarageKeyInfo{
|
||||
AccessKeyID: "AK",
|
||||
SecretAccessKey: &secret,
|
||||
})
|
||||
})
|
||||
s3, _ := adminBackedS3(t, mux)
|
||||
|
||||
for i := range 3 {
|
||||
if _, err := s3.getBucketCredentials(context.Background(), bucket); err != nil {
|
||||
t.Fatalf("call %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
if bucketCalls != 1 {
|
||||
t.Errorf("GetBucketInfo called %d times, want 1 (cache should serve calls 2-3)", bucketCalls)
|
||||
}
|
||||
if keyCalls != 1 {
|
||||
t.Errorf("GetKeyInfo called %d times, want 1", keyCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetBucketCredentials_SkipsKeysWithoutReadOrWrite(t *testing.T) {
|
||||
bucket := uniqueBucket(t)
|
||||
secret := "good-secret"
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/v2/GetBucketInfo", func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(&models.GarageBucketInfo{
|
||||
ID: "bid",
|
||||
Keys: []models.BucketKeyInfo{
|
||||
{AccessKeyID: "READ-ONLY", Permissions: models.BucketKeyPermission{Read: true, Write: false}},
|
||||
{AccessKeyID: "WRITE-ONLY", Permissions: models.BucketKeyPermission{Read: false, Write: true}},
|
||||
{AccessKeyID: "NO-PERMS", Permissions: models.BucketKeyPermission{}},
|
||||
{AccessKeyID: "RW", Permissions: models.BucketKeyPermission{Read: true, Write: true}},
|
||||
},
|
||||
})
|
||||
})
|
||||
mux.HandleFunc("/v2/GetKeyInfo", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Query().Get("id") != "RW" {
|
||||
t.Errorf("GetKeyInfo called with id=%q, want RW", r.URL.Query().Get("id"))
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(&models.GarageKeyInfo{
|
||||
AccessKeyID: "RW",
|
||||
SecretAccessKey: &secret,
|
||||
})
|
||||
})
|
||||
s3, _ := adminBackedS3(t, mux)
|
||||
|
||||
creds, err := s3.getBucketCredentials(context.Background(), bucket)
|
||||
if err != nil {
|
||||
t.Fatalf("getBucketCredentials: %v", err)
|
||||
}
|
||||
v, err := creds.GetWithContext(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("creds.GetWithContext: %v", err)
|
||||
}
|
||||
if v.AccessKeyID != "RW" {
|
||||
t.Errorf("AccessKeyID = %q, want RW", v.AccessKeyID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetBucketCredentials_NoEligibleKeyReturnsError(t *testing.T) {
|
||||
bucket := uniqueBucket(t)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/v2/GetBucketInfo", func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(&models.GarageBucketInfo{
|
||||
ID: "bid",
|
||||
Keys: []models.BucketKeyInfo{}, // no keys at all
|
||||
})
|
||||
})
|
||||
mux.HandleFunc("/v2/GetKeyInfo", func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Error("GetKeyInfo should not be called when no keys exist")
|
||||
})
|
||||
s3, _ := adminBackedS3(t, mux)
|
||||
|
||||
_, err := s3.getBucketCredentials(context.Background(), bucket)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when bucket has no keys, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "no valid credentials") {
|
||||
t.Errorf("expected 'no valid credentials' in error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetBucketCredentials_KeyWithoutSecretIsSkipped(t *testing.T) {
|
||||
bucket := uniqueBucket(t)
|
||||
secret := "good"
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/v2/GetBucketInfo", func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(&models.GarageBucketInfo{
|
||||
ID: "bid",
|
||||
Keys: []models.BucketKeyInfo{
|
||||
{AccessKeyID: "FIRST-RW", Permissions: models.BucketKeyPermission{Read: true, Write: true}},
|
||||
{AccessKeyID: "SECOND-RW", Permissions: models.BucketKeyPermission{Read: true, Write: true}},
|
||||
},
|
||||
})
|
||||
})
|
||||
mux.HandleFunc("/v2/GetKeyInfo", func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Query().Get("id") {
|
||||
case "FIRST-RW":
|
||||
_ = json.NewEncoder(w).Encode(&models.GarageKeyInfo{AccessKeyID: "FIRST-RW", SecretAccessKey: nil})
|
||||
case "SECOND-RW":
|
||||
_ = json.NewEncoder(w).Encode(&models.GarageKeyInfo{AccessKeyID: "SECOND-RW", SecretAccessKey: &secret})
|
||||
default:
|
||||
t.Errorf("unexpected GetKeyInfo id: %q", r.URL.Query().Get("id"))
|
||||
}
|
||||
})
|
||||
s3, _ := adminBackedS3(t, mux)
|
||||
|
||||
creds, err := s3.getBucketCredentials(context.Background(), bucket)
|
||||
if err != nil {
|
||||
t.Fatalf("getBucketCredentials: %v", err)
|
||||
}
|
||||
v, err := creds.GetWithContext(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("creds.GetWithContext: %v", err)
|
||||
}
|
||||
if v.AccessKeyID != "SECOND-RW" {
|
||||
t.Errorf("AccessKeyID = %q, want SECOND-RW (loop must skip FIRST-RW's nil secret)", v.AccessKeyID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetBucketCredentials_AdminErrorPropagates(t *testing.T) {
|
||||
bucket := uniqueBucket(t)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/v2/GetBucketInfo", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte(`{"error":"boom"}`))
|
||||
})
|
||||
s3, _ := adminBackedS3(t, mux)
|
||||
|
||||
_, err := s3.getBucketCredentials(context.Background(), bucket)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when admin call fails, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "failed to get bucket info") {
|
||||
t.Errorf("expected wrapped 'failed to get bucket info' error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetBucketStatistics_HappyPath(t *testing.T) {
|
||||
bucket := uniqueBucket(t)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/v2/GetBucketInfo", func(w http.ResponseWriter, r *http.Request) {
|
||||
if !strings.Contains(r.URL.RawQuery, "globalAlias=") {
|
||||
t.Errorf("expected globalAlias query, got %q", r.URL.RawQuery)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(&models.GarageBucketInfo{
|
||||
ID: "bid",
|
||||
Objects: 42,
|
||||
Bytes: 123_456,
|
||||
})
|
||||
})
|
||||
s3, _ := adminBackedS3(t, mux)
|
||||
|
||||
stats, err := s3.GetBucketStatistics(context.Background(), bucket)
|
||||
if err != nil {
|
||||
t.Fatalf("GetBucketStatistics: %v", err)
|
||||
}
|
||||
if stats.ObjectCount != 42 || stats.TotalSize != 123_456 {
|
||||
t.Errorf("got %+v, want ObjectCount=42 TotalSize=123456", stats)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetBucketStatistics_AdminErrorPropagates(t *testing.T) {
|
||||
bucket := uniqueBucket(t)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/v2/GetBucketInfo", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_, _ = w.Write([]byte(`{"error":"no such bucket"}`))
|
||||
})
|
||||
s3, _ := adminBackedS3(t, mux)
|
||||
|
||||
_, err := s3.GetBucketStatistics(context.Background(), bucket)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "failed to get bucket info") {
|
||||
t.Errorf("expected 'failed to get bucket info' wrap, got %v", err)
|
||||
}
|
||||
}
|
||||
+74
-37
@@ -3,20 +3,24 @@ package main
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"runtime"
|
||||
"runtime/debug"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"Noooste/garage-ui/internal/auth"
|
||||
"Noooste/garage-ui/internal/config"
|
||||
"Noooste/garage-ui/internal/handlers"
|
||||
appmw "Noooste/garage-ui/internal/middleware"
|
||||
"Noooste/garage-ui/internal/routes"
|
||||
"Noooste/garage-ui/internal/services"
|
||||
"Noooste/garage-ui/pkg/logger"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
"github.com/gofiber/fiber/v3/middleware/logger"
|
||||
"github.com/gofiber/fiber/v3/middleware/recover"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// @title Garage UI API
|
||||
@@ -55,27 +59,39 @@ import (
|
||||
// @name Authorization
|
||||
// @description Type "Bearer" followed by a space and JWT token.
|
||||
|
||||
const version = "0.1.0"
|
||||
var version = "dev"
|
||||
|
||||
func main() {
|
||||
// Parse command-line flags
|
||||
configPath := flag.String("config", "config.yaml", "Path to configuration file")
|
||||
flag.Parse()
|
||||
|
||||
// Load configuration
|
||||
log.Printf("Loading configuration from: %s", *configPath)
|
||||
// Load configuration first (before initializing logger)
|
||||
cfg, err := config.Load(*configPath)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to load configuration: %v", err)
|
||||
// If config fails to load, use default logger to report the error
|
||||
logger.Get().Fatal().Err(err).Str("config_path", *configPath).Msg("Failed to load configuration")
|
||||
}
|
||||
|
||||
log.Printf("Starting Garage UI Backend v%s in %s mode", version, cfg.Server.Environment)
|
||||
// Initialize logger with configuration from config file
|
||||
logger.Init(logger.Config{
|
||||
Level: cfg.Logging.Level,
|
||||
Format: cfg.Logging.Format,
|
||||
})
|
||||
|
||||
// Now log with the properly configured logger
|
||||
logger.Info().
|
||||
Str("config_path", *configPath).
|
||||
Str("version", version).
|
||||
Str("go_version", runtime.Version()).
|
||||
Str("environment", cfg.Server.Environment).
|
||||
Msg("Starting Garage UI Backend")
|
||||
|
||||
// Initialize services
|
||||
log.Println("Initializing Garage Admin service...")
|
||||
adminService := services.NewGarageAdminService(&cfg.Garage)
|
||||
logger.Info().Msg("Initializing Garage Admin service")
|
||||
adminService := services.NewGarageAdminService(&cfg.Garage, cfg.Logging.Level)
|
||||
|
||||
log.Println("Initializing S3 service...")
|
||||
logger.Info().Msg("Initializing S3 service")
|
||||
s3Service := services.NewS3Service(&cfg.Garage, adminService)
|
||||
|
||||
// Determine enabled auth methods for logging
|
||||
@@ -89,10 +105,10 @@ func main() {
|
||||
if len(authMethods) == 0 {
|
||||
authMethods = append(authMethods, "none")
|
||||
}
|
||||
log.Printf("Initializing authentication service (enabled: %v)...", authMethods)
|
||||
logger.Info().Strs("enabled_methods", authMethods).Msg("Initializing authentication service")
|
||||
authService, err := auth.NewAuthService(&cfg.Auth, &cfg.Server)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to initialize auth service: %v", err)
|
||||
logger.Fatal().Err(err).Msg("Failed to initialize auth service")
|
||||
}
|
||||
|
||||
// Initialize handlers
|
||||
@@ -121,27 +137,41 @@ func main() {
|
||||
writeBufferSize = 4096 // 4KB default
|
||||
}
|
||||
|
||||
log.Printf("Server limits - Max body: %d bytes (%.2fMB), Max header: %d bytes (%.2fKB)",
|
||||
maxBodySize, float64(maxBodySize)/(1024*1024),
|
||||
maxHeaderSize, float64(maxHeaderSize)/1024)
|
||||
logger.Info().
|
||||
Int64("max_body_bytes", maxBodySize).
|
||||
Float64("max_body_mb", float64(maxBodySize)/(1024*1024)).
|
||||
Int("max_header_bytes", maxHeaderSize).
|
||||
Float64("max_header_kb", float64(maxHeaderSize)/1024).
|
||||
Msg("Server request limits configured")
|
||||
|
||||
// Create Fiber app with configuration
|
||||
app := fiber.New(fiber.Config{
|
||||
AppName: "Garage UI Backend v" + version,
|
||||
AppName: "Garage UI Backend | Version: " + version,
|
||||
BodyLimit: int(maxBodySize),
|
||||
ReadBufferSize: readBufferSize,
|
||||
WriteBufferSize: writeBufferSize,
|
||||
ErrorHandler: customErrorHandler,
|
||||
})
|
||||
|
||||
// Apply global middleware
|
||||
app.Use(recover.New()) // Panic recovery
|
||||
app.Use(logger.New(logger.Config{
|
||||
Format: "[${time}] ${status} - ${method} ${path} (${latency})\n",
|
||||
// Apply global middleware (order matters):
|
||||
// 1. recover — must be outermost so panics become 500s.
|
||||
// 2. RequestID — mints/reads X-Request-ID before any logger needs it.
|
||||
// 3. Logging — builds per-request zerolog logger + emits access log.
|
||||
// Auth middleware is installed per-route inside routes.SetupRoutes.
|
||||
app.Use(recover.New(recover.Config{
|
||||
EnableStackTrace: true,
|
||||
StackTraceHandler: func(c fiber.Ctx, e interface{}) {
|
||||
logger.FromCtx(c.Context()).Error().
|
||||
Interface("panic", e).
|
||||
Bytes("stack", debug.Stack()).
|
||||
Msg("panic_recovered")
|
||||
},
|
||||
}))
|
||||
app.Use(appmw.RequestID())
|
||||
app.Use(appmw.Logging(log.Logger))
|
||||
|
||||
// Setup routes
|
||||
log.Println("Setting up routes...")
|
||||
logger.Info().Msg("Setting up routes")
|
||||
routes.SetupRoutes(
|
||||
app,
|
||||
cfg,
|
||||
@@ -157,42 +187,49 @@ func main() {
|
||||
// Start server in a goroutine
|
||||
go func() {
|
||||
addr := cfg.GetAddress()
|
||||
log.Printf("Server listening on %s", addr)
|
||||
log.Printf("Health check available at: http://%s/health", addr)
|
||||
log.Printf("API documentation: http://%s/api/v1/", addr)
|
||||
logger.Info().
|
||||
Str("address", addr).
|
||||
Str("health_endpoint", fmt.Sprintf("http://%s/health", addr)).
|
||||
Str("api_docs", fmt.Sprintf("http://%s/api/v1/", addr)).
|
||||
Msg("Server starting")
|
||||
|
||||
if err := app.Listen(addr); err != nil {
|
||||
log.Fatalf("Failed to start server: %v", err)
|
||||
logger.Fatal().Err(err).Msg("Failed to start server")
|
||||
}
|
||||
}()
|
||||
|
||||
// Wait for interrupt signal to gracefully shutdown the server
|
||||
// Wait for interrupt signal to gracefully shutdown the server.
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
|
||||
<-quit
|
||||
sig := <-quit
|
||||
|
||||
log.Println("Shutting down server...")
|
||||
logger.Info().Str("signal", sig.String()).Msg("Shutting down server")
|
||||
shutdownStart := time.Now()
|
||||
if err := app.Shutdown(); err != nil {
|
||||
log.Fatalf("Server shutdown failed: %v", err)
|
||||
logger.Fatal().Err(err).Msg("Server shutdown failed")
|
||||
}
|
||||
|
||||
log.Println("Server stopped gracefully")
|
||||
logger.Info().
|
||||
Dur("shutdown_duration", time.Since(shutdownStart)).
|
||||
Msg("Server stopped gracefully")
|
||||
}
|
||||
|
||||
// customErrorHandler handles errors globally
|
||||
// customErrorHandler handles errors globally. It uses the per-request logger
|
||||
// from c.Context() so request_id / user_id attach automatically, and it
|
||||
// demotes expected 4xx responses to warn (5xx stays at error).
|
||||
func customErrorHandler(c fiber.Ctx, err error) error {
|
||||
// Default to 500 Internal Server Error
|
||||
code := fiber.StatusInternalServerError
|
||||
|
||||
// Check if it's a Fiber error
|
||||
if e, ok := err.(*fiber.Error); ok {
|
||||
code = e.Code
|
||||
}
|
||||
|
||||
// Log the error
|
||||
log.Printf("Error: %v", err)
|
||||
l := logger.FromCtx(c.Context())
|
||||
evt := l.Error()
|
||||
if code >= 400 && code < 500 {
|
||||
evt = l.Warn()
|
||||
}
|
||||
evt.Err(err).Int("status_code", code).Msg("request_error")
|
||||
|
||||
// Return JSON error response
|
||||
return c.Status(code).JSON(fiber.Map{
|
||||
"success": false,
|
||||
"error": fiber.Map{
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
// ctxKey is unexported so other packages can't collide.
|
||||
type ctxKey struct{}
|
||||
|
||||
// IntoCtx returns a new context carrying the given logger. Retrieve it later
|
||||
// with FromCtx. Middleware that builds a per-request logger should call this
|
||||
// once per request.
|
||||
func IntoCtx(ctx context.Context, l zerolog.Logger) context.Context {
|
||||
return context.WithValue(ctx, ctxKey{}, l)
|
||||
}
|
||||
|
||||
// FromCtx returns the logger bound to ctx. If no logger is bound (e.g. the
|
||||
// call is outside any middleware, or ctx is nil), it returns the global
|
||||
// logger. Never returns nil.
|
||||
func FromCtx(ctx context.Context) *zerolog.Logger {
|
||||
if ctx != nil {
|
||||
if l, ok := ctx.Value(ctxKey{}).(zerolog.Logger); ok {
|
||||
return &l
|
||||
}
|
||||
}
|
||||
l := Get().Logger
|
||||
return &l
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
func TestFromCtx_ReturnsBoundLogger(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
l := zerolog.New(&buf).With().Str("request_id", "req-1").Logger()
|
||||
|
||||
ctx := IntoCtx(context.Background(), l)
|
||||
got := FromCtx(ctx)
|
||||
got.Info().Msg("hello")
|
||||
|
||||
var parsed map[string]any
|
||||
line := strings.TrimSpace(buf.String())
|
||||
if err := json.Unmarshal([]byte(line), &parsed); err != nil {
|
||||
t.Fatalf("not JSON: %v — %s", err, line)
|
||||
}
|
||||
if parsed["request_id"] != "req-1" {
|
||||
t.Errorf("request_id = %v, want req-1", parsed["request_id"])
|
||||
}
|
||||
if parsed["message"] != "hello" {
|
||||
t.Errorf("message = %v, want hello", parsed["message"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestFromCtx_NoLoggerFallsBackToDisabled(t *testing.T) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("FromCtx panicked: %v", r)
|
||||
}
|
||||
}()
|
||||
l := FromCtx(context.Background())
|
||||
l.Info().Msg("should not panic")
|
||||
}
|
||||
@@ -21,17 +21,16 @@ var (
|
||||
|
||||
// Config holds logger configuration
|
||||
type Config struct {
|
||||
Level string // debug, info, warn, error
|
||||
Pretty bool // Enable console pretty printing
|
||||
TimeFormat string // Time format (default: time.RFC3339)
|
||||
Level string // debug, info, warn, error
|
||||
Format string // json, text
|
||||
}
|
||||
|
||||
// Init initializes the global logger with the given configuration
|
||||
func Init(cfg Config) {
|
||||
var output io.Writer = os.Stdout
|
||||
|
||||
// Set up pretty console output if enabled
|
||||
if cfg.Pretty {
|
||||
// Set up console output for text format
|
||||
if cfg.Format == "text" {
|
||||
output = zerolog.ConsoleWriter{
|
||||
Out: os.Stdout,
|
||||
TimeFormat: time.RFC3339,
|
||||
@@ -70,7 +69,7 @@ func Get() *Logger {
|
||||
// Initialize with defaults if not initialized
|
||||
Init(Config{
|
||||
Level: "info",
|
||||
Pretty: true,
|
||||
Format: "text",
|
||||
})
|
||||
}
|
||||
return globalLogger
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// serializeLoggerTests guards the global mutations (os.Stdout, globalLogger,
|
||||
// zerolog global). These tests cannot run in parallel with each other.
|
||||
var serializeLoggerTests sync.Mutex
|
||||
|
||||
// captureStdout swaps os.Stdout for a pipe, calls fn, restores stdout, and
|
||||
// returns everything written during fn.
|
||||
func captureStdout(t *testing.T, fn func()) string {
|
||||
t.Helper()
|
||||
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatalf("os.Pipe: %v", err)
|
||||
}
|
||||
|
||||
old := os.Stdout
|
||||
os.Stdout = w
|
||||
t.Cleanup(func() { os.Stdout = old })
|
||||
|
||||
// Run fn and close writer so the reader unblocks.
|
||||
doneWrite := make(chan struct{})
|
||||
go func() {
|
||||
fn()
|
||||
_ = w.Close()
|
||||
close(doneWrite)
|
||||
}()
|
||||
|
||||
var buf strings.Builder
|
||||
scanner := bufio.NewScanner(r)
|
||||
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
|
||||
for scanner.Scan() {
|
||||
buf.WriteString(scanner.Text())
|
||||
buf.WriteByte('\n')
|
||||
}
|
||||
// Drain any residual (shouldn't happen after Close, but safe):
|
||||
_, _ = io.Copy(io.Discard, r)
|
||||
<-doneWrite
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
func TestInit_JSONFormatProducesParseableOutput(t *testing.T) {
|
||||
serializeLoggerTests.Lock()
|
||||
defer serializeLoggerTests.Unlock()
|
||||
|
||||
out := captureStdout(t, func() {
|
||||
Init(Config{Level: "info", Format: "json"})
|
||||
Info().Str("user", "alice").Msg("hello")
|
||||
})
|
||||
|
||||
// Find the first non-empty line; parse as JSON.
|
||||
var line string
|
||||
for l := range strings.SplitSeq(out, "\n") {
|
||||
if strings.TrimSpace(l) != "" {
|
||||
line = l
|
||||
break
|
||||
}
|
||||
}
|
||||
if line == "" {
|
||||
t.Fatalf("no log output captured; stdout = %q", out)
|
||||
}
|
||||
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal([]byte(line), &parsed); err != nil {
|
||||
t.Fatalf("log line is not valid JSON: %v\nline: %s", err, line)
|
||||
}
|
||||
|
||||
// Field assertions — zerolog uses "message" for the msg and "level" for level.
|
||||
if got, _ := parsed["message"].(string); got != "hello" {
|
||||
t.Errorf("message = %v, want hello", parsed["message"])
|
||||
}
|
||||
if got, _ := parsed["user"].(string); got != "alice" {
|
||||
t.Errorf("user field = %v, want alice", parsed["user"])
|
||||
}
|
||||
if got, _ := parsed["level"].(string); got != "info" {
|
||||
t.Errorf("level = %v, want info", parsed["level"])
|
||||
}
|
||||
if _, ok := parsed["time"]; !ok {
|
||||
t.Errorf("expected time field; got keys %v", keysOf(parsed))
|
||||
}
|
||||
if _, ok := parsed["caller"]; !ok {
|
||||
t.Errorf("expected caller field; got keys %v", keysOf(parsed))
|
||||
}
|
||||
}
|
||||
|
||||
func TestInit_LevelFilterDropsBelowThreshold(t *testing.T) {
|
||||
serializeLoggerTests.Lock()
|
||||
defer serializeLoggerTests.Unlock()
|
||||
|
||||
out := captureStdout(t, func() {
|
||||
Init(Config{Level: "warn", Format: "json"})
|
||||
Debug().Msg("debug-dropped")
|
||||
Info().Msg("info-dropped")
|
||||
Warn().Msg("warn-kept")
|
||||
Error().Msg("error-kept")
|
||||
})
|
||||
|
||||
if strings.Contains(out, "debug-dropped") {
|
||||
t.Errorf("debug event leaked through warn filter: %s", out)
|
||||
}
|
||||
if strings.Contains(out, "info-dropped") {
|
||||
t.Errorf("info event leaked through warn filter: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "warn-kept") {
|
||||
t.Errorf("warn event missing: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "error-kept") {
|
||||
t.Errorf("error event missing: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInit_UnknownLevelDefaultsToInfo(t *testing.T) {
|
||||
serializeLoggerTests.Lock()
|
||||
defer serializeLoggerTests.Unlock()
|
||||
|
||||
out := captureStdout(t, func() {
|
||||
Init(Config{Level: "gibberish", Format: "json"})
|
||||
Debug().Msg("debug-should-be-dropped")
|
||||
Info().Msg("info-should-appear")
|
||||
})
|
||||
|
||||
if strings.Contains(out, "debug-should-be-dropped") {
|
||||
t.Errorf("debug leaked at default info level: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "info-should-appear") {
|
||||
t.Errorf("info missing at default info level: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInit_TextFormatDoesNotCrashAndIsNotJSON(t *testing.T) {
|
||||
serializeLoggerTests.Lock()
|
||||
defer serializeLoggerTests.Unlock()
|
||||
|
||||
out := captureStdout(t, func() {
|
||||
Init(Config{Level: "info", Format: "text"})
|
||||
Info().Str("k", "v").Msg("plain")
|
||||
})
|
||||
|
||||
if !strings.Contains(out, "plain") {
|
||||
t.Errorf("text output missing message: %s", out)
|
||||
}
|
||||
// Console writer output is ANSI-colored key=value form, not JSON.
|
||||
var parsed map[string]any
|
||||
if json.Unmarshal([]byte(strings.Split(out, "\n")[0]), &parsed) == nil {
|
||||
t.Errorf("text format unexpectedly parsed as JSON: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGet_AutoInitializesWhenUnused(t *testing.T) {
|
||||
serializeLoggerTests.Lock()
|
||||
defer serializeLoggerTests.Unlock()
|
||||
|
||||
// Forcibly clear the global so Get() hits the lazy-init branch.
|
||||
globalLogger = nil
|
||||
|
||||
l := Get()
|
||||
if l == nil {
|
||||
t.Fatal("Get() returned nil; lazy init did not run")
|
||||
}
|
||||
if globalLogger == nil {
|
||||
t.Fatal("globalLogger still nil after Get()")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithComponent_AddsComponentField(t *testing.T) {
|
||||
serializeLoggerTests.Lock()
|
||||
defer serializeLoggerTests.Unlock()
|
||||
|
||||
out := captureStdout(t, func() {
|
||||
Init(Config{Level: "info", Format: "json"})
|
||||
comp := WithComponent("buckets")
|
||||
comp.Info().Msg("tagged")
|
||||
})
|
||||
|
||||
line := firstNonEmptyLine(out)
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal([]byte(line), &parsed); err != nil {
|
||||
t.Fatalf("not JSON: %v — %s", err, line)
|
||||
}
|
||||
if got, _ := parsed["component"].(string); got != "buckets" {
|
||||
t.Errorf("component = %v, want buckets", parsed["component"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogger_WithContext_AddsFields(t *testing.T) {
|
||||
serializeLoggerTests.Lock()
|
||||
defer serializeLoggerTests.Unlock()
|
||||
|
||||
out := captureStdout(t, func() {
|
||||
Init(Config{Level: "info", Format: "json"})
|
||||
l := Get().WithContext(map[string]any{
|
||||
"request_id": "req-42",
|
||||
"attempt": 2,
|
||||
})
|
||||
l.Info().Msg("ctx")
|
||||
})
|
||||
|
||||
line := firstNonEmptyLine(out)
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal([]byte(line), &parsed); err != nil {
|
||||
t.Fatalf("not JSON: %v — %s", err, line)
|
||||
}
|
||||
if parsed["request_id"] != "req-42" {
|
||||
t.Errorf("request_id = %v", parsed["request_id"])
|
||||
}
|
||||
// JSON numbers decode to float64.
|
||||
if got, _ := parsed["attempt"].(float64); got != 2 {
|
||||
t.Errorf("attempt = %v, want 2", parsed["attempt"])
|
||||
}
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
func firstNonEmptyLine(s string) string {
|
||||
for l := range strings.SplitSeq(s, "\n") {
|
||||
if strings.TrimSpace(l) != "" {
|
||||
return l
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func keysOf(m map[string]any) []string {
|
||||
out := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package logger
|
||||
|
||||
// redactThreshold is the minimum length at which partial visibility is shown.
|
||||
// Below this, the entire value is replaced with "***" because first-4/last-4
|
||||
// would leak too much of short strings.
|
||||
const redactThreshold = 12
|
||||
|
||||
// RedactKey returns a partially-visible form of a non-secret identifier
|
||||
// (access key ID, user ID, etc.) showing first 4 and last 4 characters.
|
||||
// Shorter values are fully redacted to avoid over-exposure.
|
||||
func RedactKey(s string) string {
|
||||
if len(s) < redactThreshold {
|
||||
return "***"
|
||||
}
|
||||
return s[:4] + "…" + s[len(s)-4:]
|
||||
}
|
||||
|
||||
// RedactToken returns "***" for any secret (passwords, bearer tokens, JWT,
|
||||
// client secrets). Secrets must never be partially visible in logs.
|
||||
func RedactToken(s string) string {
|
||||
return "***"
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package logger
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestRedactKey(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"empty", "", "***"},
|
||||
{"short", "abc", "***"},
|
||||
{"just-under-threshold", "abcdefghijk", "***"}, // 11 chars
|
||||
{"at-threshold", "abcdefghijkl", "abcd…ijkl"}, // 12 chars
|
||||
{"long", "GK5a9bfcdefghijklzW9q", "GK5a…zW9q"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := RedactKey(tc.in); got != tc.want {
|
||||
t.Errorf("RedactKey(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedactToken_AlwaysStars(t *testing.T) {
|
||||
for _, in := range []string{"", "abc", "very-long-secret-token-value"} {
|
||||
if got := RedactToken(in); got != "***" {
|
||||
t.Errorf("RedactToken(%q) = %q, want ***", in, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -91,5 +91,4 @@ func (c *Cache) cleanupExpired() {
|
||||
}
|
||||
}
|
||||
|
||||
// Global cache instance
|
||||
var GlobalCache = NewCache()
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCache_GetMissReturnsNil(t *testing.T) {
|
||||
c := NewCache()
|
||||
if v := c.Get("nope"); v != nil {
|
||||
t.Errorf("expected nil for missing key, got %v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCache_SetThenGetReturnsValue(t *testing.T) {
|
||||
c := NewCache()
|
||||
c.Set("k", "v", time.Minute)
|
||||
got := c.Get("k")
|
||||
if got != "v" {
|
||||
t.Errorf("Get(k) = %v, want v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCache_SetWithDifferentTypes(t *testing.T) {
|
||||
c := NewCache()
|
||||
c.Set("str", "hello", time.Minute)
|
||||
c.Set("int", 42, time.Minute)
|
||||
c.Set("slice", []int{1, 2, 3}, time.Minute)
|
||||
|
||||
if got := c.Get("str"); got != "hello" {
|
||||
t.Errorf("str: got %v", got)
|
||||
}
|
||||
if got := c.Get("int"); got != 42 {
|
||||
t.Errorf("int: got %v", got)
|
||||
}
|
||||
if got, ok := c.Get("slice").([]int); !ok || len(got) != 3 {
|
||||
t.Errorf("slice: got %v", c.Get("slice"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCache_GetExpiredReturnsNil(t *testing.T) {
|
||||
c := NewCache()
|
||||
c.Set("k", "v", 10*time.Millisecond)
|
||||
time.Sleep(25 * time.Millisecond)
|
||||
if got := c.Get("k"); got != nil {
|
||||
t.Errorf("expected nil after TTL, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCache_DeleteRemovesItem(t *testing.T) {
|
||||
c := NewCache()
|
||||
c.Set("k", "v", time.Minute)
|
||||
c.Delete("k")
|
||||
if got := c.Get("k"); got != nil {
|
||||
t.Errorf("expected nil after Delete, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCache_DeleteMissingKeyIsNoOp(t *testing.T) {
|
||||
c := NewCache()
|
||||
// Should not panic or error.
|
||||
c.Delete("never-set")
|
||||
}
|
||||
|
||||
func TestCache_ClearRemovesAllItems(t *testing.T) {
|
||||
c := NewCache()
|
||||
c.Set("a", 1, time.Minute)
|
||||
c.Set("b", 2, time.Minute)
|
||||
c.Set("c", 3, time.Minute)
|
||||
|
||||
c.Clear()
|
||||
|
||||
if c.Get("a") != nil || c.Get("b") != nil || c.Get("c") != nil {
|
||||
t.Errorf("expected all items cleared")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCache_SetOverwrites(t *testing.T) {
|
||||
c := NewCache()
|
||||
c.Set("k", "v1", time.Minute)
|
||||
c.Set("k", "v2", time.Minute)
|
||||
if got := c.Get("k"); got != "v2" {
|
||||
t.Errorf("expected v2 after overwrite, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCache_ConcurrentAccess exercises the RWMutex under load. Run with
|
||||
// `go test -race` to catch data races. Uses bounded concurrency so the test
|
||||
// stays deterministic.
|
||||
func TestCache_ConcurrentAccess(t *testing.T) {
|
||||
c := NewCache()
|
||||
const goroutines = 50
|
||||
const opsPerGoroutine = 100
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(goroutines)
|
||||
|
||||
for g := range goroutines {
|
||||
go func(id int) {
|
||||
defer wg.Done()
|
||||
for i := range opsPerGoroutine {
|
||||
key := fmt.Sprintf("k%d", (id+i)%10)
|
||||
c.Set(key, i, time.Minute)
|
||||
_ = c.Get(key)
|
||||
if i%10 == 0 {
|
||||
c.Delete(key)
|
||||
}
|
||||
}
|
||||
}(g)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
// If we got here without a panic and `-race` is clean, the RWMutex is
|
||||
// protecting the map correctly.
|
||||
}
|
||||
|
||||
// TestGlobalCache_IsUsable is a smoke test for the package-level var.
|
||||
// It doesn't Clear() afterwards because the global is shared state that
|
||||
// other packages may depend on at test time.
|
||||
func TestGlobalCache_IsUsable(t *testing.T) {
|
||||
key := "stage2-smoke-key"
|
||||
GlobalCache.Set(key, "x", time.Minute)
|
||||
t.Cleanup(func() { GlobalCache.Delete(key) })
|
||||
if got := GlobalCache.Get(key); got != "x" {
|
||||
t.Errorf("GlobalCache.Get = %v, want x", got)
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
@@ -50,6 +51,15 @@ func IsConnectionRefused(err error) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// Some HTTP clients (e.g. azuretls) collapse the syscall error into a
|
||||
// plain string before returning. Fall back to substring matching so the
|
||||
// retry path still triggers for those wrappers. "connectex" is the
|
||||
// Windows variant emitted by the Go runtime.
|
||||
msg := err.Error()
|
||||
if strings.Contains(msg, "connection refused") || strings.Contains(msg, "actively refused") {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestIsConnectionRefused(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "nil error returns false",
|
||||
err: nil,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "unrelated error returns false",
|
||||
err: errors.New("something else went wrong"),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "bare ECONNREFUSED returns true (fallback errors.Is branch)",
|
||||
err: syscall.ECONNREFUSED,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "wrapped ECONNREFUSED returns true (fallback errors.Is branch)",
|
||||
err: fmt.Errorf("context: %w", syscall.ECONNREFUSED),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "OpError dial+ECONNREFUSED returns true (primary branch)",
|
||||
err: &net.OpError{
|
||||
Op: "dial",
|
||||
Net: "tcp",
|
||||
Err: syscall.ECONNREFUSED,
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "OpError read+ECONNREFUSED returns true (primary branch)",
|
||||
err: &net.OpError{
|
||||
Op: "read",
|
||||
Net: "tcp",
|
||||
Err: syscall.ECONNREFUSED,
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "OpError dial+ETIMEDOUT returns false (primary branch, wrong errno)",
|
||||
err: &net.OpError{
|
||||
Op: "dial",
|
||||
Net: "tcp",
|
||||
Err: syscall.ETIMEDOUT,
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "OpError dial+plain error falls through to errors.Is and returns false (inner As miss)",
|
||||
err: &net.OpError{
|
||||
Op: "dial",
|
||||
Net: "tcp",
|
||||
Err: errors.New("not a syscall errno"),
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "OpError write+ECONNREFUSED returns true via fallback errors.Is",
|
||||
err: &net.OpError{
|
||||
Op: "write",
|
||||
Net: "tcp",
|
||||
Err: syscall.ECONNREFUSED,
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := IsConnectionRefused(tc.err); got != tc.want {
|
||||
t.Errorf("IsConnectionRefused(%v) = %v, want %v", tc.err, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// fastRetryConfig keeps test runtime in the low-millisecond range.
|
||||
func fastRetryConfig() RetryConfig {
|
||||
return RetryConfig{
|
||||
MaxRetries: 3,
|
||||
InitialBackoff: 1 * time.Millisecond,
|
||||
MaxBackoff: 5 * time.Millisecond,
|
||||
BackoffFactor: 2.0,
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryWithBackoff_SuccessOnFirstAttempt(t *testing.T) {
|
||||
calls := 0
|
||||
err := RetryWithBackoff(context.Background(), fastRetryConfig(), func() error {
|
||||
calls++
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Errorf("want 1 call, got %d", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryWithBackoff_NonRetryableErrorReturnedImmediately(t *testing.T) {
|
||||
sentinel := errors.New("boom")
|
||||
calls := 0
|
||||
err := RetryWithBackoff(context.Background(), fastRetryConfig(), func() error {
|
||||
calls++
|
||||
return sentinel
|
||||
})
|
||||
if !errors.Is(err, sentinel) {
|
||||
t.Errorf("want wrapped sentinel, got %v", err)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Errorf("want 1 call (no retry on non-conn-refused), got %d", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryWithBackoff_SuccessAfterTransientRefusals(t *testing.T) {
|
||||
cfg := fastRetryConfig()
|
||||
cfg.MaxRetries = 5 // allow up to 6 attempts
|
||||
calls := 0
|
||||
err := RetryWithBackoff(context.Background(), cfg, func() error {
|
||||
calls++
|
||||
if calls < 3 {
|
||||
return syscall.ECONNREFUSED
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if calls != 3 {
|
||||
t.Errorf("want 3 calls (2 failures + 1 success), got %d", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryWithBackoff_MaxRetriesExceededReturnsWrappedError(t *testing.T) {
|
||||
cfg := fastRetryConfig()
|
||||
cfg.MaxRetries = 2 // 3 total attempts (attempt 0, 1, 2)
|
||||
calls := 0
|
||||
err := RetryWithBackoff(context.Background(), cfg, func() error {
|
||||
calls++
|
||||
return syscall.ECONNREFUSED
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error after exhausting retries, got nil")
|
||||
}
|
||||
if !errors.Is(err, syscall.ECONNREFUSED) {
|
||||
t.Errorf("expected wrapped ECONNREFUSED, got %v", err)
|
||||
}
|
||||
// The loop runs attempt = 0..MaxRetries inclusive.
|
||||
if calls != cfg.MaxRetries+1 {
|
||||
t.Errorf("want %d calls, got %d", cfg.MaxRetries+1, calls)
|
||||
}
|
||||
// Error message includes the retry count for operator diagnostics.
|
||||
if !containsAll(err.Error(), "max retries", "2") {
|
||||
t.Errorf("error message missing retry count: %q", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryWithBackoff_ZeroMaxRetriesReturnsImmediately(t *testing.T) {
|
||||
cfg := RetryConfig{
|
||||
MaxRetries: 0,
|
||||
InitialBackoff: 1 * time.Second, // large on purpose; must not sleep
|
||||
MaxBackoff: 5 * time.Second,
|
||||
BackoffFactor: 2.0,
|
||||
}
|
||||
calls := 0
|
||||
start := time.Now()
|
||||
err := RetryWithBackoff(context.Background(), cfg, func() error {
|
||||
calls++
|
||||
return syscall.ECONNREFUSED
|
||||
})
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !errors.Is(err, syscall.ECONNREFUSED) {
|
||||
t.Errorf("expected wrapped ECONNREFUSED, got %v", err)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Errorf("want 1 call (no retry budget), got %d", calls)
|
||||
}
|
||||
// The only sleep would be after the attempt, but attempt == MaxRetries is
|
||||
// short-circuited before the sleep select. So total runtime must be well
|
||||
// under InitialBackoff.
|
||||
if elapsed >= 500*time.Millisecond {
|
||||
t.Errorf("no-retry path should not have slept; elapsed %v", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryWithBackoff_ContextCancelledDuringBackoff(t *testing.T) {
|
||||
// Use a slow backoff so cancellation is guaranteed to land during the sleep.
|
||||
cfg := RetryConfig{
|
||||
MaxRetries: 5,
|
||||
InitialBackoff: 50 * time.Millisecond,
|
||||
MaxBackoff: 1 * time.Second,
|
||||
BackoffFactor: 2.0,
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
// Cancel shortly after the first failed attempt starts its backoff.
|
||||
go func() {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
cancel()
|
||||
}()
|
||||
calls := 0
|
||||
err := RetryWithBackoff(ctx, cfg, func() error {
|
||||
calls++
|
||||
return syscall.ECONNREFUSED
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error from cancelled context, got nil")
|
||||
}
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Errorf("expected wrapped context.Canceled, got %v", err)
|
||||
}
|
||||
if calls < 1 {
|
||||
t.Errorf("expected at least 1 call before cancellation, got %d", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryWithBackoff_WaitsBetweenAttempts(t *testing.T) {
|
||||
// Lower-bound timing check — with InitialBackoff=20ms and BackoffFactor=2,
|
||||
// three failed attempts sleep ~20ms + ~40ms = ~60ms before giving up.
|
||||
// Assert >= 50ms to absorb scheduler jitter.
|
||||
cfg := RetryConfig{
|
||||
MaxRetries: 2,
|
||||
InitialBackoff: 20 * time.Millisecond,
|
||||
MaxBackoff: 100 * time.Millisecond,
|
||||
BackoffFactor: 2.0,
|
||||
}
|
||||
start := time.Now()
|
||||
_ = RetryWithBackoff(context.Background(), cfg, func() error {
|
||||
return syscall.ECONNREFUSED
|
||||
})
|
||||
elapsed := time.Since(start)
|
||||
if elapsed < 50*time.Millisecond {
|
||||
t.Errorf("expected at least ~60ms of backoff delay, got %v", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
// containsAll reports whether s contains every substring in subs.
|
||||
func containsAll(s string, subs ...string) bool {
|
||||
for _, sub := range subs {
|
||||
if !strings.Contains(s, sub) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
---
|
||||
services:
|
||||
garage:
|
||||
image: dxflrs/garage:v2.1.0
|
||||
volumes:
|
||||
- ./garage.toml:/etc/garage.toml:ro
|
||||
tmpfs:
|
||||
- /var/lib/garage/meta
|
||||
- /var/lib/garage/data
|
||||
healthcheck:
|
||||
test: ["CMD", "/garage", "status"]
|
||||
interval: 2s
|
||||
timeout: 2s
|
||||
retries: 30
|
||||
start_period: 2s
|
||||
|
||||
backend:
|
||||
build:
|
||||
context: ../../..
|
||||
dockerfile: Dockerfile
|
||||
environment:
|
||||
GARAGE_UI_SERVER_HOST: "0.0.0.0"
|
||||
GARAGE_UI_SERVER_PORT: "8080"
|
||||
GARAGE_UI_SERVER_ENVIRONMENT: "development"
|
||||
GARAGE_UI_GARAGE_ENDPOINT: "garage:3900"
|
||||
GARAGE_UI_GARAGE_REGION: "garage"
|
||||
GARAGE_UI_GARAGE_ADMIN_ENDPOINT: "http://garage:3903"
|
||||
GARAGE_UI_GARAGE_ADMIN_TOKEN: "smoke-admin-token-do-not-use-in-prod"
|
||||
GARAGE_UI_AUTH_ADMIN_ENABLED: "true"
|
||||
GARAGE_UI_AUTH_ADMIN_USERNAME: "smokeadmin"
|
||||
GARAGE_UI_AUTH_ADMIN_PASSWORD: "smokepass"
|
||||
GARAGE_UI_CORS_ENABLED: "false"
|
||||
GARAGE_UI_LOGGING_LEVEL: "info"
|
||||
GARAGE_UI_LOGGING_FORMAT: "json"
|
||||
ports:
|
||||
- "127.0.0.1:18080:8080"
|
||||
depends_on:
|
||||
garage:
|
||||
condition: service_healthy
|
||||
@@ -0,0 +1,23 @@
|
||||
metadata_dir = "/var/lib/garage/meta"
|
||||
data_dir = "/var/lib/garage/data"
|
||||
db_engine = "lmdb"
|
||||
|
||||
replication_factor = 1
|
||||
|
||||
rpc_bind_addr = "[::]:3901"
|
||||
rpc_public_addr = "127.0.0.1:3901"
|
||||
rpc_secret = "0000000000000000000000000000000000000000000000000000000000000000"
|
||||
|
||||
[s3_api]
|
||||
s3_region = "garage"
|
||||
api_bind_addr = "[::]:3900"
|
||||
root_domain = ".s3.garage"
|
||||
|
||||
[s3_web]
|
||||
bind_addr = "[::]:3902"
|
||||
root_domain = ".web.garage"
|
||||
index = "index.html"
|
||||
|
||||
[admin]
|
||||
api_bind_addr = "[::]:3903"
|
||||
admin_token = "smoke-admin-token-do-not-use-in-prod"
|
||||
@@ -0,0 +1,388 @@
|
||||
//go:build smoke
|
||||
|
||||
package smoke_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
composeProject = "garage-ui-smoke"
|
||||
backendBaseURL = "http://127.0.0.1:18080"
|
||||
adminToken = "smoke-admin-token-do-not-use-in-prod"
|
||||
adminUsername = "smokeadmin"
|
||||
adminPassword = "smokepass"
|
||||
readyTimeout = 90 * time.Second
|
||||
readyPollEvery = 1 * time.Second
|
||||
)
|
||||
|
||||
func composeFile(t testing.TB) string {
|
||||
t.Helper()
|
||||
_, thisFile, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
t.Fatal("runtime.Caller failed")
|
||||
}
|
||||
return filepath.Join(filepath.Dir(thisFile), "docker-compose.test.yml")
|
||||
}
|
||||
|
||||
func runCompose(t testing.TB, args ...string) string {
|
||||
t.Helper()
|
||||
full := append([]string{"compose", "-p", composeProject, "-f", composeFile(t)}, args...)
|
||||
cmd := exec.Command("docker", full...)
|
||||
var out bytes.Buffer
|
||||
cmd.Stdout = &out
|
||||
cmd.Stderr = &out
|
||||
if err := cmd.Run(); err != nil {
|
||||
t.Fatalf("docker %s failed: %v\n%s", strings.Join(full, " "), err, out.String())
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
func composeExec(service string, argv ...string) (string, error) {
|
||||
full := append([]string{"compose", "-p", composeProject, "-f", mustComposeFile(), "exec", "-T", service}, argv...)
|
||||
cmd := exec.Command("docker", full...)
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return "", fmt.Errorf("docker %s: %w\nstderr:\n%s", strings.Join(full, " "), err, stderr.String())
|
||||
}
|
||||
return stdout.String(), nil
|
||||
}
|
||||
|
||||
func mustComposeFile() string {
|
||||
_, thisFile, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
panic("runtime.Caller failed")
|
||||
}
|
||||
return filepath.Join(filepath.Dir(thisFile), "docker-compose.test.yml")
|
||||
}
|
||||
|
||||
func waitForHTTP(ctx context.Context, url string, timeout time.Duration) error {
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err == nil {
|
||||
_ = resp.Body.Close()
|
||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(readyPollEvery):
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("timeout waiting for %s", url)
|
||||
}
|
||||
|
||||
func initGarageLayout() error {
|
||||
nodeOut, err := composeExec("garage", "/garage", "node", "id", "-q")
|
||||
if err != nil {
|
||||
return fmt.Errorf("garage node id: %w", err)
|
||||
}
|
||||
nodeID := strings.TrimSpace(nodeOut)
|
||||
if i := strings.Index(nodeID, "@"); i > 0 {
|
||||
nodeID = nodeID[:i]
|
||||
}
|
||||
if nodeID == "" {
|
||||
return fmt.Errorf("empty node id from garage node id")
|
||||
}
|
||||
|
||||
if _, err := composeExec("garage", "/garage", "layout", "assign", "-z", "dc1", "-c", "1G", nodeID); err != nil {
|
||||
return fmt.Errorf("garage layout assign: %w", err)
|
||||
}
|
||||
if _, err := composeExec("garage", "/garage", "layout", "apply", "--version", "1"); err != nil {
|
||||
return fmt.Errorf("garage layout apply: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
fmt.Fprintln(os.Stderr, "[smoke] bringing up garage (waiting for healthcheck)...")
|
||||
runComposeNoT("up", "-d", "--wait", "garage")
|
||||
|
||||
cleanup := func() {
|
||||
fmt.Fprintln(os.Stderr, "[smoke] tearing down compose...")
|
||||
_ = exec.Command("docker", "compose", "-p", composeProject, "-f", mustComposeFile(), "logs").Run()
|
||||
_ = exec.Command("docker", "compose", "-p", composeProject, "-f", mustComposeFile(), "down", "-v").Run()
|
||||
}
|
||||
|
||||
fmt.Fprintln(os.Stderr, "[smoke] initializing garage cluster layout...")
|
||||
if err := initGarageLayout(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[smoke] layout init failed: %v\n", err)
|
||||
cleanup()
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Fprintln(os.Stderr, "[smoke] starting backend...")
|
||||
runComposeNoT("up", "-d", "backend")
|
||||
|
||||
if err := waitForHTTP(ctx, backendBaseURL+"/health", readyTimeout); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[smoke] backend not ready: %v\n", err)
|
||||
cleanup()
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
code := m.Run()
|
||||
|
||||
if code != 0 {
|
||||
fmt.Fprintln(os.Stderr, "[smoke] tests failed — dumping compose logs")
|
||||
logCmd := exec.Command("docker", "compose", "-p", composeProject, "-f", mustComposeFile(), "logs", "--no-color")
|
||||
logCmd.Stdout = os.Stderr
|
||||
logCmd.Stderr = os.Stderr
|
||||
_ = logCmd.Run()
|
||||
}
|
||||
|
||||
_ = exec.Command("docker", "compose", "-p", composeProject, "-f", mustComposeFile(), "down", "-v").Run()
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
func runComposeNoT(args ...string) {
|
||||
full := append([]string{"compose", "-p", composeProject, "-f", mustComposeFile()}, args...)
|
||||
cmd := exec.Command("docker", full...)
|
||||
cmd.Stdout = os.Stderr
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[smoke] docker %s failed: %v\n", strings.Join(full, " "), err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
type testState struct {
|
||||
client *http.Client
|
||||
token string
|
||||
bucketName string
|
||||
objectKey string
|
||||
sourceBody []byte
|
||||
accessKeyID string
|
||||
}
|
||||
|
||||
func (s *testState) do(t *testing.T, method, path string, body io.Reader, contentType string) *http.Response {
|
||||
t.Helper()
|
||||
req, err := http.NewRequest(method, backendBaseURL+path, body)
|
||||
if err != nil {
|
||||
t.Fatalf("build request: %v", err)
|
||||
}
|
||||
if s.token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+s.token)
|
||||
}
|
||||
if contentType != "" {
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
}
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("%s %s: %v", method, path, err)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func readBody(t *testing.T, resp *http.Response) []byte {
|
||||
t.Helper()
|
||||
defer resp.Body.Close()
|
||||
b, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read body: %v", err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func TestSmokeGoldenPath(t *testing.T) {
|
||||
sourcePath := filepath.Join(filepath.Dir(mustComposeFile()), "testdata", "small.bin")
|
||||
source, err := os.ReadFile(sourcePath)
|
||||
if err != nil {
|
||||
t.Fatalf("read fixture: %v", err)
|
||||
}
|
||||
|
||||
state := &testState{
|
||||
client: &http.Client{Timeout: 30 * time.Second},
|
||||
bucketName: fmt.Sprintf("smoke-%d", time.Now().UnixNano()),
|
||||
objectKey: "small.bin",
|
||||
sourceBody: source,
|
||||
}
|
||||
|
||||
t.Run("AdminLogin", func(t *testing.T) {
|
||||
payload := fmt.Sprintf(`{"username":%q,"password":%q}`, adminUsername, adminPassword)
|
||||
resp := state.do(t, "POST", "/auth/login", strings.NewReader(payload), "application/json")
|
||||
body := readBody(t, resp)
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("login status = %d, body = %s", resp.StatusCode, body)
|
||||
}
|
||||
var parsed struct {
|
||||
Success bool `json:"success"`
|
||||
Token string `json:"token"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &parsed); err != nil {
|
||||
t.Fatalf("parse login body: %v, body=%s", err, body)
|
||||
}
|
||||
if !parsed.Success || parsed.Token == "" {
|
||||
t.Fatalf("login did not return token: %s", body)
|
||||
}
|
||||
state.token = parsed.Token
|
||||
})
|
||||
|
||||
t.Run("CreateBucket", func(t *testing.T) {
|
||||
if state.token == "" {
|
||||
t.Skip("login did not succeed")
|
||||
}
|
||||
payload := fmt.Sprintf(`{"name":%q}`, state.bucketName)
|
||||
resp := state.do(t, "POST", "/api/v1/buckets", strings.NewReader(payload), "application/json")
|
||||
body := readBody(t, resp)
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
t.Fatalf("create bucket status = %d, body = %s", resp.StatusCode, body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("CreateKey", func(t *testing.T) {
|
||||
if state.token == "" {
|
||||
t.Skip("login did not succeed")
|
||||
}
|
||||
payload := `{"name":"smoke-key"}`
|
||||
resp := state.do(t, "POST", "/api/v1/users", strings.NewReader(payload), "application/json")
|
||||
body := readBody(t, resp)
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
t.Fatalf("create key status = %d, body = %s", resp.StatusCode, body)
|
||||
}
|
||||
var parsed struct {
|
||||
Data struct {
|
||||
AccessKeyID string `json:"accessKeyId"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &parsed); err != nil {
|
||||
t.Fatalf("parse key body: %v, body=%s", err, body)
|
||||
}
|
||||
if parsed.Data.AccessKeyID == "" {
|
||||
t.Fatalf("empty accessKeyId: %s", body)
|
||||
}
|
||||
state.accessKeyID = parsed.Data.AccessKeyID
|
||||
})
|
||||
|
||||
t.Run("GrantPermission", func(t *testing.T) {
|
||||
if state.accessKeyID == "" {
|
||||
t.Skip("key creation did not succeed")
|
||||
}
|
||||
payload := fmt.Sprintf(
|
||||
`{"accessKeyId":%q,"permissions":{"read":true,"write":true,"owner":false}}`,
|
||||
state.accessKeyID,
|
||||
)
|
||||
path := fmt.Sprintf("/api/v1/buckets/%s/permissions", state.bucketName)
|
||||
resp := state.do(t, "POST", path, strings.NewReader(payload), "application/json")
|
||||
body := readBody(t, resp)
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
t.Fatalf("grant permission status = %d, body = %s", resp.StatusCode, body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("UploadObject", func(t *testing.T) {
|
||||
if state.token == "" {
|
||||
t.Skip("login did not succeed")
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
mw := multipart.NewWriter(&buf)
|
||||
fw, err := mw.CreateFormFile("file", filepath.Base(state.objectKey))
|
||||
if err != nil {
|
||||
t.Fatalf("create form file: %v", err)
|
||||
}
|
||||
if _, err := fw.Write(state.sourceBody); err != nil {
|
||||
t.Fatalf("write form file: %v", err)
|
||||
}
|
||||
if err := mw.WriteField("key", state.objectKey); err != nil {
|
||||
t.Fatalf("write key field: %v", err)
|
||||
}
|
||||
if err := mw.Close(); err != nil {
|
||||
t.Fatalf("close multipart: %v", err)
|
||||
}
|
||||
path := fmt.Sprintf("/api/v1/buckets/%s/objects/", state.bucketName)
|
||||
resp := state.do(t, "POST", path, &buf, mw.FormDataContentType())
|
||||
body := readBody(t, resp)
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
t.Fatalf("upload status = %d, body = %s", resp.StatusCode, body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ListObjects", func(t *testing.T) {
|
||||
if state.token == "" {
|
||||
t.Skip("login did not succeed")
|
||||
}
|
||||
path := fmt.Sprintf("/api/v1/buckets/%s/objects/", state.bucketName)
|
||||
resp := state.do(t, "GET", path, nil, "")
|
||||
body := readBody(t, resp)
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("list status = %d, body = %s", resp.StatusCode, body)
|
||||
}
|
||||
if !bytes.Contains(body, []byte(state.objectKey)) {
|
||||
t.Fatalf("uploaded key %q not found in list: %s", state.objectKey, body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("DownloadObject", func(t *testing.T) {
|
||||
if state.token == "" {
|
||||
t.Skip("login did not succeed")
|
||||
}
|
||||
path := fmt.Sprintf("/api/v1/buckets/%s/objects/%s", state.bucketName, state.objectKey)
|
||||
resp := state.do(t, "GET", path, nil, "")
|
||||
body := readBody(t, resp)
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("download status = %d, body = %s", resp.StatusCode, body)
|
||||
}
|
||||
if !bytes.Equal(body, state.sourceBody) {
|
||||
t.Fatalf("downloaded bytes differ: got %d bytes, want %d bytes", len(body), len(state.sourceBody))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("DeleteObject", func(t *testing.T) {
|
||||
if state.token == "" {
|
||||
t.Skip("login did not succeed")
|
||||
}
|
||||
path := fmt.Sprintf("/api/v1/buckets/%s/objects/%s", state.bucketName, state.objectKey)
|
||||
resp := state.do(t, "DELETE", path, nil, "")
|
||||
body := readBody(t, resp)
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
t.Fatalf("delete object status = %d, body = %s", resp.StatusCode, body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("DeleteBucket", func(t *testing.T) {
|
||||
if state.token == "" {
|
||||
t.Skip("login did not succeed")
|
||||
}
|
||||
path := fmt.Sprintf("/api/v1/buckets/%s", state.bucketName)
|
||||
resp := state.do(t, "DELETE", path, nil, "")
|
||||
body := readBody(t, resp)
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
t.Fatalf("delete bucket status = %d, body = %s", resp.StatusCode, body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ListKeys", func(t *testing.T) {
|
||||
if state.token == "" {
|
||||
t.Skip("login did not succeed")
|
||||
}
|
||||
resp := state.do(t, "GET", "/api/v1/users", nil, "")
|
||||
body := readBody(t, resp)
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("list users status = %d, body = %s", resp.StatusCode, body)
|
||||
}
|
||||
})
|
||||
}
|
||||
BIN
Binary file not shown.
+4
-6
@@ -57,9 +57,6 @@ auth:
|
||||
|
||||
# OIDC Provider URLs
|
||||
issuer_url: "https://keycloak.example.com/realms/master"
|
||||
auth_url: "https://keycloak.example.com/realms/master/protocol/openid-connect/auth"
|
||||
token_url: "https://keycloak.example.com/realms/master/protocol/openid-connect/token"
|
||||
userinfo_url: "https://keycloak.example.com/realms/master/protocol/openid-connect/userinfo"
|
||||
|
||||
# Token validation
|
||||
skip_issuer_check: false
|
||||
@@ -103,7 +100,8 @@ cors:
|
||||
allow_credentials: false
|
||||
max_age: 3600
|
||||
|
||||
# Logging
|
||||
# Logging Configuration
|
||||
# The application uses zerolog for structured logging
|
||||
logging:
|
||||
level: "info" # debug, info, warn, error
|
||||
format: "json" # json, text
|
||||
level: "info" # Options: debug, info, warn, error
|
||||
format: "text" or "json"
|
||||
|
||||
@@ -41,7 +41,3 @@ services:
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MC4CAQAwBQYDK2VwBCIEIH0ZHqIV7MEyVsxNYc5TA/a0qaBxgq3ntlFS3w1F03MS
|
||||
-----END PRIVATE KEY-----
|
||||
|
||||
# Logging
|
||||
GARAGE_UI_LOGGING_LEVEL: "info"
|
||||
GARAGE_UI_LOGGING_FORMAT: "json"
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { FolderIcon, Loader2, MoreVertical, Plus, Search, Settings, Trash2 } from 'lucide-react';
|
||||
import { FolderIcon, Globe, Loader2, MoreVertical, Plus, Search, Settings, Trash2 } from 'lucide-react';
|
||||
import { formatBytes } from '@/lib/file-utils';
|
||||
import { formatDate } from '@/lib/utils';
|
||||
import type { Bucket } from '@/types';
|
||||
@@ -23,6 +23,7 @@ interface BucketListViewProps {
|
||||
onOpenSettings: (bucket: Bucket) => void;
|
||||
onCreateBucket: () => void;
|
||||
onDeleteBucket: (bucket: Bucket) => void;
|
||||
onWebsiteSettings: (bucket: Bucket) => void;
|
||||
}
|
||||
|
||||
export function BucketListView({
|
||||
@@ -34,6 +35,7 @@ export function BucketListView({
|
||||
onOpenSettings,
|
||||
onCreateBucket,
|
||||
onDeleteBucket,
|
||||
onWebsiteSettings,
|
||||
}: BucketListViewProps) {
|
||||
const filteredBuckets = buckets.filter((bucket) =>
|
||||
bucket.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
@@ -94,7 +96,15 @@ export function BucketListView({
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => onViewBucket(bucket.name)}
|
||||
>
|
||||
<TableCell className="font-medium truncate max-w-[200px]">{bucket.name}</TableCell>
|
||||
<TableCell className="font-medium max-w-[200px]">
|
||||
<span className="truncate">{bucket.name}</span>
|
||||
{bucket.websiteAccess && (
|
||||
<Badge variant="outline" className="text-xs ml-2">
|
||||
<Globe className="h-3 w-3 mr-1" />
|
||||
Website
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="hidden sm:table-cell">
|
||||
<Badge variant="secondary">{bucket.region || 'default'}</Badge>
|
||||
</TableCell>
|
||||
@@ -123,6 +133,13 @@ export function BucketListView({
|
||||
<Settings className="h-4 w-4" />
|
||||
Settings
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onWebsiteSettings(bucket);
|
||||
}}>
|
||||
<Globe className="h-4 w-4" />
|
||||
Website Settings
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
|
||||
@@ -10,8 +10,8 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { accessApi } from '@/lib/api';
|
||||
import type { AccessKey, Bucket } from '@/types';
|
||||
import { useAccessKeys } from '@/hooks/useApi';
|
||||
import type { Bucket } from '@/types';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface BucketSettingsDialogProps {
|
||||
@@ -22,7 +22,7 @@ interface BucketSettingsDialogProps {
|
||||
}
|
||||
|
||||
export function BucketSettingsDialog({ open, onOpenChange, bucket, onGrantPermission }: BucketSettingsDialogProps) {
|
||||
const [availableKeys, setAvailableKeys] = useState<AccessKey[]>([]);
|
||||
const { data: availableKeys = [] } = useAccessKeys();
|
||||
const [selectedAccessKey, setSelectedAccessKey] = useState<string>('');
|
||||
const [permissionRead, setPermissionRead] = useState(false);
|
||||
const [permissionWrite, setPermissionWrite] = useState(false);
|
||||
@@ -30,20 +30,10 @@ export function BucketSettingsDialog({ open, onOpenChange, bucket, onGrantPermis
|
||||
|
||||
useEffect(() => {
|
||||
if (open && bucket) {
|
||||
loadAccessKeys();
|
||||
resetForm();
|
||||
}
|
||||
}, [open, bucket]);
|
||||
|
||||
const loadAccessKeys = async () => {
|
||||
try {
|
||||
const keys = await accessApi.listKeys();
|
||||
setAvailableKeys(keys);
|
||||
} catch (error) {
|
||||
console.error('Failed to load access keys:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setSelectedAccessKey('');
|
||||
setPermissionRead(false);
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import type { Bucket } from '@/types';
|
||||
|
||||
interface BucketWebsiteDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
bucket: Bucket | null;
|
||||
onSave: (
|
||||
bucketName: string,
|
||||
payload: { enabled: boolean; indexDocument?: string; errorDocument?: string }
|
||||
) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export function BucketWebsiteDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
bucket,
|
||||
onSave,
|
||||
}: BucketWebsiteDialogProps) {
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [indexDocument, setIndexDocument] = useState('index.html');
|
||||
const [errorDocument, setErrorDocument] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && bucket) {
|
||||
setEnabled(bucket.websiteAccess);
|
||||
setIndexDocument(bucket.websiteConfig?.indexDocument ?? 'index.html');
|
||||
setErrorDocument(bucket.websiteConfig?.errorDocument ?? '');
|
||||
}
|
||||
}, [open, bucket]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!bucket) return;
|
||||
setSaving(true);
|
||||
const success = await onSave(bucket.name, {
|
||||
enabled,
|
||||
indexDocument: enabled ? indexDocument : undefined,
|
||||
errorDocument: enabled && errorDocument ? errorDocument : undefined,
|
||||
});
|
||||
setSaving(false);
|
||||
if (success) onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Website Hosting — {bucket?.name}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Configure this bucket to serve a static website.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Website access</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Allow public HTTP access to bucket objects
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge variant={enabled ? 'default' : 'secondary'}>
|
||||
{enabled ? 'Enabled' : 'Disabled'}
|
||||
</Badge>
|
||||
<Switch checked={enabled} onCheckedChange={setEnabled} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{enabled && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">
|
||||
Index document <span className="text-destructive">*</span>
|
||||
</label>
|
||||
<Input
|
||||
value={indexDocument}
|
||||
onChange={(e) => setIndexDocument(e.target.value)}
|
||||
placeholder="index.html"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
The file served when a directory is requested (e.g. index.html)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Error document</label>
|
||||
<Input
|
||||
value={errorDocument}
|
||||
onChange={(e) => setErrorDocument(e.target.value)}
|
||||
placeholder="404.html (optional)"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
The file served when an object is not found (optional)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
variant={!enabled && bucket?.websiteAccess ? 'destructive' : 'default'}
|
||||
disabled={saving || (enabled && !indexDocument)}
|
||||
>
|
||||
{saving
|
||||
? 'Saving...'
|
||||
: !enabled && bucket?.websiteAccess
|
||||
? 'Disable Website'
|
||||
: 'Save'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import {useEffect, useState} from 'react';
|
||||
import {useEffect, useMemo, useState} from 'react';
|
||||
import {useNavigate} from 'react-router-dom';
|
||||
import {Badge} from '@/components/ui/badge';
|
||||
import {Button} from '@/components/ui/button';
|
||||
@@ -64,7 +64,6 @@ export function ObjectsTable({
|
||||
const navigate = useNavigate();
|
||||
const [sortColumn, setSortColumn] = useState<SortColumn>('name');
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>('asc');
|
||||
const [filteredObjects, setFilteredObjects] = useState<S3Object[]>([]);
|
||||
// Store tokens for each page: [undefined (page 1), token1 (page 2), token2 (page 3), ...]
|
||||
const [pageTokens, setPageTokens] = useState<(string | undefined)[]>([undefined]);
|
||||
const [currentPageIndex, setCurrentPageIndex] = useState(0);
|
||||
@@ -86,14 +85,13 @@ export function ObjectsTable({
|
||||
}
|
||||
}, [initialized, initialPageToken, initialItemsPerPage, itemsPerPage, nextContinuationToken, onPageChange, onItemsPerPageChange]);
|
||||
|
||||
const sortObjects = (objList: S3Object[]): S3Object[] => {
|
||||
const sorted = [...objList].sort((a, b) => {
|
||||
// Always put folders before files
|
||||
const filteredObjects = useMemo(() => {
|
||||
const query = searchQuery.toLowerCase();
|
||||
const filtered = objects.filter((obj) => obj.key.toLowerCase().includes(query));
|
||||
return [...filtered].sort((a, b) => {
|
||||
const aIsFolder = a.isFolder ? 1 : 0;
|
||||
const bIsFolder = b.isFolder ? 1 : 0;
|
||||
if (aIsFolder !== bIsFolder) {
|
||||
return bIsFolder - aIsFolder;
|
||||
}
|
||||
if (aIsFolder !== bIsFolder) return bIsFolder - aIsFolder;
|
||||
|
||||
let compareValue = 0;
|
||||
switch (sortColumn) {
|
||||
@@ -116,20 +114,7 @@ export function ObjectsTable({
|
||||
|
||||
return sortDirection === 'asc' ? compareValue : -compareValue;
|
||||
});
|
||||
|
||||
return sorted;
|
||||
};
|
||||
|
||||
// Effect 1: Apply client-side filtering and sorting (NO pagination reset)
|
||||
useEffect(() => {
|
||||
const filtered = objects.filter((obj) =>
|
||||
obj.key.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
const sorted = sortObjects(filtered);
|
||||
setFilteredObjects(sorted);
|
||||
|
||||
// Do NOT reset pagination - search/sort are client-side operations
|
||||
}, [searchQuery, objects, sortColumn, sortDirection]);
|
||||
}, [objects, searchQuery, sortColumn, sortDirection, currentPath]);
|
||||
|
||||
// Effect 2: Reset pagination ONLY on path navigation
|
||||
useEffect(() => {
|
||||
@@ -192,6 +177,7 @@ export function ObjectsTable({
|
||||
return (
|
||||
<>
|
||||
<div className="overflow-x-auto">
|
||||
<TooltipProvider>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
@@ -301,56 +287,58 @@ export function ObjectsTable({
|
||||
</TableCell>
|
||||
<TableCell>{obj.isFolder ? null : formatBytes(obj.size)}</TableCell>
|
||||
<TableCell>
|
||||
{obj.lastModified ?
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="decoration-dashed decoration-1 underline underline-offset-6 cursor-pointer text-muted-foreground hover:text-foreground transition-colors">
|
||||
{new Date(obj.lastModified).toLocaleDateString('en-GB', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
})} {new Date(obj.lastModified).toLocaleTimeString('en-GB', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
})} CET
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<div className="space-y-1 min-w-max">
|
||||
<div className="flex gap-3 items-center">
|
||||
<span className="text-sm text-gray-400 w-20 text-right">UTC</span>
|
||||
<span className="text-sm text-white">
|
||||
{new Date(obj.lastModified).toLocaleString('en-GB', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
timeZone: 'UTC',
|
||||
})} UTC
|
||||
</span>
|
||||
{obj.lastModified ? (() => {
|
||||
const d = new Date(obj.lastModified);
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="decoration-dashed decoration-1 underline underline-offset-6 cursor-pointer text-muted-foreground hover:text-foreground transition-colors">
|
||||
{d.toLocaleDateString('en-GB', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
})} {d.toLocaleTimeString('en-GB', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
})} CET
|
||||
</div>
|
||||
<div className="flex gap-3 items-center">
|
||||
<span className="text-sm text-gray-400 w-20 text-right">Relative</span>
|
||||
<span className="text-sm text-white">
|
||||
{formatRelativeTime(new Date(obj.lastModified))}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<div className="space-y-1 min-w-max">
|
||||
<div className="flex gap-3 items-center">
|
||||
<span className="text-sm text-gray-400 w-20 text-right">UTC</span>
|
||||
<span className="text-sm text-white">
|
||||
{d.toLocaleString('en-GB', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
timeZone: 'UTC',
|
||||
})} UTC
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-3 items-center">
|
||||
<span className="text-sm text-gray-400 w-20 text-right">Relative</span>
|
||||
<span className="text-sm text-white">
|
||||
{formatRelativeTime(d)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-3 items-center">
|
||||
<span className="text-sm text-gray-400 w-20 text-right">Timestamp</span>
|
||||
<span className="text-sm text-white font-mono">
|
||||
{d.toISOString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3 items-center">
|
||||
<span className="text-sm text-gray-400 w-20 text-right">Timestamp</span>
|
||||
<span className="text-sm text-white font-mono">
|
||||
{new Date(obj.lastModified).toISOString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>: null}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})() : null}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{!obj.isFolder && (
|
||||
@@ -386,6 +374,7 @@ export function ObjectsTable({
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
|
||||
{/* Pagination Controls */}
|
||||
|
||||
@@ -3,6 +3,8 @@ import {cn} from '@/lib/utils';
|
||||
import {Database, Key, LayoutDashboard, LogOut, Server, User} from 'lucide-react';
|
||||
import {useAuthStore} from '@/store/auth-store';
|
||||
import {Button} from '@/components/ui/button';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { healthApi, garageApi } from '@/lib/api';
|
||||
|
||||
interface NavItem {
|
||||
title: string;
|
||||
@@ -46,6 +48,24 @@ export function Sidebar({ isOpen, onClose }: SidebarProps) {
|
||||
logout();
|
||||
};
|
||||
|
||||
const { data: uiVersion } = useQuery({
|
||||
queryKey: ['ui-version'],
|
||||
queryFn: healthApi.getVersion,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const { data: nodeInfo } = useQuery({
|
||||
queryKey: ['garage-version'],
|
||||
queryFn: () => garageApi.getNodeInfo('self'),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const garageVersion = nodeInfo
|
||||
? Object.values(nodeInfo.success)[0]?.garageVersion
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -107,6 +127,13 @@ export function Sidebar({ isOpen, onClose }: SidebarProps) {
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{(uiVersion || garageVersion) && (
|
||||
<div className="px-4 pb-3 text-xs text-muted-foreground text-center">
|
||||
{uiVersion && `UI ${uiVersion}`}
|
||||
{uiVersion && garageVersion && ' | '}
|
||||
{garageVersion && `Garage ${garageVersion}`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface SwitchProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange'> {
|
||||
checked?: boolean;
|
||||
onCheckedChange?: (checked: boolean) => void;
|
||||
}
|
||||
|
||||
const Switch = React.forwardRef<HTMLInputElement, SwitchProps>(
|
||||
({ className, checked, onCheckedChange, ...props }, ref) => {
|
||||
return (
|
||||
<label className="relative inline-flex cursor-pointer items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="sr-only peer"
|
||||
ref={ref}
|
||||
checked={checked}
|
||||
onChange={(e) => onCheckedChange?.(e.target.checked)}
|
||||
{...props}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
'relative h-6 w-11 rounded-full border transition-colors',
|
||||
checked ? 'border-[#ff9329] bg-[#ff9329]' : 'border-[#6b7280] bg-[#6b7280]',
|
||||
'peer-focus-visible:ring-2 peer-focus-visible:ring-ring peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-background',
|
||||
'peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className="absolute left-[2px] h-5 w-5 rounded-full bg-white shadow transition-transform"
|
||||
style={{ top: '50%', transform: `translateY(-50%) translateX(${checked ? '20px' : '0px'})` }}
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
);
|
||||
Switch.displayName = 'Switch';
|
||||
|
||||
export { Switch };
|
||||
@@ -3,9 +3,6 @@ import { bucketsApi, objectsApi, accessApi, garageApi, analyticsApi } from '@/li
|
||||
import { queryKeys } from '@/lib/query-client';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
// ===========================
|
||||
// Bucket Hooks
|
||||
// ===========================
|
||||
|
||||
export function useBuckets() {
|
||||
return useQuery({
|
||||
@@ -66,9 +63,6 @@ export function useGrantBucketPermission() {
|
||||
});
|
||||
}
|
||||
|
||||
// ===========================
|
||||
// Object Hooks
|
||||
// ===========================
|
||||
|
||||
export function useObjects(bucket: string, prefix?: string, enabled = true) {
|
||||
return useQuery({
|
||||
@@ -138,9 +132,6 @@ export function useDeleteMultipleObjects() {
|
||||
});
|
||||
}
|
||||
|
||||
// ===========================
|
||||
// Access Key Hooks
|
||||
// ===========================
|
||||
|
||||
export function useAccessKeys() {
|
||||
return useQuery({
|
||||
@@ -197,9 +188,6 @@ export function useUpdateAccessKey() {
|
||||
});
|
||||
}
|
||||
|
||||
// ===========================
|
||||
// Cluster Hooks
|
||||
// ===========================
|
||||
|
||||
export function useClusterHealth() {
|
||||
return useQuery({
|
||||
@@ -225,9 +213,6 @@ export function useClusterStatistics() {
|
||||
});
|
||||
}
|
||||
|
||||
// ===========================
|
||||
// Dashboard Hooks
|
||||
// ===========================
|
||||
|
||||
export function useDashboardMetrics() {
|
||||
return useQuery({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { objectsApi } from '@/lib/api';
|
||||
import type { S3Object, UploadTask } from '@/types';
|
||||
import { toast } from 'sonner';
|
||||
@@ -13,8 +13,9 @@ export function useBucketObjects(bucketName: string | null, currentPath: string
|
||||
const [nextContinuationToken, setNextContinuationToken] = useState<string | undefined>(undefined);
|
||||
const [itemsPerPage, setItemsPerPage] = useState(25);
|
||||
const [currentContinuationToken, setCurrentContinuationToken] = useState<string | undefined>(undefined);
|
||||
const [previousPath, setPreviousPath] = useState<string>(currentPath);
|
||||
const previousPathRef = useRef<string>(currentPath);
|
||||
const [uploadTasks, setUploadTasks] = useState<UploadTask[]>([]);
|
||||
const clearTasksTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const fetchObjects = useCallback(async (continuationToken?: string, isRefresh = false, isNav = false) => {
|
||||
if (!bucketName) return;
|
||||
@@ -46,24 +47,26 @@ export function useBucketObjects(bucketName: string | null, currentPath: string
|
||||
useEffect(() => {
|
||||
if (!bucketName) return;
|
||||
|
||||
// Detect if this is a path change (navigation) or initial load
|
||||
const isPathChange = previousPath !== currentPath && objects.length > 0;
|
||||
setPreviousPath(currentPath);
|
||||
const isPathChange = previousPathRef.current !== currentPath && objects.length > 0;
|
||||
previousPathRef.current = currentPath;
|
||||
|
||||
// Use navigation mode if it's a path change, otherwise use normal loading
|
||||
fetchObjects(undefined, false, isPathChange);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [bucketName, currentPath, itemsPerPage]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (clearTasksTimerRef.current) clearTimeout(clearTasksTimerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const uploadFiles = useCallback(async (files: File[]) => {
|
||||
if (!bucketName) return false;
|
||||
|
||||
// Check if files are from a folder upload
|
||||
const hasRelativePaths = files.some((file: any) => file.webkitRelativePath);
|
||||
const hasRelativePaths = files.some((file) => !!file.webkitRelativePath);
|
||||
|
||||
// Get unique folders from the files
|
||||
const folders = new Set<string>();
|
||||
files.forEach((file: any) => {
|
||||
files.forEach((file) => {
|
||||
if (file.webkitRelativePath) {
|
||||
const parts = file.webkitRelativePath.split('/');
|
||||
if (parts.length > 1) {
|
||||
@@ -72,9 +75,8 @@ export function useBucketObjects(bucketName: string | null, currentPath: string
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize upload tasks
|
||||
const tasks: UploadTask[] = files.map((file, index) => {
|
||||
const relativePath = (file as any).webkitRelativePath || file.name;
|
||||
const relativePath = file.webkitRelativePath || file.name;
|
||||
const key = currentPath ? `${currentPath}${relativePath}` : relativePath;
|
||||
return {
|
||||
id: `${Date.now()}-${index}`,
|
||||
@@ -88,53 +90,36 @@ export function useBucketObjects(bucketName: string | null, currentPath: string
|
||||
|
||||
setUploadTasks(tasks);
|
||||
|
||||
// Upload files with progress tracking and error handling
|
||||
let successCount = 0;
|
||||
let errorCount = 0;
|
||||
|
||||
// Upload files one by one
|
||||
const concurrency = 1;
|
||||
const uploadPromises: Promise<void>[] = [];
|
||||
for (const task of tasks) {
|
||||
try {
|
||||
setUploadTasks(prev => prev.map(t =>
|
||||
t.id === task.id ? { ...t, status: 'uploading' as const } : t
|
||||
));
|
||||
|
||||
for (let i = 0; i < tasks.length; i += concurrency) {
|
||||
const batch = tasks.slice(i, Math.min(i + concurrency, tasks.length));
|
||||
await objectsApi.upload(bucketName, task.key, task.file, (progress) => {
|
||||
setUploadTasks(prev => prev.map(t => {
|
||||
if (t.id !== task.id || t.progress === progress) return t;
|
||||
return { ...t, progress };
|
||||
}));
|
||||
});
|
||||
|
||||
const batchPromises = batch.map(async (task) => {
|
||||
try {
|
||||
// Update task status to uploading
|
||||
setUploadTasks(prev => prev.map(t =>
|
||||
t.id === task.id ? { ...t, status: 'uploading' as const } : t
|
||||
));
|
||||
|
||||
await objectsApi.upload(bucketName, task.key, task.file, (progress) => {
|
||||
setUploadTasks(prev => prev.map(t =>
|
||||
t.id === task.id ? { ...t, progress } : t
|
||||
));
|
||||
});
|
||||
|
||||
// Update task status to completed
|
||||
setUploadTasks(prev => prev.map(t =>
|
||||
t.id === task.id ? { ...t, status: 'completed' as const, progress: 100 } : t
|
||||
));
|
||||
successCount++;
|
||||
} catch (error) {
|
||||
// Update task status to error but continue with other uploads
|
||||
const errorMessage = error instanceof Error ? error.message : 'Upload failed';
|
||||
setUploadTasks(prev => prev.map(t =>
|
||||
t.id === task.id ? { ...t, status: 'error' as const, error: errorMessage } : t
|
||||
));
|
||||
errorCount++;
|
||||
console.error(`Failed to upload ${task.key}:`, error);
|
||||
}
|
||||
});
|
||||
|
||||
uploadPromises.push(...batchPromises);
|
||||
await Promise.all(batchPromises);
|
||||
setUploadTasks(prev => prev.map(t =>
|
||||
t.id === task.id ? { ...t, status: 'completed' as const, progress: 100 } : t
|
||||
));
|
||||
successCount++;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Upload failed';
|
||||
setUploadTasks(prev => prev.map(t =>
|
||||
t.id === task.id ? { ...t, status: 'error' as const, error: errorMessage } : t
|
||||
));
|
||||
errorCount++;
|
||||
console.error(`Failed to upload ${task.key}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(uploadPromises);
|
||||
|
||||
// Show summary toast
|
||||
if (errorCount === 0) {
|
||||
if (hasRelativePaths && folders.size > 0) {
|
||||
const folderNames = Array.from(folders).join(', ');
|
||||
@@ -148,9 +133,10 @@ export function useBucketObjects(bucketName: string | null, currentPath: string
|
||||
toast.error(`Failed to upload ${errorCount} file${errorCount > 1 ? 's' : ''}`);
|
||||
}
|
||||
|
||||
// Clear upload tasks after a delay
|
||||
setTimeout(() => {
|
||||
if (clearTasksTimerRef.current) clearTimeout(clearTasksTimerRef.current);
|
||||
clearTasksTimerRef.current = setTimeout(() => {
|
||||
setUploadTasks([]);
|
||||
clearTasksTimerRef.current = null;
|
||||
}, 3000);
|
||||
|
||||
await fetchObjects(currentContinuationToken, true);
|
||||
@@ -161,7 +147,6 @@ export function useBucketObjects(bucketName: string | null, currentPath: string
|
||||
if (!bucketName) return false;
|
||||
|
||||
try {
|
||||
// Optimistically remove the object from the UI
|
||||
setObjects(prev => prev.filter(obj => obj.key !== key));
|
||||
|
||||
await objectsApi.delete(bucketName, key);
|
||||
@@ -170,7 +155,6 @@ export function useBucketObjects(bucketName: string | null, currentPath: string
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Delete object error:', error);
|
||||
// Revert the optimistic update by refetching
|
||||
await fetchObjects(currentContinuationToken, true);
|
||||
return false;
|
||||
}
|
||||
@@ -180,7 +164,6 @@ export function useBucketObjects(bucketName: string | null, currentPath: string
|
||||
if (!bucketName || keys.length === 0) return false;
|
||||
|
||||
try {
|
||||
// Optimistically remove the objects from the UI
|
||||
setObjects(prev => prev.filter(obj => !keys.includes(obj.key)));
|
||||
|
||||
await objectsApi.deleteMultiple(bucketName, keys, currentPath || undefined);
|
||||
@@ -189,7 +172,6 @@ export function useBucketObjects(bucketName: string | null, currentPath: string
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Bulk delete error:', error);
|
||||
// Revert the optimistic update by refetching
|
||||
await fetchObjects(currentContinuationToken, true);
|
||||
return false;
|
||||
}
|
||||
@@ -200,7 +182,7 @@ export function useBucketObjects(bucketName: string | null, currentPath: string
|
||||
|
||||
try {
|
||||
const dirKey = currentPath ? `${currentPath}${dirName}/` : `${dirName}/`;
|
||||
await objectsApi.upload(bucketName, dirKey, new File([], ''));
|
||||
await objectsApi.createDirectory(bucketName, dirKey);
|
||||
toast.success(`Directory "${dirName}" created successfully`);
|
||||
await fetchObjects(currentContinuationToken, true);
|
||||
return true;
|
||||
|
||||
@@ -2,6 +2,7 @@ import axios from 'axios';
|
||||
import {toast} from 'sonner';
|
||||
import type {
|
||||
AccessKey,
|
||||
ApiResponse,
|
||||
Bucket,
|
||||
BucketDetails,
|
||||
ClusterHealth,
|
||||
@@ -158,6 +159,14 @@ export const authApi = {
|
||||
},
|
||||
};
|
||||
|
||||
// Health API
|
||||
export const healthApi = {
|
||||
getVersion: async (): Promise<string> => {
|
||||
const response = await api.get('/v1/health');
|
||||
return response.data.data.version as string;
|
||||
},
|
||||
};
|
||||
|
||||
// Bucket API
|
||||
export const bucketsApi = {
|
||||
list: async (): Promise<Bucket[]> => {
|
||||
@@ -192,6 +201,17 @@ export const bucketsApi = {
|
||||
updateSettings: async (name: string, settings: Partial<BucketDetails>): Promise<void> => {
|
||||
await api.patch(`/v1/buckets/${name}/settings`, settings);
|
||||
},
|
||||
|
||||
updateBucketWebsite: async (
|
||||
name: string,
|
||||
payload: { enabled: boolean; indexDocument?: string; errorDocument?: string }
|
||||
) => {
|
||||
const response = await api.put<ApiResponse<any>>(
|
||||
`/v1/buckets/${encodeURIComponent(name)}/website`,
|
||||
payload
|
||||
);
|
||||
return response.data.data;
|
||||
},
|
||||
};
|
||||
|
||||
// Objects API
|
||||
@@ -256,6 +276,10 @@ export const objectsApi = {
|
||||
};
|
||||
},
|
||||
|
||||
createDirectory: async (bucket: string, key: string): Promise<void> => {
|
||||
await api.post(`/v1/buckets/${bucket}/directories`, { key });
|
||||
},
|
||||
|
||||
upload: async (bucket: string, key: string, file: File, onProgress?: (progress: number) => void): Promise<void> => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {useEffect, useState} from 'react';
|
||||
import {useEffect, useMemo, useState} from 'react';
|
||||
import {Header} from '@/components/layout/header';
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {Input} from '@/components/ui/input';
|
||||
@@ -31,7 +31,6 @@ import {toast} from 'sonner';
|
||||
|
||||
export function AccessControl() {
|
||||
const [keys, setKeys] = useState<AccessKey[]>([]);
|
||||
const [filteredKeys, setFilteredKeys] = useState<AccessKey[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||
@@ -83,7 +82,6 @@ export function AccessControl() {
|
||||
setIsLoading(true);
|
||||
const data = await accessApi.listKeys();
|
||||
setKeys(data);
|
||||
setFilteredKeys(data);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch keys:', error);
|
||||
} finally {
|
||||
@@ -94,14 +92,15 @@ export function AccessControl() {
|
||||
fetchKeys();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const filtered = keys.filter(
|
||||
(key) =>
|
||||
key.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
key.accessKeyId.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
setFilteredKeys(filtered);
|
||||
}, [searchQuery, keys]);
|
||||
const filteredKeys = useMemo(
|
||||
() =>
|
||||
keys.filter(
|
||||
(key) =>
|
||||
key.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
key.accessKeyId.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
),
|
||||
[keys, searchQuery]
|
||||
);
|
||||
|
||||
const handleCreateKey = async () => {
|
||||
if (!newKeyName) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { Header } from '@/components/layout/header';
|
||||
import { useBuckets, useCreateBucket, useDeleteBucket, useGrantBucketPermission } from '@/hooks/useApi';
|
||||
@@ -8,8 +9,10 @@ import { ObjectBrowserView } from '@/components/buckets/ObjectBrowserView';
|
||||
import { CreateBucketDialog } from '@/components/buckets/CreateBucketDialog';
|
||||
import { DeleteBucketDialog } from '@/components/buckets/DeleteBucketDialog';
|
||||
import { BucketSettingsDialog } from '@/components/buckets/BucketSettingsDialog';
|
||||
import { BucketWebsiteDialog } from '@/components/buckets/BucketWebsiteDialog';
|
||||
import type { Bucket } from '@/types';
|
||||
import { toast } from 'sonner';
|
||||
import { bucketsApi } from '@/lib/api';
|
||||
|
||||
export function Buckets() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
@@ -21,6 +24,8 @@ export function Buckets() {
|
||||
const [selectedBucket, setSelectedBucket] = useState<Bucket | null>(null);
|
||||
const [settingsDialogOpen, setSettingsDialogOpen] = useState(false);
|
||||
const [settingsBucket, setSettingsBucket] = useState<Bucket | null>(null);
|
||||
const [websiteDialogOpen, setWebsiteDialogOpen] = useState(false);
|
||||
const [websiteBucket, setWebsiteBucket] = useState<Bucket | null>(null);
|
||||
|
||||
// Object browser state - initialize from URL params
|
||||
const [viewingBucket, setViewingBucket] = useState<string | null>(searchParams.get('bucket'));
|
||||
@@ -51,6 +56,7 @@ export function Buckets() {
|
||||
}, [searchParams]);
|
||||
|
||||
// Custom hooks
|
||||
const queryClient = useQueryClient();
|
||||
const { data: buckets = [], isLoading: bucketsLoading } = useBuckets();
|
||||
const createBucketMutation = useCreateBucket();
|
||||
const deleteBucketMutation = useDeleteBucket();
|
||||
@@ -139,6 +145,25 @@ export function Buckets() {
|
||||
setSettingsDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleOpenWebsiteSettings = (bucket: Bucket) => {
|
||||
setWebsiteBucket(bucket);
|
||||
setWebsiteDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleSaveWebsite = async (
|
||||
bucketName: string,
|
||||
payload: { enabled: boolean; indexDocument?: string; errorDocument?: string }
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
await bucketsApi.updateBucketWebsite(bucketName, payload);
|
||||
queryClient.invalidateQueries({ queryKey: ['buckets'] });
|
||||
toast.success('Website configuration updated');
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefreshObjects = async () => {
|
||||
if (isRefreshing) return;
|
||||
try {
|
||||
@@ -224,6 +249,7 @@ export function Buckets() {
|
||||
onSearchChange={setSearchQuery}
|
||||
onViewBucket={handleViewBucket}
|
||||
onOpenSettings={handleOpenSettings}
|
||||
onWebsiteSettings={handleOpenWebsiteSettings}
|
||||
onCreateBucket={() => setCreateDialogOpen(true)}
|
||||
onDeleteBucket={(bucket) => {
|
||||
setSelectedBucket(bucket);
|
||||
@@ -252,6 +278,13 @@ export function Buckets() {
|
||||
bucket={settingsBucket}
|
||||
onGrantPermission={grantPermission}
|
||||
/>
|
||||
|
||||
<BucketWebsiteDialog
|
||||
open={websiteDialogOpen}
|
||||
onOpenChange={setWebsiteDialogOpen}
|
||||
bucket={websiteBucket}
|
||||
onSave={handleSaveWebsite}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,11 @@ export interface Bucket {
|
||||
objectCount?: number;
|
||||
size?: number;
|
||||
region?: string;
|
||||
websiteAccess: boolean;
|
||||
websiteConfig?: {
|
||||
indexDocument: string;
|
||||
errorDocument?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface BucketDetails extends Bucket {
|
||||
|
||||
@@ -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.1.12
|
||||
appVersion: "v0.1.1"
|
||||
version: 0.2.2
|
||||
appVersion: "v0.2.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
|
||||
|
||||
@@ -409,9 +409,6 @@ config:
|
||||
- email
|
||||
- profile
|
||||
issuer_url: "https://auth.example.com/realms/production"
|
||||
auth_url: "https://auth.example.com/realms/production/protocol/openid-connect/auth"
|
||||
token_url: "https://auth.example.com/realms/production/protocol/openid-connect/token"
|
||||
userinfo_url: "https://auth.example.com/realms/production/protocol/openid-connect/userinfo"
|
||||
cookie_secure: true
|
||||
cookie_http_only: true
|
||||
cookie_same_site: "lax"
|
||||
|
||||
@@ -2,6 +2,7 @@ apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: {{ include "garage-ui.fullname" . }}-config
|
||||
namespace: {{ .Release.Namespace }}
|
||||
labels:
|
||||
{{- include "garage-ui.labels" . | nindent 4 }}
|
||||
data:
|
||||
|
||||
@@ -2,6 +2,7 @@ apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ include "garage-ui.fullname" . }}
|
||||
namespace: {{ .Release.Namespace }}
|
||||
labels:
|
||||
{{- include "garage-ui.labels" . | nindent 4 }}
|
||||
spec:
|
||||
|
||||
@@ -3,6 +3,7 @@ apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: {{ include "garage-ui.fullname" . }}
|
||||
namespace: {{ .Release.Namespace }}
|
||||
labels:
|
||||
{{- include "garage-ui.labels" . | nindent 4 }}
|
||||
{{- with .Values.ingress.annotations }}
|
||||
|
||||
@@ -3,6 +3,7 @@ apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: {{ include "garage-ui.fullname" . }}
|
||||
namespace: {{ .Release.Namespace }}
|
||||
labels:
|
||||
{{- include "garage-ui.labels" . | nindent 4 }}
|
||||
spec:
|
||||
|
||||
@@ -3,6 +3,7 @@ apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: {{ include "garage-ui.fullname" . }}-admin-token
|
||||
namespace: {{ .Release.Namespace }}
|
||||
labels:
|
||||
{{- include "garage-ui.labels" . | nindent 4 }}
|
||||
type: Opaque
|
||||
@@ -15,6 +16,7 @@ apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: {{ include "garage-ui.fullname" . }}-admin-password
|
||||
namespace: {{ .Release.Namespace }}
|
||||
labels:
|
||||
{{- include "garage-ui.labels" . | nindent 4 }}
|
||||
type: Opaque
|
||||
@@ -31,6 +33,7 @@ apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: {{ include "garage-ui.fullname" . }}-oidc-client-secret
|
||||
namespace: {{ .Release.Namespace }}
|
||||
labels:
|
||||
{{- include "garage-ui.labels" . | nindent 4 }}
|
||||
type: Opaque
|
||||
@@ -43,6 +46,7 @@ apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: {{ include "garage-ui.fullname" . }}-jwt-key
|
||||
namespace: {{ .Release.Namespace }}
|
||||
labels:
|
||||
{{- include "garage-ui.labels" . | nindent 4 }}
|
||||
annotations:
|
||||
@@ -61,6 +65,7 @@ apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: {{ $secretName }}
|
||||
namespace: {{ .Release.Namespace }}
|
||||
labels:
|
||||
{{- include "garage-ui.labels" . | nindent 4 }}
|
||||
annotations:
|
||||
|
||||
@@ -2,6 +2,7 @@ apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ include "garage-ui.fullname" . }}
|
||||
namespace: {{ .Release.Namespace }}
|
||||
labels:
|
||||
{{- include "garage-ui.labels" . | nindent 4 }}
|
||||
spec:
|
||||
|
||||
@@ -3,6 +3,7 @@ apiVersion: monitoring.coreos.com/v1
|
||||
kind: ServiceMonitor
|
||||
metadata:
|
||||
name: {{ include "garage-ui.fullname" . }}
|
||||
namespace: {{ .Release.Namespace }}
|
||||
labels:
|
||||
{{- include "garage-ui.labels" . | nindent 4 }}
|
||||
{{- with .Values.serviceMonitor.labels }}
|
||||
|
||||
@@ -292,24 +292,6 @@
|
||||
"pattern": "^https?://",
|
||||
"default": "https://keycloak.example.com/realms/master"
|
||||
},
|
||||
"auth_url": {
|
||||
"type": "string",
|
||||
"description": "Authorization endpoint URL",
|
||||
"pattern": "^https?://",
|
||||
"default": "https://keycloak.example.com/realms/master/protocol/openid-connect/auth"
|
||||
},
|
||||
"token_url": {
|
||||
"type": "string",
|
||||
"description": "Token endpoint URL",
|
||||
"pattern": "^https?://",
|
||||
"default": "https://keycloak.example.com/realms/master/protocol/openid-connect/token"
|
||||
},
|
||||
"userinfo_url": {
|
||||
"type": "string",
|
||||
"description": "User info endpoint URL",
|
||||
"pattern": "^https?://",
|
||||
"default": "https://keycloak.example.com/realms/master/protocol/openid-connect/userinfo"
|
||||
},
|
||||
"skip_issuer_check": {
|
||||
"type": "boolean",
|
||||
"description": "Skip issuer validation (not recommended for production)",
|
||||
|
||||
@@ -77,9 +77,6 @@ config:
|
||||
- profile
|
||||
# OIDC provider endpoints
|
||||
issuer_url: "https://keycloak.example.com/realms/master"
|
||||
auth_url: "https://keycloak.example.com/realms/master/protocol/openid-connect/auth"
|
||||
token_url: "https://keycloak.example.com/realms/master/protocol/openid-connect/token"
|
||||
userinfo_url: "https://keycloak.example.com/realms/master/protocol/openid-connect/userinfo"
|
||||
# Validation settings
|
||||
skip_issuer_check: false
|
||||
skip_expiry_check: false
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Usage: scripts/coverage-gate.sh <coverage.out>
|
||||
#
|
||||
# Enforces:
|
||||
# - Total backend coverage >= 70% (excluding backend/main.go and backend/docs/).
|
||||
# - Per-package floor >= 70% for each of:
|
||||
# internal/auth, internal/config, internal/handlers, internal/services.
|
||||
|
||||
if [ "$#" -ne 1 ]; then
|
||||
echo "usage: $0 <coverage.out>" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
COVERAGE_IN="$1"
|
||||
TOTAL_FLOOR="70.0"
|
||||
PER_PKG_FLOOR="70.0"
|
||||
PER_PKG_GATES=(
|
||||
"internal/auth"
|
||||
"internal/config"
|
||||
"internal/handlers"
|
||||
"internal/services"
|
||||
)
|
||||
|
||||
if [ ! -f "$COVERAGE_IN" ]; then
|
||||
echo "error: coverage file not found: $COVERAGE_IN" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
FILTERED="coverage-filtered.out"
|
||||
trap 'rm -f "$FILTERED"' EXIT
|
||||
|
||||
# Keep the mode: line, drop excluded paths.
|
||||
# Excluded from coverage totals:
|
||||
# - main.go: entry point, covered transitively by smoke.
|
||||
# - backend/docs/: generated swagger.
|
||||
# - mocks/ and *_mock.go: test infrastructure, not production code.
|
||||
# - services/s3.go: network methods deferred to smoke per parent spec
|
||||
# (docs/superpowers/specs/2026-04-17-backend-test-suite-design.md §internal/services).
|
||||
grep -E '^(mode:|.*/)' "$COVERAGE_IN" \
|
||||
| grep -v '/main\.go' \
|
||||
| grep -v '/docs/' \
|
||||
| grep -v '/mocks/' \
|
||||
| grep -v '_mock\.go' \
|
||||
| grep -v '/services/s3\.go' \
|
||||
> "$FILTERED"
|
||||
|
||||
FUNC_OUT=$(cd backend && go tool cover -func="../$FILTERED")
|
||||
TOTAL_PCT=$(echo "$FUNC_OUT" | awk '/^total:/ {gsub("%",""); print $NF}')
|
||||
|
||||
if [ -z "$TOTAL_PCT" ]; then
|
||||
echo "error: could not parse total coverage from go tool cover output" >&2
|
||||
echo "$FUNC_OUT" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
echo "== Coverage summary =="
|
||||
printf " %-30s %s%%\n" "total" "$TOTAL_PCT"
|
||||
|
||||
FAIL=0
|
||||
if awk -v a="$TOTAL_PCT" -v b="$TOTAL_FLOOR" 'BEGIN { exit !(a+0 < b+0) }'; then
|
||||
echo "FAIL total: ${TOTAL_PCT}% < ${TOTAL_FLOOR}%"
|
||||
FAIL=1
|
||||
fi
|
||||
|
||||
for pkg in "${PER_PKG_GATES[@]}"; do
|
||||
pkg_pct=$(echo "$FUNC_OUT" \
|
||||
| awk -v p="/$pkg/" '$1 ~ p { sub("%","",$NF); sum+=$NF; n++ } END { if (n>0) printf "%.1f", sum/n; else print "n/a" }')
|
||||
printf " %-30s %s%%\n" "$pkg" "$pkg_pct"
|
||||
if [ "$pkg_pct" = "n/a" ]; then
|
||||
echo "FAIL $pkg: no coverage data"
|
||||
FAIL=1
|
||||
continue
|
||||
fi
|
||||
if awk -v a="$pkg_pct" -v b="$PER_PKG_FLOOR" 'BEGIN { exit !(a+0 < b+0) }'; then
|
||||
echo "FAIL $pkg: ${pkg_pct}% < ${PER_PKG_FLOOR}%"
|
||||
FAIL=1
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$FAIL" -ne 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "PASS"
|
||||
Reference in New Issue
Block a user