Compare commits

...

36 Commits

Author SHA1 Message Date
Noooste f48d6f0812 feat: remove toast error messages for various operations and rely on axios interceptor for error handling
Signed-off-by: Noooste <83548733+Noooste@users.noreply.github.com>
2026-04-19 23:40:00 +02:00
Noooste 1cd9e734bc feat: remove success toast message after bucket deletion in Buckets and BucketSettings components
Signed-off-by: Noooste <83548733+Noooste@users.noreply.github.com>
2026-04-19 23:34:57 +02:00
Noooste eb1e7fe96a chore: update version and appVersion in Chart.yaml and README.md
Signed-off-by: Noooste <83548733+Noooste@users.noreply.github.com>
2026-04-19 23:29:53 +02:00
Noooste af7af32d4e feat: add denyBucketKeyFn and enhance allowBucketKeyFn with detailed error logging in buckets_test.go
Signed-off-by: Noooste <83548733+Noooste@users.noreply.github.com>
2026-04-19 23:20:32 +02:00
Noooste c951e5fa4b feat: implement permission denial functionality and enhance permission saving state in AccessControl component
Signed-off-by: Noooste <83548733+Noooste@users.noreply.github.com>
2026-04-19 23:15:27 +02:00
Noooste a828020994 feat: enhance AccessControl component with ConfirmDialog and improved UI for key management
Signed-off-by: Noooste <83548733+Noooste@users.noreply.github.com>
2026-04-19 23:09:29 +02:00
Noooste dcef1e2cbd chore: update version and appVersion in Chart.yaml and README.md
Signed-off-by: Noooste <83548733+Noooste@users.noreply.github.com>
2026-04-19 22:50:45 +02:00
Noooste 13e6fa3d1d refactor: remove unused state variables in AccessControl component
Signed-off-by: Noooste <83548733+Noooste@users.noreply.github.com>
2026-04-19 22:41:21 +02:00
Noooste fece799627 refactor: improve error handling and utility functions in bucket management components
Signed-off-by: Noooste <83548733+Noooste@users.noreply.github.com>
2026-04-19 22:37:55 +02:00
Noste c5324db1ad Merge pull request #18 from Noooste/feat/ui-redesign
UI redesign
2026-04-19 22:17:57 +02:00
Noooste bfed48421b feat: implement UI redesign with updated button styles and new components
Signed-off-by: Noooste <83548733+Noooste@users.noreply.github.com>
2026-04-19 22:11:22 +02:00
Noooste 38cc1dded0 docs: add roadmap section to README outlining future features
Signed-off-by: Noooste <83548733+Noooste@users.noreply.github.com>
2026-04-19 19:00:35 +02:00
Noooste d83313d866 chore: rename workflow names in build.yml and release.yml for consistency
Signed-off-by: Noooste <83548733+Noooste@users.noreply.github.com>
2026-04-19 18:39:55 +02:00
Noooste 6f10510c49 docs: update Codecov badge text for clarity
Signed-off-by: Noooste <83548733+Noooste@users.noreply.github.com>
2026-04-19 18:39:10 +02:00
Noooste dd0e0d43fb docs: add Codecov badge to README for coverage tracking
Signed-off-by: Noooste <83548733+Noooste@users.noreply.github.com>
2026-04-19 18:38:49 +02:00
Noooste 4447f6533f test: add comprehensive tests for admin methods and logger error handling
Signed-off-by: Noooste <83548733+Noooste@users.noreply.github.com>
2026-04-19 18:37:32 +02:00
Noooste 09289371a2 test: add unit tests for CreateDirectory endpoint and related S3 functionality
Signed-off-by: Noooste <83548733+Noooste@users.noreply.github.com>
2026-04-19 18:11:21 +02:00
Noooste 50d33f5dfc chore: bump chart version to 0.2.3 and app version to v0.3.0 in README and Chart.yaml
Signed-off-by: Noooste <83548733+Noooste@users.noreply.github.com>
2026-04-19 17:15:46 +02:00
Noooste cb1b14b941 feat: add CreateDirectory endpoint and S3 directory marker support
Signed-off-by: Noooste <83548733+Noooste@users.noreply.github.com>
2026-04-19 17:10:06 +02:00
Noooste 03bb3e8fb6 chore: update Makefile and README to use 'docker compose' syntax
Signed-off-by: Noooste <83548733+Noooste@users.noreply.github.com>
2026-04-19 16:11:15 +02:00
Noste ae6af68ed9 Merge pull request #17 from Noooste/add-testsuite
Add testsuite
2026-04-19 15:57:20 +02:00
Noooste e918a0a940 test: update script execution to use bash for coverage gate
Signed-off-by: Noooste <83548733+Noooste@users.noreply.github.com>
2026-04-19 15:54:30 +02:00
Noooste 005211f073 test: add step to generate Swagger documentation in CI pipeline
Signed-off-by: Noooste <83548733+Noooste@users.noreply.github.com>
2026-04-19 15:49:20 +02:00
Noooste 646813b977 test(docker): add docker-compose configuration for smoke tests
Signed-off-by: Noooste <83548733+Noooste@users.noreply.github.com>
2026-04-19 15:46:53 +02:00
Noooste 361756859a test(smoke): add smoke tests and coverage gate for backend
Signed-off-by: Noooste <83548733+Noooste@users.noreply.github.com>
2026-04-19 15:41:25 +02:00
Noooste 9022b90f02 test: add unit tests for authentication and CORS middleware
Signed-off-by: Noooste <83548733+Noooste@users.noreply.github.com>
2026-04-19 15:41:17 +02:00
Noooste 68be5ea2be feat(logging): enhance logging in GarageAdminService with detailed request information
Signed-off-by: Noooste <83548733+Noooste@users.noreply.github.com>
2026-04-19 13:59:27 +02:00
Noooste afc4da7491 feat(logging): implement per-request logging middleware with access log
Signed-off-by: Noooste <83548733+Noooste@users.noreply.github.com>
2026-04-19 13:59:08 +02:00
Noooste 1522439c68 test(mocks): add hand-rolled mocks for AdminService and S3Storage
Signed-off-by: Noooste <83548733+Noooste@users.noreply.github.com>
2026-04-19 13:58:51 +02:00
Noooste e4f86cd176 chore(deps): update dependencies to latest versions
Signed-off-by: Noooste <83548733+Noooste@users.noreply.github.com>
2026-04-19 13:58:37 +02:00
Noooste dd275d2e78 test: add comprehensive test suite for logging, request ID, and user management
Signed-off-by: Noooste <83548733+Noooste@users.noreply.github.com>
2026-04-19 13:58:29 +02:00
Noooste c14463fb8e feat(logger): add IntoCtx/FromCtx for per-request loggers 2026-04-19 11:32:27 +02:00
Noooste ab188dac4a test: add unit tests for authentication and JWT functionalities
Signed-off-by: Noooste <83548733+Noooste@users.noreply.github.com>
2026-04-19 11:23:44 +02:00
Noooste cbdab9a775 refactor: streamline object key handling and improve app name format
Signed-off-by: Noooste <83548733+Noooste@users.noreply.github.com>
2026-04-19 11:09:19 +02:00
Noooste 901ae70d02 refactor: remove deprecated OIDC URLs from configuration
Signed-off-by: Noooste <83548733+Noooste@users.noreply.github.com>
2026-04-19 11:08:24 +02:00
Noooste d0040be103 refactor: remove deprecated OIDC URLs and enhance logging in auth service
Signed-off-by: Noooste <83548733+Noooste@users.noreply.github.com>
2026-04-19 11:08:17 +02:00
114 changed files with 12171 additions and 2264 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
name: Docker Build and Push
name: build
on:
push:
+1 -1
View File
@@ -1,4 +1,4 @@
name: Release Charts
name: release
on:
push:
+83
View File
@@ -0,0 +1,83 @@
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
- name: Upload coverage reports to Codecov
uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
slug: Noooste/garage-ui
files: ./coverage.out
fail_ci_if_error: false
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
+6 -1
View File
@@ -54,11 +54,16 @@ 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
docs/**/*.md
docs/**/*.md
# Superpowers brainstorm / session scratch
.superpowers/
+22 -3
View File
@@ -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/...
+51 -6
View File
@@ -4,9 +4,9 @@ A web interface for managing [Garage](https://garagehq.deuxfleurs.fr/) object st
[![Docker Build](https://github.com/Noooste/garage-ui/actions/workflows/build.yml/badge.svg)](https://github.com/Noooste/garage-ui/actions/workflows/build.yml)
[![Helm Chart](https://github.com/Noooste/garage-ui/actions/workflows/release.yml/badge.svg)](https://github.com/Noooste/garage-ui/actions/workflows/release.yml)
[![Coverage](https://codecov.io/gh/Noooste/garage-ui/branch/main/graph/badge.svg)](https://codecov.io/gh/Noooste/garage-ui)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Go Version](https://img.shields.io/badge/Go-1.25%2B-00ADD8?logo=go)](https://go.dev/)
[![Node Version](https://img.shields.io/badge/Node-25%2B-339933?logo=node.js)](https://nodejs.org/)
[![Artifact Hub](https://img.shields.io/endpoint?url=https://artifacthub.io/badge/repository/garage-ui)](https://artifacthub.io/packages/search?repo=garage-ui)
---
@@ -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
@@ -186,6 +186,51 @@ logging:
format: "text" # or "json"
```
## Roadmap
Ideas being considered. Contributions welcome.
**Object browser**
- [ ] Inline preview (images, PDF, video, text/markdown, code)
- [ ] Resumable multipart uploads with pause/resume
- [ ] Folder uploads preserving prefix structure
- [ ] Bulk actions (delete, copy prefix, download prefix as zip)
- [ ] Command palette (Cmd-K) and keyboard navigation
**Sharing**
- [ ] Presigned download links with expiry + QR code
- [ ] Presigned upload drop-zones ("send me a file" pages)
**Buckets**
- [ ] Bucket alias manager (global vs. user-scoped)
- [ ] Quota editor with live usage bar
- [ ] Lifecycle editor (expiration + abort-multipart)
- [ ] CORS editor with built-in test request
- [ ] Website config (index/error docs) with live link
- [ ] Per-bucket usage graph over time
**Access keys**
- [ ] Permission matrix view (keys × buckets)
- [ ] Key rotation helper
- [ ] Copy-ready snippets per key (aws-cli, rclone, restic, s3cmd, mc, Terraform)
**Cluster**
- [ ] Visual layout editor with staged vs. applied diff
- [ ] Capacity planner / simulation
- [ ] Rebalance progress and node health timeline
- [ ] Worker/repair panel (trigger scrub, repair, rebalance)
**Observability**
- [ ] Dashboard with dedup/compression savings
- [ ] Metrics explorer pulling from Garage `/metrics`
- [ ] Admin audit log
**Polish**
- [ ] i18n (FR/EN)
- [ ] Mobile-friendly object browser
- [ ] First-run onboarding wizard
- [ ] GitOps export (layout + buckets + keys as YAML)
## License
MIT - see [LICENSE](LICENSE)
+43 -36
View File
@@ -3,75 +3,82 @@ module Noooste/garage-ui
go 1.25.3
require (
github.com/Noooste/azuretls-client v1.12.11
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/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/minio/minio-go/v7 v7.0.98
github.com/rs/zerolog v1.34.0
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
golang.org/x/oauth2 v0.35.0
github.com/swaggo/swag v1.16.6
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.21.0 // indirect
github.com/go-openapi/jsonreference v0.21.0 // indirect
github.com/go-openapi/spec v0.21.0 // indirect
github.com/go-openapi/swag v0.23.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/gofiber/schema v1.7.0 // indirect
github.com/gofiber/utils/v2 v2.0.2 // 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/josharian/intern v1.0.0 // indirect
github.com/klauspost/compress v1.18.4 // indirect
github.com/klauspost/cpuid/v2 v2.2.11 // 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.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.11.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/swaggo/swag v1.16.6 // indirect
github.com/tinylib/msgp v1.6.3 // indirect
github.com/tinylib/msgp v1.6.4 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.69.0 // indirect
github.com/valyala/fasthttp v1.70.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/mod v0.32.0 // indirect
golang.org/x/net v0.50.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect
golang.org/x/tools v0.41.0 // 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
)
+85
View File
@@ -4,6 +4,8 @@ github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1
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=
@@ -12,18 +14,28 @@ 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.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=
@@ -31,12 +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/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=
@@ -45,28 +60,58 @@ 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-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 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/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.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=
@@ -81,9 +126,13 @@ github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8Hm
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.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.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
@@ -92,6 +141,8 @@ 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=
@@ -99,18 +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.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=
@@ -120,14 +177,21 @@ 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/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/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=
@@ -150,10 +214,14 @@ 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.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.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=
@@ -166,21 +234,31 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
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.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.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.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=
@@ -188,16 +266,23 @@ 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.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.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.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-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=
+3 -29
View File
@@ -1,6 +1,7 @@
package auth
import (
"Noooste/garage-ui/pkg/logger"
"context"
"crypto/subtle"
"encoding/base64"
@@ -107,35 +108,6 @@ func (a *Service) 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 *Service) GetAuthorizationURL(state string) (string, error) {
if a.oauth2Config == nil {
@@ -213,6 +185,8 @@ func (a *Service) GetUserInfo(ctx context.Context, token *oauth2.Token) (*UserIn
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),
+267
View File
@@ -0,0 +1,267 @@
package auth
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"Noooste/garage-ui/internal/config"
"golang.org/x/oauth2"
)
// newOIDCServerWithTokenAndUserInfo extends the minimal discovery stub with
// token and userinfo endpoints so ExchangeCode and GetUserInfo can be driven
// end-to-end without a real IdP.
func newOIDCServerWithTokenAndUserInfo(
t *testing.T,
tokenResp map[string]any,
tokenStatus int,
userInfoResp map[string]any,
userInfoStatus int,
) *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"},
"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":[]}`))
})
mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(tokenStatus)
if tokenResp != nil {
_ = json.NewEncoder(w).Encode(tokenResp)
}
})
mux.HandleFunc("/userinfo", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(userInfoStatus)
if userInfoResp != nil {
_ = json.NewEncoder(w).Encode(userInfoResp)
}
})
srv = httptest.NewServer(mux)
t.Cleanup(srv.Close)
return srv
}
func TestExchangeCode_OIDCDisabledReturnsError(t *testing.T) {
svc := &Service{authConfig: &config.AuthConfig{}, serverConfig: &config.ServerConfig{}}
if _, err := svc.ExchangeCode(context.Background(), "some-code"); err == nil {
t.Fatal("expected error when OIDC not initialized")
}
}
func TestExchangeCode_TokenEndpointErrorPropagates(t *testing.T) {
srv := newOIDCServerWithTokenAndUserInfo(t,
map[string]any{"error": "invalid_grant"}, http.StatusBadRequest,
nil, http.StatusOK,
)
svc, err := NewAuthService(
&config.AuthConfig{OIDC: config.OIDCConfig{
Enabled: true,
ClientID: "c",
IssuerURL: srv.URL,
Scopes: []string{"openid"},
}},
&config.ServerConfig{RootURL: "https://example.test"},
)
if err != nil {
t.Fatalf("NewAuthService: %v", err)
}
_, err = svc.ExchangeCode(context.Background(), "bad-code")
if err == nil {
t.Fatal("expected error for 400 from token endpoint")
}
if !strings.Contains(err.Error(), "failed to exchange code") {
t.Errorf("error = %v, want wrap 'failed to exchange code'", err)
}
}
func TestVerifyIDToken_OIDCDisabledReturnsError(t *testing.T) {
svc := &Service{authConfig: &config.AuthConfig{}, serverConfig: &config.ServerConfig{}}
if _, err := svc.VerifyIDToken(context.Background(), "tok"); err == nil {
t.Fatal("expected error when OIDC not initialized")
}
}
func TestVerifyIDToken_GarbageTokenRejected(t *testing.T) {
// Discovery works; JWKS is empty so no signature can verify — any token
// is rejected. This exercises the verifier error path.
srv := newOIDCServerWithTokenAndUserInfo(t, nil, http.StatusOK, nil, http.StatusOK)
svc, err := NewAuthService(
&config.AuthConfig{OIDC: config.OIDCConfig{
Enabled: true,
ClientID: "c",
IssuerURL: srv.URL,
Scopes: []string{"openid"},
}},
&config.ServerConfig{RootURL: "https://example.test"},
)
if err != nil {
t.Fatalf("NewAuthService: %v", err)
}
_, err = svc.VerifyIDToken(context.Background(), "not-a-real-jwt")
if err == nil {
t.Fatal("expected verifier error for garbage token")
}
}
func TestGetUserInfo_OIDCDisabledReturnsError(t *testing.T) {
svc := &Service{authConfig: &config.AuthConfig{}, serverConfig: &config.ServerConfig{}}
_, err := svc.GetUserInfo(context.Background(), &oauth2.Token{AccessToken: "x"})
if err == nil {
t.Fatal("expected error when OIDC not initialized")
}
}
func TestGetUserInfo_ProviderErrorPropagates(t *testing.T) {
srv := newOIDCServerWithTokenAndUserInfo(t,
nil, http.StatusOK,
map[string]any{"error": "invalid_token"}, http.StatusUnauthorized,
)
svc, err := NewAuthService(
&config.AuthConfig{OIDC: config.OIDCConfig{
Enabled: true,
ClientID: "c",
IssuerURL: srv.URL,
Scopes: []string{"openid"},
}},
&config.ServerConfig{RootURL: "https://example.test"},
)
if err != nil {
t.Fatalf("NewAuthService: %v", err)
}
_, err = svc.GetUserInfo(context.Background(), &oauth2.Token{AccessToken: "bad"})
if err == nil {
t.Fatal("expected error from userinfo endpoint")
}
if !strings.Contains(err.Error(), "failed to get user info") {
t.Errorf("error = %v", err)
}
}
func TestGetUserInfo_HappyPath_ExtractsClaims(t *testing.T) {
srv := newOIDCServerWithTokenAndUserInfo(t,
nil, http.StatusOK,
map[string]any{
"sub": "user-123",
"preferred_username": "alice",
"email": "alice@example.com",
"name": "Alice Example",
"resource_access": map[string]any{
"garage": map[string]any{
"roles": []any{"admin", "user"},
},
},
},
http.StatusOK,
)
svc, err := NewAuthService(
&config.AuthConfig{OIDC: config.OIDCConfig{
Enabled: true,
ClientID: "c",
IssuerURL: srv.URL,
Scopes: []string{"openid"},
UsernameAttribute: "preferred_username",
EmailAttribute: "email",
NameAttribute: "name",
RoleAttributePath: "resource_access.garage.roles",
}},
&config.ServerConfig{RootURL: "https://example.test"},
)
if err != nil {
t.Fatalf("NewAuthService: %v", err)
}
info, err := svc.GetUserInfo(context.Background(), &oauth2.Token{AccessToken: "good"})
if err != nil {
t.Fatalf("GetUserInfo: %v", err)
}
if info.Username != "alice" || info.Email != "alice@example.com" || info.Name != "Alice Example" {
t.Errorf("got %+v, want alice/alice@example.com/Alice Example", info)
}
if len(info.Roles) != 2 || info.Roles[0] != "admin" {
t.Errorf("Roles = %v, want [admin user]", info.Roles)
}
}
func TestExtractClaim(t *testing.T) {
cases := []struct {
name string
claims map[string]interface{}
key string
want string
}{
{"empty key returns empty", map[string]interface{}{"a": "b"}, "", ""},
{"missing key returns empty", map[string]interface{}{"a": "b"}, "missing", ""},
{"non-string value returns empty", map[string]interface{}{"n": 42}, "n", ""},
{"string value returned", map[string]interface{}{"email": "x@y"}, "email", "x@y"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := extractClaim(tc.claims, tc.key); got != tc.want {
t.Errorf("extractClaim(%v, %q) = %q, want %q", tc.claims, tc.key, got, tc.want)
}
})
}
}
func TestGenerateAndValidateStateToken_Roundtrip(t *testing.T) {
jwtSvc, err := NewJWTService()
if err != nil {
t.Fatalf("NewJWTService: %v", err)
}
svc := &Service{
authConfig: &config.AuthConfig{},
serverConfig: &config.ServerConfig{},
jwtService: jwtSvc,
}
tok, err := svc.GenerateStateToken()
if err != nil {
t.Fatalf("GenerateStateToken: %v", err)
}
if tok == "" {
t.Fatal("state token is empty")
}
if !svc.ValidateAndConsumeState(tok) {
t.Fatal("ValidateAndConsumeState rejected a freshly-issued token")
}
// Double-consume must fail (CSRF single-use).
if svc.ValidateAndConsumeState(tok) {
t.Fatal("ValidateAndConsumeState accepted a re-used token")
}
}
func TestValidateAndConsumeState_RejectsGarbage(t *testing.T) {
jwtSvc, err := NewJWTService()
if err != nil {
t.Fatalf("NewJWTService: %v", err)
}
svc := &Service{jwtService: jwtSvc}
if svc.ValidateAndConsumeState("definitely-not-a-token") {
t.Fatal("accepted an invalid token")
}
}
+458
View File
@@ -3,6 +3,9 @@ package auth
import (
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"Noooste/garage-ui/internal/config"
@@ -96,3 +99,458 @@ func TestGenerateSessionToken_ZeroSessionMaxAge_IsNotImmediatelyExpired(t *testi
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)
}
})
}
}
+469
View File
@@ -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)
}
-6
View File
@@ -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")
+332
View File
@@ -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)
}
}
+52 -15
View File
@@ -287,23 +287,60 @@ func (h *BucketHandler) GrantBucketPermission(c fiber.Ctx) error {
)
}
// Build the permission request for Garage Admin API
permRequest := models.BucketKeyPermRequest{
BucketID: bucketInfo.ID,
AccessKeyID: req.AccessKeyID,
Permissions: models.BucketKeyPermission{
Read: req.Permissions.Read,
Write: req.Permissions.Write,
Owner: req.Permissions.Owner,
},
// Garage's AllowBucketKey is additive — false values are no-ops, not revokes.
// To make this endpoint a true "set permissions" operation, split into Allow
// for the requested-true perms and Deny for the requested-false perms.
allow := models.BucketKeyPermission{
Read: req.Permissions.Read,
Write: req.Permissions.Write,
Owner: req.Permissions.Owner,
}
deny := models.BucketKeyPermission{
Read: !req.Permissions.Read,
Write: !req.Permissions.Write,
Owner: !req.Permissions.Owner,
}
// Grant permissions using Garage Admin API
result, err := h.adminService.AllowBucketKey(ctx, permRequest)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to grant permissions: "+err.Error()),
)
var result *models.GarageBucketInfo
if allow.Read || allow.Write || allow.Owner {
r, err := h.adminService.AllowBucketKey(ctx, models.BucketKeyPermRequest{
BucketID: bucketInfo.ID,
AccessKeyID: req.AccessKeyID,
Permissions: allow,
})
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to grant permissions: "+err.Error()),
)
}
result = r
}
if deny.Read || deny.Write || deny.Owner {
r, err := h.adminService.DenyBucketKey(ctx, models.BucketKeyPermRequest{
BucketID: bucketInfo.ID,
AccessKeyID: req.AccessKeyID,
Permissions: deny,
})
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to revoke permissions: "+err.Error()),
)
}
result = r
}
if result == nil {
// Caller passed all-false on a key with no existing perms — nothing to do.
// Fetch current bucket state to return a consistent response.
r, err := h.adminService.GetBucketInfo(ctx, bucketInfo.ID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to fetch bucket info: "+err.Error()),
)
}
result = r
}
return c.JSON(models.SuccessResponse(result))
+428
View File
@@ -0,0 +1,428 @@
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("allow req = %+v", req)
}
if !req.Permissions.Read || !req.Permissions.Write || req.Permissions.Owner {
t.Errorf("allow perms = %+v", req.Permissions)
}
return &models.GarageBucketInfo{ID: "id-1"}, nil
}
admin.DenyBucketKeyFn = func(_ context.Context, req models.BucketKeyPermRequest) (*models.GarageBucketInfo, error) {
if req.BucketID != "id-1" || req.AccessKeyID != "AKIA" {
t.Errorf("deny req = %+v", req)
}
if req.Permissions.Read || req.Permissions.Write || !req.Permissions.Owner {
t.Errorf("deny perms = %+v", req.Permissions)
}
return &models.GarageBucketInfo{ID: "id-1"}, nil
}
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)
}
}
+176
View File
@@ -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)
}
}
+70
View File
@@ -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)
}
}
@@ -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)
}
}
+52
View File
@@ -194,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
@@ -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)
}
}
})
}
}
+866
View File
@@ -0,0 +1,866 @@
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/directories", h.CreateDirectory)
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)
}
}
// --- CreateDirectory ---
func TestCreateDirectory_Success_AppendsTrailingSlash(t *testing.T) {
app, s3 := newObjectsTestApp(t)
var gotKey string
s3.CreateDirectoryMarkerFn = func(_ context.Context, bucket, key string) (*models.ObjectUploadResponse, error) {
gotKey = key
return &models.ObjectUploadResponse{Bucket: bucket, Key: key, Size: 0, ContentType: "application/x-directory"}, nil
}
body := bytes.NewBufferString(`{"key": "photos/2024"}`)
req := httptest.NewRequest(http.MethodPost, "/buckets/b1/directories", 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)
}
if gotKey != "photos/2024/" {
t.Errorf("service key = %q, want trailing slash appended", gotKey)
}
}
func TestCreateDirectory_StripsLeadingSlashes(t *testing.T) {
app, s3 := newObjectsTestApp(t)
var gotKey string
s3.CreateDirectoryMarkerFn = func(_ context.Context, _, key string) (*models.ObjectUploadResponse, error) {
gotKey = key
return &models.ObjectUploadResponse{Key: key}, nil
}
body := bytes.NewBufferString(`{"key": "///already/"}`)
req := httptest.NewRequest(http.MethodPost, "/buckets/b1/directories", 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)
}
if gotKey != "already/" {
t.Errorf("service key = %q, want 'already/'", gotKey)
}
}
func TestCreateDirectory_MissingKey400(t *testing.T) {
app, _ := newObjectsTestApp(t)
body := bytes.NewBufferString(`{"key": ""}`)
req := httptest.NewRequest(http.MethodPost, "/buckets/b1/directories", 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 TestCreateDirectory_MalformedJSON400(t *testing.T) {
app, _ := newObjectsTestApp(t)
req := httptest.NewRequest(http.MethodPost, "/buckets/b1/directories", bytes.NewBufferString("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 TestCreateDirectory_ServiceError500(t *testing.T) {
app, s3 := newObjectsTestApp(t)
s3.CreateDirectoryMarkerFn = func(_ context.Context, _, _ string) (*models.ObjectUploadResponse, error) {
return nil, errors.New("boom")
}
body := bytes.NewBufferString(`{"key": "x/"}`)
req := httptest.NewRequest(http.MethodPost, "/buckets/b1/directories", 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)
}
}
+367
View File
@@ -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)
}
}
+42 -12
View File
@@ -4,60 +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
// 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"),
)
}
}
// 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 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"
}
}
+403
View File
@@ -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"])
}
}
+282
View File
@@ -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)
}
})
}
}
+82
View File
@@ -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()
}
}
+166
View File
@@ -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)
}
})
}
}
+29
View File
@@ -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)
}
}
-37
View File
@@ -12,48 +12,11 @@ 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"
-7
View File
@@ -138,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
+47
View File
@@ -0,0 +1,47 @@
package models
import "testing"
func TestSuccessResponse(t *testing.T) {
payload := map[string]int{"count": 3}
r := SuccessResponse(payload)
if !r.Success {
t.Error("Success should be true")
}
if r.Error != nil {
t.Errorf("Error should be nil, got %+v", r.Error)
}
m, ok := r.Data.(map[string]int)
if !ok {
t.Fatalf("Data type = %T, want map[string]int", r.Data)
}
if m["count"] != 3 {
t.Errorf("Data.count = %d, want 3", m["count"])
}
}
func TestSuccessResponse_NilData(t *testing.T) {
r := SuccessResponse(nil)
if !r.Success {
t.Error("Success should be true even with nil data")
}
if r.Data != nil {
t.Errorf("Data = %v, want nil", r.Data)
}
}
func TestErrorResponse(t *testing.T) {
r := ErrorResponse(ErrCodeBadRequest, "bad input")
if r.Success {
t.Error("Success should be false for error response")
}
if r.Data != nil {
t.Errorf("Data should be nil, got %v", r.Data)
}
if r.Error == nil {
t.Fatal("Error should not be nil")
}
if r.Error.Code != ErrCodeBadRequest || r.Error.Message != "bad input" {
t.Errorf("Error = %+v", r.Error)
}
}
+31 -51
View File
@@ -72,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)
}
@@ -226,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 {
@@ -253,10 +235,8 @@ func SetupRoutes(
}
}
if !authService.IsAdmin(userInfo) {
if uiFromUserinfo, uiErr := authService.GetUserInfo(ctx, token); uiErr == nil {
if len(uiFromUserinfo.Roles) > 0 {
userInfo.Roles = uiFromUserinfo.Roles
}
if ui, err := authService.GetUserInfo(ctx, token); err == nil && len(ui.Roles) > 0 {
userInfo.Roles = ui.Roles
}
}
if !authService.IsAdmin(userInfo) {
+254
View File
@@ -0,0 +1,254 @@
package routes
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"Noooste/garage-ui/internal/config"
"Noooste/garage-ui/internal/models"
)
// newNoAuthFixture builds a fixture with both admin and OIDC disabled. The
// AuthMiddleware short-circuits with c.Next(), letting handler logic run so
// wildcard-route dispatch can be exercised.
func newNoAuthFixture(t *testing.T) *routeFixture {
return newTestApp(t, func(c *config.Config) {
c.Auth.Admin.Enabled = false
c.Auth.OIDC.Enabled = false
})
}
func plainReq(method, path string, body io.Reader) *http.Request {
return httptest.NewRequest(method, path, body)
}
func TestRoutes_ObjectWildcard_GET_DefaultRoutesToGetObject(t *testing.T) {
f := newNoAuthFixture(t)
var gotBucket, gotKey string
f.S3.GetObjectFn = func(_ context.Context, bucket, key string) (io.ReadCloser, *models.ObjectInfo, error) {
gotBucket, gotKey = bucket, key
return io.NopCloser(strings.NewReader("hello")), &models.ObjectInfo{Key: key, Size: 5, ContentType: "text/plain"}, nil
}
req := plainReq(http.MethodGet, "/api/v1/buckets/b1/objects/folder/subdir/file.txt", nil)
resp, err := f.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 gotBucket != "b1" || gotKey != "folder/subdir/file.txt" {
t.Errorf("service called with (%q, %q)", gotBucket, gotKey)
}
}
func TestRoutes_ObjectWildcard_GET_MetadataSuffixRoutesToMetadata(t *testing.T) {
f := newNoAuthFixture(t)
var gotKey string
f.S3.GetObjectMetadataFn = func(_ context.Context, _ string, key string) (*models.ObjectInfo, error) {
gotKey = key
return &models.ObjectInfo{Key: key, Size: 42, ContentType: "application/json"}, nil
}
req := plainReq(http.MethodGet, "/api/v1/buckets/b1/objects/data/file.bin/metadata", nil)
resp, err := f.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)
}
// The handler strips the /metadata suffix before calling the service.
if gotKey != "data/file.bin" {
t.Errorf("key passed to service = %q, want 'data/file.bin'", gotKey)
}
}
func TestRoutes_ObjectWildcard_GET_PresignSuffixRoutesToPresigned(t *testing.T) {
f := newNoAuthFixture(t)
// The object must exist for the presign handler to succeed.
f.S3.ObjectExistsFn = func(_ context.Context, _, _ string) (bool, error) { return true, nil }
var gotKey string
f.S3.GetPresignedURLFn = func(_ context.Context, _ string, key string, _ time.Duration) (string, error) {
gotKey = key
return "https://signed.example/k", nil
}
req := plainReq(http.MethodGet, "/api/v1/buckets/b1/objects/sub/file.bin/presign", nil)
resp, err := f.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 — body: (unavailable)", resp.StatusCode)
}
if gotKey != "sub/file.bin" {
t.Errorf("key passed to service = %q, want 'sub/file.bin'", gotKey)
}
}
func TestRoutes_ObjectWildcard_DELETE_RoutesToDeleteObject(t *testing.T) {
f := newNoAuthFixture(t)
f.S3.ObjectExistsFn = func(_ context.Context, _, _ string) (bool, error) { return true, nil }
var gotKey string
f.S3.DeleteObjectFn = func(_ context.Context, _ string, key string) error {
gotKey = key
return nil
}
req := plainReq(http.MethodDelete, "/api/v1/buckets/b1/objects/path/to/delete.txt", nil)
resp, err := f.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 gotKey != "path/to/delete.txt" {
t.Errorf("key passed to service = %q", gotKey)
}
}
func TestRoutes_ObjectWildcard_HEAD_RoutesToMetadata(t *testing.T) {
f := newNoAuthFixture(t)
var gotKey string
f.S3.GetObjectMetadataFn = func(_ context.Context, _ string, key string) (*models.ObjectInfo, error) {
gotKey = key
return &models.ObjectInfo{Key: key, Size: 7, ContentType: "text/plain"}, nil
}
req := plainReq(http.MethodHead, "/api/v1/buckets/b1/objects/deep/nested/x.txt", nil)
resp, err := f.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 gotKey != "deep/nested/x.txt" {
t.Errorf("key passed to service = %q", gotKey)
}
}
func TestRoutes_ObjectWildcard_URLDecodedBeforeDispatch(t *testing.T) {
// %20 in the wildcard portion must be decoded before the service is called.
f := newNoAuthFixture(t)
var gotKey string
f.S3.GetObjectFn = func(_ context.Context, _, key string) (io.ReadCloser, *models.ObjectInfo, error) {
gotKey = key
return io.NopCloser(strings.NewReader("")), &models.ObjectInfo{Key: key}, nil
}
req := plainReq(http.MethodGet, "/api/v1/buckets/b1/objects/with%20space/file.txt", nil)
resp, err := f.App.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if gotKey != "with space/file.txt" {
t.Errorf("decoded key = %q, want 'with space/file.txt'", gotKey)
}
}
// Covers the "skip SPA fallback for API-prefixed paths" branch without
// triggering SendFile (which holds file handles on Windows and races with
// t.TempDir cleanup). Only API/auth/health/docs paths are hit here — the
// fallback short-circuits to c.Next(), so no file is opened.
func TestRoutes_SPAFallback_SkipsAPIAndAuthPrefixes(t *testing.T) {
dir := t.TempDir()
t.Chdir(dir)
// The presence of ./frontend/dist is what enables the fallback middleware.
if err := os.MkdirAll(filepath.Join(dir, "frontend", "dist"), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
// We deliberately do NOT create index.html — the test must not reach SendFile.
f := newTestApp(t, func(c *config.Config) {
c.Auth.Admin.Enabled = true
c.Auth.Admin.Username = "u"
c.Auth.Admin.Password = "p"
})
// Every prefix listed in the fallback's skip-list should bypass file
// serving and return 404 from fiber's default handler.
for _, p := range []string{
"/api/v1/definitely-not-a-route",
"/auth/nope",
"/health/extra/segments",
"/docs/missing",
} {
req := httptest.NewRequest(http.MethodGet, p, nil)
resp, err := f.App.Test(req)
if err != nil {
t.Fatalf("%s: %v", p, err)
}
_ = resp.Body.Close()
// /api/v1/* hits auth middleware → 401. The others hit the SPA
// fallback's skip branch then fall through to 404. We only care that
// the SPA middleware did NOT attempt to serve index.html (which would
// succeed with 200 if it existed — here it doesn't exist, so SendFile
// would error; either way, != 200 suffices to prove the skip path ran).
if resp.StatusCode == 200 {
t.Errorf("%s returned 200 — SPA fallback should have skipped", p)
}
}
}
// Covers the third OIDC role-resolution fallback: when neither the ID token
// nor the access token exposes roles, the callback calls GetUserInfo and
// re-evaluates IsAdmin against those roles.
func TestRoutes_OIDCCallback_RoleMatchedViaUserInfoFallback_Succeeds(t *testing.T) {
f, iss := newOIDCFixture(t, "admin")
// ID token and access token have no roles by default — leave as-is.
// Override /userinfo to return a roles structure matching the configured
// RoleAttributePath (resource_access.test-client.roles).
iss.UserInfoFn = func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"sub": "user-1",
"preferred_username": "alice",
"email": "alice@example.com",
"resource_access": map[string]any{
"test-client": map[string]any{"roles": []any{"admin"}},
},
})
}
state := oidcState(t, f)
req := httptest.NewRequest(http.MethodGet, "/auth/oidc/callback?state="+state+"&code=c", nil)
resp, err := f.App.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != 303 {
t.Fatalf("status = %d, want 303 (userinfo fallback should grant admin)", resp.StatusCode)
}
if loc := resp.Header.Get("Location"); loc != "/login?login=success" {
t.Errorf("Location = %q", loc)
}
}
@@ -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)
}
+604
View File
@@ -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)
}
+115 -15
View File
@@ -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"
)
@@ -177,100 +179,177 @@ func (s *GarageAdminService) ImportKey(ctx context.Context, req models.ImportKey
return &result, nil
}
// 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
}
@@ -304,18 +383,34 @@ func (s *GarageAdminService) RemoveBucketAlias(ctx context.Context, req models.R
return &result, nil
}
// 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
}
@@ -427,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)
}
}
+622
View File
@@ -0,0 +1,622 @@
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)
}
}
// TestAllMethods_Non2xxReturnsError exercises the decodeResponse error branch
// of every admin method by pointing them at a server that always returns 500.
// This is a single sweep over the near-identical "if err := decodeResponse ...
// return nil, fmt.Errorf(...)" branches that each wrapper repeats.
func TestAllMethods_Non2xxReturnsError(t *testing.T) {
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "boom", http.StatusInternalServerError)
})
svc, _ := newAdminTestServer(t, h)
ctx := context.Background()
calls := map[string]func() error{
"ListKeys": func() error { _, err := svc.ListKeys(ctx); return err },
"CreateKey": func() error { _, err := svc.CreateKey(ctx, models.CreateKeyRequest{}); return err },
"GetKeyInfo": func() error { _, err := svc.GetKeyInfo(ctx, "k", false); return err },
"UpdateKey": func() error { _, err := svc.UpdateKey(ctx, "k", models.UpdateKeyRequest{}); return err },
"DeleteKey": func() error { return svc.DeleteKey(ctx, "k") },
"ImportKey": func() error { _, err := svc.ImportKey(ctx, models.ImportKeyRequest{}); return err },
"ListBuckets": func() error { _, err := svc.ListBuckets(ctx); return err },
"GetBucketInfo": func() error { _, err := svc.GetBucketInfo(ctx, "b"); return err },
"GetBucketInfoByAlias": func() error { _, err := svc.GetBucketInfoByAlias(ctx, "b"); return err },
"CreateBucket": func() error { _, err := svc.CreateBucket(ctx, models.CreateBucketAdminRequest{}); return err },
"UpdateBucket": func() error { _, err := svc.UpdateBucket(ctx, "b", models.UpdateBucketRequest{}); return err },
"DeleteBucket": func() error { return svc.DeleteBucket(ctx, "b") },
"AddBucketAlias": func() error { _, err := svc.AddBucketAlias(ctx, models.AddBucketAliasRequest{}); return err },
"RemoveBucketAlias": func() error { _, err := svc.RemoveBucketAlias(ctx, models.RemoveBucketAliasRequest{}); return err },
"AllowBucketKey": func() error { _, err := svc.AllowBucketKey(ctx, models.BucketKeyPermRequest{}); return err },
"DenyBucketKey": func() error { _, err := svc.DenyBucketKey(ctx, models.BucketKeyPermRequest{}); return err },
"GetClusterHealth": func() error { _, err := svc.GetClusterHealth(ctx); return err },
"GetClusterStatus": func() error { _, err := svc.GetClusterStatus(ctx); return err },
"GetClusterStatistics": func() error { _, err := svc.GetClusterStatistics(ctx); return err },
"GetNodeInfo": func() error { _, err := svc.GetNodeInfo(ctx, "n"); return err },
"GetNodeStatistics": func() error { _, err := svc.GetNodeStatistics(ctx, "n"); return err },
"HealthCheck": func() error { return svc.HealthCheck(ctx) },
}
for name, fn := range calls {
t.Run(name, func(t *testing.T) {
if err := fn(); err == nil {
t.Fatalf("%s: expected error on 500, got nil", name)
}
})
}
}
// TestDebugLogLevelEnablesSessionLog exercises the NewGarageAdminService
// branch that enables azuretls' session logging when logLevel == "debug".
func TestDebugLogLevelEnablesSessionLog(t *testing.T) {
svc := NewGarageAdminService(&config.GarageConfig{
AdminEndpoint: "http://127.0.0.1:1",
AdminToken: "t",
}, "debug")
if svc == nil || svc.httpClient == nil {
t.Fatal("expected service with configured http client")
}
}
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)
}
}
+2
View File
@@ -28,6 +28,7 @@ type AdminService interface {
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)
DenyBucketKey(ctx context.Context, req models.BucketKeyPermRequest) (*models.GarageBucketInfo, error)
// Cluster
GetClusterHealth(ctx context.Context) (*models.ClusterHealth, error)
@@ -48,6 +49,7 @@ type AdminService interface {
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
@@ -0,0 +1,238 @@
// 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)
DenyBucketKeyFn 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)
}
func (m *AdminMock) DenyBucketKey(ctx context.Context, req models.BucketKeyPermRequest) (*models.GarageBucketInfo, error) {
m.record("DenyBucketKey", req)
if m.DenyBucketKeyFn == nil {
return nil, errNotConfigured("DenyBucketKey")
}
return m.DenyBucketKeyFn(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,215 @@
package mocks
import (
"context"
"io"
"strings"
"testing"
"time"
"Noooste/garage-ui/internal/models"
"Noooste/garage-ui/internal/services"
)
// The mocks are exercised across the handlers test suite, but Go's per-
// package coverage counts only statements executed from tests in THIS
// package. This test calls every mock method with no Fn configured, which
// covers the default "not configured" error path (and the record() helpers
// that build the call log).
func TestAdminMock_UnconfiguredMethodsReturnSentinel(t *testing.T) {
ctx := context.Background()
m := &AdminMock{}
type call struct {
name string
fn func() error
}
calls := []call{
{"ListKeys", func() error { _, e := m.ListKeys(ctx); return e }},
{"CreateKey", func() error { _, e := m.CreateKey(ctx, models.CreateKeyRequest{}); return e }},
{"GetKeyInfo", func() error { _, e := m.GetKeyInfo(ctx, "k", false); return e }},
{"UpdateKey", func() error { _, e := m.UpdateKey(ctx, "k", models.UpdateKeyRequest{}); return e }},
{"DeleteKey", func() error { return m.DeleteKey(ctx, "k") }},
{"ListBuckets", func() error { _, e := m.ListBuckets(ctx); return e }},
{"GetBucketInfo", func() error { _, e := m.GetBucketInfo(ctx, "b"); return e }},
{"GetBucketInfoByAlias", func() error { _, e := m.GetBucketInfoByAlias(ctx, "a"); return e }},
{"CreateBucket", func() error { _, e := m.CreateBucket(ctx, models.CreateBucketAdminRequest{}); return e }},
{"UpdateBucket", func() error { _, e := m.UpdateBucket(ctx, "b", models.UpdateBucketRequest{}); return e }},
{"DeleteBucket", func() error { return m.DeleteBucket(ctx, "b") }},
{"AllowBucketKey", func() error { _, e := m.AllowBucketKey(ctx, models.BucketKeyPermRequest{}); return e }},
{"DenyBucketKey", func() error { _, e := m.DenyBucketKey(ctx, models.BucketKeyPermRequest{}); return e }},
{"GetClusterHealth", func() error { _, e := m.GetClusterHealth(ctx); return e }},
{"GetClusterStatus", func() error { _, e := m.GetClusterStatus(ctx); return e }},
{"GetClusterStatistics", func() error { _, e := m.GetClusterStatistics(ctx); return e }},
{"GetNodeInfo", func() error { _, e := m.GetNodeInfo(ctx, "n"); return e }},
{"GetNodeStatistics", func() error { _, e := m.GetNodeStatistics(ctx, "n"); return e }},
{"HealthCheck", func() error { return m.HealthCheck(ctx) }},
{"GetMetrics", func() error { _, e := m.GetMetrics(ctx); return e }},
}
for _, c := range calls {
err := c.fn()
if err == nil {
t.Errorf("%s: expected error from unconfigured mock, got nil", c.name)
continue
}
if !strings.Contains(err.Error(), c.name) {
t.Errorf("%s: error %q should mention method name", c.name, err.Error())
}
}
if len(m.Calls) != len(calls) {
t.Errorf("Calls recorded = %d, want %d", len(m.Calls), len(calls))
}
}
func TestAdminMock_ConfiguredFnsAreInvoked(t *testing.T) {
ctx := context.Background()
m := &AdminMock{
ListKeysFn: func(ctx context.Context) ([]models.ListKeysResponseItem, error) {
return []models.ListKeysResponseItem{{ID: "k1"}}, nil
},
DeleteKeyFn: func(ctx context.Context, id string) error { return nil },
HealthCheckFn: func(ctx context.Context) error { return nil },
GetMetricsFn: func(ctx context.Context) (string, error) { return "metric 1", nil },
}
if got, err := m.ListKeys(ctx); err != nil || len(got) != 1 {
t.Errorf("ListKeys = (%v, %v), want one item", got, err)
}
if err := m.DeleteKey(ctx, "k"); err != nil {
t.Errorf("DeleteKey: %v", err)
}
if err := m.HealthCheck(ctx); err != nil {
t.Errorf("HealthCheck: %v", err)
}
if got, _ := m.GetMetrics(ctx); got != "metric 1" {
t.Errorf("GetMetrics = %q", got)
}
}
func TestS3Mock_UnconfiguredMethodsReturnSentinel(t *testing.T) {
ctx := context.Background()
m := &S3Mock{}
if _, err := m.ListObjects(ctx, "b", "", 0, ""); err == nil {
t.Error("ListObjects: want error")
}
if _, err := m.UploadObject(ctx, "b", "k", strings.NewReader(""), ""); err == nil {
t.Error("UploadObject: want error")
}
if _, err := m.CreateDirectoryMarker(ctx, "b", "k/"); err == nil {
t.Error("CreateDirectoryMarker: want error")
}
if _, _, err := m.GetObject(ctx, "b", "k"); err == nil {
t.Error("GetObject: want error")
}
if _, err := m.ObjectExists(ctx, "b", "k"); err == nil {
t.Error("ObjectExists: want error")
}
if err := m.DeleteObject(ctx, "b", "k"); err == nil {
t.Error("DeleteObject: want error")
}
if _, err := m.GetObjectMetadata(ctx, "b", "k"); err == nil {
t.Error("GetObjectMetadata: want error")
}
if _, err := m.GetPresignedURL(ctx, "b", "k", time.Minute); err == nil {
t.Error("GetPresignedURL: want error")
}
if err := m.DeleteMultipleObjects(ctx, "b", []string{"k"}); err == nil {
t.Error("DeleteMultipleObjects: want error")
}
// UploadMultipleObjects has no error channel; it must return a result slice
// with one failed entry per input file.
results := m.UploadMultipleObjects(ctx, "b", []struct {
Key string
Body io.Reader
ContentType string
}{
{Key: "a"}, {Key: "b"},
})
if len(results) != 2 {
t.Fatalf("len(results) = %d, want 2", len(results))
}
for _, r := range results {
if r.Success {
t.Errorf("result[%s] should not be successful with no Fn set", r.Key)
}
}
if len(m.Calls) < 10 {
t.Errorf("expected Calls to capture each invocation, got %d entries", len(m.Calls))
}
}
func TestS3Mock_ConfiguredFnsAreInvoked(t *testing.T) {
ctx := context.Background()
m := &S3Mock{
ListObjectsFn: func(_ context.Context, _, _ string, _ int, _ string) (*models.ObjectListResponse, error) {
return &models.ObjectListResponse{Count: 1}, nil
},
UploadObjectFn: func(_ context.Context, _, _ string, _ io.Reader, _ string) (*models.ObjectUploadResponse, error) {
return &models.ObjectUploadResponse{}, nil
},
CreateDirectoryMarkerFn: func(_ context.Context, _, _ string) (*models.ObjectUploadResponse, error) {
return &models.ObjectUploadResponse{}, nil
},
GetObjectFn: func(_ context.Context, _, _ string) (io.ReadCloser, *models.ObjectInfo, error) {
return io.NopCloser(strings.NewReader("x")), &models.ObjectInfo{}, nil
},
ObjectExistsFn: func(_ context.Context, _, _ string) (bool, error) { return true, nil },
DeleteObjectFn: func(_ context.Context, _, _ string) error { return nil },
GetObjectMetadataFn: func(_ context.Context, _, _ string) (*models.ObjectInfo, error) {
return &models.ObjectInfo{}, nil
},
GetPresignedURLFn: func(_ context.Context, _, _ string, _ time.Duration) (string, error) {
return "http://x", nil
},
DeleteMultipleObjectsFn: func(_ context.Context, _ string, _ []string) error { return nil },
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: true}
}
return out
},
}
if r, err := m.ListObjects(ctx, "b", "", 0, ""); err != nil || r.Count != 1 {
t.Errorf("ListObjects = (%+v, %v)", r, err)
}
if _, err := m.UploadObject(ctx, "b", "k", strings.NewReader(""), ""); err != nil {
t.Errorf("UploadObject: %v", err)
}
if _, err := m.CreateDirectoryMarker(ctx, "b", "k/"); err != nil {
t.Errorf("CreateDirectoryMarker: %v", err)
}
if _, _, err := m.GetObject(ctx, "b", "k"); err != nil {
t.Errorf("GetObject: %v", err)
}
if ok, err := m.ObjectExists(ctx, "b", "k"); err != nil || !ok {
t.Errorf("ObjectExists = (%v, %v)", ok, err)
}
if err := m.DeleteObject(ctx, "b", "k"); err != nil {
t.Errorf("DeleteObject: %v", err)
}
if _, err := m.GetObjectMetadata(ctx, "b", "k"); err != nil {
t.Errorf("GetObjectMetadata: %v", err)
}
if u, err := m.GetPresignedURL(ctx, "b", "k", time.Minute); err != nil || u == "" {
t.Errorf("GetPresignedURL = (%q, %v)", u, err)
}
if err := m.DeleteMultipleObjects(ctx, "b", []string{"k"}); err != nil {
t.Errorf("DeleteMultipleObjects: %v", err)
}
results := m.UploadMultipleObjects(ctx, "b", []struct {
Key string
Body io.Reader
ContentType string
}{{Key: "a"}})
if len(results) != 1 || !results[0].Success {
t.Errorf("UploadMultipleObjects results = %+v", results)
}
}
+143
View File
@@ -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)
}
+68 -5
View File
@@ -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,6 +345,39 @@ 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
+569
View File
@@ -0,0 +1,569 @@
package services
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"Noooste/garage-ui/internal/config"
"Noooste/garage-ui/internal/models"
"Noooste/garage-ui/pkg/utils"
)
// s3ErrorXML writes an S3-style error response that the MinIO SDK parses.
func s3ErrorXML(w http.ResponseWriter, status int, code, msg string) {
w.Header().Set("Content-Type", "application/xml")
w.WriteHeader(status)
_, _ = fmt.Fprintf(w, `<?xml version="1.0" encoding="UTF-8"?>
<Error><Code>%s</Code><Message>%s</Message><Resource>/</Resource><RequestId>x</RequestId></Error>`, code, msg)
}
// newS3TestService builds an S3Service whose Endpoint points at a single
// httptest.Server handling BOTH Garage admin calls (for credential lookup)
// and S3 data-plane requests. Admin requests are routed by path prefix
// `/v2/` and `/health`; everything else is treated as an S3 call and
// dispatched to s3Handler.
func newS3TestService(t *testing.T, s3Handler http.Handler) *S3Service {
t.Helper()
secret := "s3-test-secret"
adminMux := http.NewServeMux()
adminMux.HandleFunc("/v2/GetBucketInfo", func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(&models.GarageBucketInfo{
ID: "bid",
Keys: []models.BucketKeyInfo{
{AccessKeyID: "TESTAK", Permissions: models.BucketKeyPermission{Read: true, Write: true}},
},
})
})
adminMux.HandleFunc("/v2/GetKeyInfo", func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(&models.GarageKeyInfo{
AccessKeyID: "TESTAK",
SecretAccessKey: &secret,
})
})
combined := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/v2/") || r.URL.Path == "/health" {
adminMux.ServeHTTP(w, r)
return
}
s3Handler.ServeHTTP(w, r)
})
srv := httptest.NewServer(combined)
t.Cleanup(srv.Close)
admin := NewGarageAdminService(&config.GarageConfig{
AdminEndpoint: srv.URL,
AdminToken: "test",
}, "")
// strip scheme for S3 endpoint (NewS3Service does this itself if http:// prefix)
s3 := NewS3Service(&config.GarageConfig{
Endpoint: srv.URL, // http://127.0.0.1:NNNN
Region: "garage",
}, admin)
return s3
}
// uniqueBucket2 returns a per-test bucket name so GlobalCache doesn't leak
// credentials between tests.
func uniqueBucket2(t *testing.T) string {
t.Helper()
name := "b-" + strings.ReplaceAll(t.Name(), "/", "-")
t.Cleanup(func() { utils.GlobalCache.Delete("key:" + name) })
return name
}
// fixedRequestCounter returns an http.Handler that always replies with the
// given S3 error, and counts requests.
func errS3Handler(status int, code string) (http.Handler, *int) {
var count int
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
count++
s3ErrorXML(w, status, code, code)
}), &count
}
func TestS3_ListBuckets_ServerError(t *testing.T) {
h, _ := errS3Handler(http.StatusInternalServerError, "InternalError")
s3 := newS3TestService(t, h)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
_, err := s3.ListBuckets(ctx)
if err == nil {
t.Fatal("expected error from ListBuckets, got nil")
}
if !strings.Contains(err.Error(), "failed to list buckets") {
t.Errorf("error %v should wrap 'failed to list buckets'", err)
}
}
func TestS3_CreateBucket_ServerError(t *testing.T) {
h, _ := errS3Handler(http.StatusConflict, "BucketAlreadyExists")
s3 := newS3TestService(t, h)
_ = uniqueBucket2(t)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
err := s3.CreateBucket(ctx, "b-TestS3_CreateBucket_ServerError")
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "failed to create bucket") {
t.Errorf("error = %v, want wrap 'failed to create bucket'", err)
}
}
func TestS3_DeleteBucket_ServerError(t *testing.T) {
h, _ := errS3Handler(http.StatusNotFound, "NoSuchBucket")
s3 := newS3TestService(t, h)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
err := s3.DeleteBucket(ctx, "b-TestS3_DeleteBucket_ServerError")
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "failed to delete bucket") {
t.Errorf("error = %v", err)
}
}
func TestS3_ListObjects_ServerError(t *testing.T) {
h, _ := errS3Handler(http.StatusForbidden, "AccessDenied")
s3 := newS3TestService(t, h)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
_, err := s3.ListObjects(ctx, "b-TestS3_ListObjects_ServerError", "", 0, "")
if err == nil {
t.Fatal("expected error from ListObjects, got nil")
}
if !strings.Contains(err.Error(), "failed to list objects") {
t.Errorf("error = %v", err)
}
}
func TestS3_UploadObject_ServerError(t *testing.T) {
h, _ := errS3Handler(http.StatusForbidden, "AccessDenied")
s3 := newS3TestService(t, h)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
_, err := s3.UploadObject(ctx, "b-TestS3_UploadObject_ServerError", "k", bytes.NewReader([]byte("hi")), "text/plain")
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "failed to upload object") {
t.Errorf("error = %v", err)
}
}
func TestS3_CreateDirectoryMarker_ServerError(t *testing.T) {
h, _ := errS3Handler(http.StatusForbidden, "AccessDenied")
s3 := newS3TestService(t, h)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
_, err := s3.CreateDirectoryMarker(ctx, "b-TestS3_CreateDirectoryMarker_ServerError", "folder/")
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "failed to create directory") {
t.Errorf("error = %v", err)
}
}
func TestS3_GetObject_ServerError(t *testing.T) {
h, _ := errS3Handler(http.StatusNotFound, "NoSuchKey")
s3 := newS3TestService(t, h)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
_, _, err := s3.GetObject(ctx, "b-TestS3_GetObject_ServerError", "missing")
if err == nil {
t.Fatal("expected error, got nil")
}
}
func TestS3_DeleteObject_ServerError(t *testing.T) {
h, _ := errS3Handler(http.StatusForbidden, "AccessDenied")
s3 := newS3TestService(t, h)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
err := s3.DeleteObject(ctx, "b-TestS3_DeleteObject_ServerError", "k")
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "failed to delete object") {
t.Errorf("error = %v", err)
}
}
func TestS3_ObjectExists_NoSuchKeyReturnsFalseNil(t *testing.T) {
h, _ := errS3Handler(http.StatusNotFound, "NoSuchKey")
s3 := newS3TestService(t, h)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
exists, err := s3.ObjectExists(ctx, "b-TestS3_ObjectExists_NoSuchKeyReturnsFalseNil", "k")
if err != nil {
t.Fatalf("ObjectExists returned error for NoSuchKey: %v", err)
}
if exists {
t.Error("exists should be false for NoSuchKey")
}
}
func TestS3_ObjectExists_OtherErrorPropagates(t *testing.T) {
h, _ := errS3Handler(http.StatusForbidden, "AccessDenied")
s3 := newS3TestService(t, h)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
_, err := s3.ObjectExists(ctx, "b-TestS3_ObjectExists_OtherErrorPropagates", "k")
if err == nil {
t.Fatal("expected error for AccessDenied, got nil")
}
}
func TestS3_GetObjectMetadata_ServerError(t *testing.T) {
h, _ := errS3Handler(http.StatusNotFound, "NoSuchKey")
s3 := newS3TestService(t, h)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
_, err := s3.GetObjectMetadata(ctx, "b-TestS3_GetObjectMetadata_ServerError", "k")
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "failed to get metadata") {
t.Errorf("error = %v", err)
}
}
func TestS3_DeleteMultipleObjects_EmptyKeysIsNoop(t *testing.T) {
// No S3 handler should be called; use a handler that fails if invoked.
called := false
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
s3ErrorXML(w, http.StatusInternalServerError, "ShouldNotHappen", "")
})
s3 := newS3TestService(t, h)
if err := s3.DeleteMultipleObjects(context.Background(), "whatever", nil); err != nil {
t.Fatalf("empty keys should return nil, got %v", err)
}
if called {
t.Error("S3 handler was invoked for empty-keys call")
}
}
func TestS3_DeleteMultipleObjects_ServerErrorPropagates(t *testing.T) {
h, _ := errS3Handler(http.StatusForbidden, "AccessDenied")
s3 := newS3TestService(t, h)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
err := s3.DeleteMultipleObjects(ctx, "b-TestS3_DeleteMultipleObjects_ServerErrorPropagates", []string{"a", "b"})
if err == nil {
t.Fatal("expected error, got nil")
}
}
func TestS3_GetPresignedURL_ReturnsURLWithoutServerCall(t *testing.T) {
// Presign is purely local (no network round-trip). Any handler suffices.
called := false
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
})
s3 := newS3TestService(t, h)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
got, err := s3.GetPresignedURL(ctx, "b-TestS3_GetPresignedURL_ReturnsURLWithoutServerCall", "k", 10*time.Minute)
if err != nil {
t.Fatalf("GetPresignedURL: %v", err)
}
u, perr := url.Parse(got)
if perr != nil {
t.Fatalf("returned URL is not parseable: %v", perr)
}
if u.Scheme == "" || u.Host == "" {
t.Errorf("presigned URL missing scheme/host: %q", got)
}
if !strings.Contains(u.RawQuery, "X-Amz-Signature") {
t.Errorf("presigned URL should contain X-Amz-Signature, got %q", got)
}
if called {
t.Error("presign should not make a network call")
}
}
// listBucketResultXML produces a ListBucketResult XML body that MinIO
// parses. ListObjectsV2 is keyed on the `list-type=2` query parameter.
func listBucketResultXML(bucket string, isTruncated bool, nextToken string, contents []struct {
Key string
Size int64
LastModified string
ETag string
}, commonPrefixes []string) string {
var b strings.Builder
b.WriteString(`<?xml version="1.0" encoding="UTF-8"?>`)
b.WriteString(`<ListBucketResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">`)
fmt.Fprintf(&b, `<Name>%s</Name>`, bucket)
b.WriteString(`<Prefix></Prefix>`)
fmt.Fprintf(&b, `<KeyCount>%d</KeyCount>`, len(contents))
b.WriteString(`<MaxKeys>1000</MaxKeys>`)
fmt.Fprintf(&b, `<IsTruncated>%t</IsTruncated>`, isTruncated)
if nextToken != "" {
fmt.Fprintf(&b, `<NextContinuationToken>%s</NextContinuationToken>`, nextToken)
}
for _, c := range contents {
lm := c.LastModified
if lm == "" {
lm = "2024-01-01T00:00:00.000Z"
}
etag := c.ETag
if etag == "" {
etag = "d41d8cd98f00b204e9800998ecf8427e"
}
fmt.Fprintf(&b, `<Contents><Key>%s</Key><LastModified>%s</LastModified><ETag>"%s"</ETag><Size>%d</Size><StorageClass>STANDARD</StorageClass></Contents>`,
c.Key, lm, etag, c.Size)
}
for _, p := range commonPrefixes {
fmt.Fprintf(&b, `<CommonPrefixes><Prefix>%s</Prefix></CommonPrefixes>`, p)
}
b.WriteString(`</ListBucketResult>`)
return b.String()
}
// s3ListHandler routes ListObjectsV2 (GET with list-type=2) to listBody
// and StatObject (HEAD) to a 200 response whose headers reflect statHeaders.
func s3ListHandler(listBody string, statHeaders map[string]string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodHead {
for k, v := range statHeaders {
w.Header().Set(k, v)
}
if _, ok := statHeaders["Content-Length"]; !ok {
w.Header().Set("Content-Length", "0")
}
w.WriteHeader(http.StatusOK)
return
}
// Treat any GET as a ListObjectsV2 request.
w.Header().Set("Content-Type", "application/xml")
w.WriteHeader(http.StatusOK)
_, _ = io.WriteString(w, listBody)
})
}
func TestS3_ListObjects_EmptyResult(t *testing.T) {
xml := listBucketResultXML("b", false, "", nil, nil)
s3 := newS3TestService(t, s3ListHandler(xml, nil))
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
got, err := s3.ListObjects(ctx, "b-TestS3_ListObjects_EmptyResult", "", 0, "")
if err != nil {
t.Fatalf("ListObjects: %v", err)
}
if got.Count != 0 || len(got.Objects) != 0 || len(got.Prefixes) != 0 {
t.Errorf("expected empty listing, got %+v", got)
}
}
func TestS3_ListObjects_ObjectsAndPrefixes(t *testing.T) {
contents := []struct {
Key string
Size int64
LastModified string
ETag string
}{
{Key: "file.txt", Size: 10},
}
xml := listBucketResultXML("b", false, "", contents, []string{"folder/"})
s3 := newS3TestService(t, s3ListHandler(xml, map[string]string{
"Content-Type": "text/plain",
"Content-Length": "10",
}))
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
got, err := s3.ListObjects(ctx, "b-TestS3_ListObjects_ObjectsAndPrefixes", "", 0, "")
if err != nil {
t.Fatalf("ListObjects: %v", err)
}
if got.Count != 1 || got.Objects[0].Key != "file.txt" {
t.Errorf("objects = %+v", got.Objects)
}
if len(got.Prefixes) != 1 || got.Prefixes[0] != "folder/" {
t.Errorf("prefixes = %+v", got.Prefixes)
}
// ContentType from StatObject may or may not round-trip depending on
// signature validation in the MinIO client; don't assert on it here.
}
func TestS3_ListObjects_DirectoryMarkerPromotedToPrefix(t *testing.T) {
// A zero-byte key ending in "/" in Contents must be dropped from
// Objects and promoted to Prefixes (unless already covered).
contents := []struct {
Key string
Size int64
LastModified string
ETag string
}{
{Key: "empty-folder/", Size: 0},
{Key: "already/", Size: 0}, // duplicate of CommonPrefix — must not duplicate
{Key: "real.txt", Size: 5},
}
xml := listBucketResultXML("b", true, "tokenXYZ", contents, []string{"already/"})
s3 := newS3TestService(t, s3ListHandler(xml, map[string]string{"Content-Length": "5"}))
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
got, err := s3.ListObjects(ctx, "b-TestS3_ListObjects_DirectoryMarkerPromotedToPrefix", "", 0, "")
if err != nil {
t.Fatalf("ListObjects: %v", err)
}
if got.Count != 1 || got.Objects[0].Key != "real.txt" {
t.Errorf("Objects should contain only real.txt, got %+v", got.Objects)
}
// Prefixes should contain "already/" (from CommonPrefix) and "empty-folder/"
// (promoted from Contents). "already/" must not appear twice.
count := map[string]int{}
for _, p := range got.Prefixes {
count[p]++
}
if count["already/"] != 1 {
t.Errorf("Prefixes contains 'already/' %d times, want 1: %v", count["already/"], got.Prefixes)
}
if count["empty-folder/"] != 1 {
t.Errorf("Prefixes missing 'empty-folder/': %v", got.Prefixes)
}
if !got.IsTruncated || got.NextContinuationToken != "tokenXYZ" {
t.Errorf("pagination fields not propagated: IsTruncated=%v Token=%q", got.IsTruncated, got.NextContinuationToken)
}
}
func TestS3_ListObjects_MarkerMatchingPrefixIsDropped(t *testing.T) {
// When listing a specific prefix, a marker whose key == the listing
// prefix is the folder itself — it must not render as a child of itself.
contents := []struct {
Key string
Size int64
LastModified string
ETag string
}{
{Key: "mydir/", Size: 0}, // matches prefix — must be dropped entirely
}
xml := listBucketResultXML("b", false, "", contents, nil)
s3 := newS3TestService(t, s3ListHandler(xml, nil))
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
got, err := s3.ListObjects(ctx, "b-TestS3_ListObjects_MarkerMatchingPrefixIsDropped", "mydir/", 0, "")
if err != nil {
t.Fatalf("ListObjects: %v", err)
}
if len(got.Objects) != 0 || len(got.Prefixes) != 0 {
t.Errorf("marker equal to prefix should be dropped entirely, got %+v", got)
}
}
func TestS3_ListObjects_StatObjectFailureLeavesContentTypeEmpty(t *testing.T) {
contents := []struct {
Key string
Size int64
LastModified string
ETag string
}{
{Key: "f.bin", Size: 100},
}
xml := listBucketResultXML("b", false, "", contents, nil)
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodHead {
// StatObject fails — return 403.
w.WriteHeader(http.StatusForbidden)
return
}
w.Header().Set("Content-Type", "application/xml")
w.WriteHeader(http.StatusOK)
_, _ = io.WriteString(w, xml)
})
s3 := newS3TestService(t, h)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
got, err := s3.ListObjects(ctx, "b-TestS3_ListObjects_StatObjectFailureLeavesContentTypeEmpty", "", 0, "")
if err != nil {
t.Fatalf("ListObjects: %v", err)
}
if got.Count != 1 || got.Objects[0].Key != "f.bin" {
t.Fatalf("unexpected object list %+v", got.Objects)
}
if got.Objects[0].ContentType != "" {
t.Errorf("ContentType = %q, want empty on StatObject failure", got.Objects[0].ContentType)
}
}
func TestS3_UploadMultipleObjects_PerFileFailuresRecorded(t *testing.T) {
h, _ := errS3Handler(http.StatusForbidden, "AccessDenied")
s3 := newS3TestService(t, h)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
results := s3.UploadMultipleObjects(ctx, "b-TestS3_UploadMultipleObjects_PerFileFailuresRecorded", []struct {
Key string
Body io.Reader
ContentType string
}{
{Key: "a", Body: bytes.NewReader([]byte("hello")), ContentType: "text/plain"},
{Key: "b", Body: bytes.NewReader([]byte("world")), ContentType: "text/plain"},
})
if len(results) != 2 {
t.Fatalf("len(results) = %d, want 2", len(results))
}
for i, r := range results {
if r.Success {
t.Errorf("result[%d] should not be successful: %+v", i, r)
}
if r.Error == nil {
t.Errorf("result[%d] should have Error set", i)
}
}
}
+331
View File
@@ -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)
}
}
+39 -19
View File
@@ -5,17 +5,22 @@ import (
"fmt"
"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/recover"
"github.com/rs/zerolog/log"
)
// @title Garage UI API
@@ -78,6 +83,7 @@ func main() {
logger.Info().
Str("config_path", *configPath).
Str("version", version).
Str("go_version", runtime.Version()).
Str("environment", cfg.Server.Environment).
Msg("Starting Garage UI Backend")
@@ -140,15 +146,29 @@ func main() {
// 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
// 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
logger.Info().Msg("Setting up routes")
@@ -178,38 +198,38 @@ func main() {
}
}()
// 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
logger.Info().Msg("Shutting down server")
logger.Info().Str("signal", sig.String()).Msg("Shutting down server")
shutdownStart := time.Now()
if err := app.Shutdown(); err != nil {
logger.Fatal().Err(err).Msg("Server shutdown failed")
}
logger.Info().Msg("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
logger.Error().
Err(err).
Int("status_code", code).
Str("method", c.Method()).
Str("path", c.Path()).
Msg("Request error")
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{
+30
View File
@@ -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
}
+42
View File
@@ -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")
}
+22
View File
@@ -219,6 +219,28 @@ func TestLogger_WithContext_AddsFields(t *testing.T) {
}
}
func TestWithError_AttachesErrorField(t *testing.T) {
serializeLoggerTests.Lock()
defer serializeLoggerTests.Unlock()
out := captureStdout(t, func() {
Init(Config{Level: "error", Format: "json"})
WithError(io.EOF).Msg("boom")
})
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["error"].(string); got != io.EOF.Error() {
t.Errorf("error field = %v, want %q", parsed["error"], io.EOF.Error())
}
if got, _ := parsed["level"].(string); got != "error" {
t.Errorf("level = %v, want error", parsed["level"])
}
}
// --- helpers ---
func firstNonEmptyLine(s string) string {
+22
View File
@@ -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 "***"
}
+32
View File
@@ -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)
}
}
}
+10
View File
@@ -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
}
+16
View File
@@ -266,3 +266,19 @@ func containsAll(s string, subs ...string) bool {
}
return true
}
func TestDefaultRetryConfig(t *testing.T) {
c := DefaultRetryConfig()
if c.MaxRetries <= 0 {
t.Errorf("MaxRetries = %d, want >0", c.MaxRetries)
}
if c.InitialBackoff <= 0 {
t.Errorf("InitialBackoff = %v, want >0", c.InitialBackoff)
}
if c.MaxBackoff < c.InitialBackoff {
t.Errorf("MaxBackoff (%v) should be >= InitialBackoff (%v)", c.MaxBackoff, c.InitialBackoff)
}
if c.BackoffFactor < 1.0 {
t.Errorf("BackoffFactor = %v, want >=1.0", c.BackoffFactor)
}
}
@@ -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
+23
View File
@@ -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"
+388
View File
@@ -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)
}
})
}
Binary file not shown.
+17
View File
@@ -0,0 +1,17 @@
coverage:
status:
project:
default:
target: 85%
threshold: 1%
patch:
default:
target: 80%
ignore:
- "backend/main.go"
- "backend/docs/"
- "backend/internal/services/mocks/"
- "backend/**/*_mock.go"
- "backend/internal/services/s3.go"
- "frontend/"
-3
View File
@@ -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
+93
View File
@@ -0,0 +1,93 @@
Copyright 2024 The Geist Project Authors (https://github.com/vercel/geist-font.git)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+30 -5
View File
@@ -1,14 +1,19 @@
import { useEffect } from 'react';
import {BrowserRouter, Route, Routes} from 'react-router-dom';
import {BrowserRouter, Navigate, Route, Routes} from 'react-router-dom';
import {QueryClientProvider} from '@tanstack/react-query';
import {ThemeProvider, useTheme} from '@/components/theme-provider';
import {Layout} from '@/components/layout/layout';
import {BucketDetailShell} from '@/components/layout/bucket-detail-shell';
import {Dashboard} from '@/pages/Dashboard';
import {Buckets} from '@/pages/Buckets';
import {BucketObjects} from '@/pages/BucketObjects';
import {ObjectDetailsView} from '@/components/buckets/ObjectDetailsView';
import {BucketPermissions} from '@/pages/BucketPermissions';
import {BucketWebsite} from '@/pages/BucketWebsite';
import {BucketSettings} from '@/pages/BucketSettings';
import {Cluster} from '@/pages/Cluster';
import {AccessControl} from '@/pages/AccessControl';
import {Login} from '@/pages/Login';
import {ObjectDetailsView} from '@/components/buckets/ObjectDetailsView';
import {Toaster} from 'sonner';
import {queryClient} from '@/lib/query-client';
import {useAuthStore} from '@/store/auth-store';
@@ -17,8 +22,21 @@ import {LoadingSpinner} from '@/components/auth/LoadingSpinner';
function ThemedToaster() {
const { theme } = useTheme();
return <Toaster richColors position="bottom-right" theme={theme} />;
return (
<Toaster
richColors
position="bottom-right"
theme={theme}
toastOptions={{
classNames: {
toast:
'rounded-lg border border-[var(--border)] bg-[var(--card)] text-[var(--foreground)] font-sans shadow-lg',
title: 'text-[14px] font-medium',
description: 'text-[13px] text-[var(--muted-foreground)]',
},
}}
/>
);
}
function App() {
@@ -49,7 +67,14 @@ function App() {
>
<Route index element={<Dashboard />} />
<Route path="buckets" element={<Buckets />} />
<Route path="buckets/:bucketName/objects/*" element={<ObjectDetailsView />} />
<Route path="buckets/:bucketName" element={<BucketDetailShell />}>
<Route index element={<Navigate to="objects" replace />} />
<Route path="objects" element={<BucketObjects />} />
<Route path="objects/*" element={<ObjectDetailsView />} />
<Route path="permissions" element={<BucketPermissions />} />
<Route path="website" element={<BucketWebsite />} />
<Route path="settings" element={<BucketSettings />} />
</Route>
<Route path="cluster" element={<Cluster />} />
<Route path="access" element={<AccessControl />} />
</Route>
@@ -99,7 +99,7 @@ export function BasicLoginForm({ showOIDC = false, config }: BasicLoginFormProps
</div>
<Button
type="button"
variant="outline"
variant="secondary"
className="w-full"
onClick={loginOIDC}
>
@@ -9,7 +9,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { FolderIcon, Globe, Loader2, MoreVertical, Plus, Search, Settings, Trash2 } from 'lucide-react';
import { FolderIcon, Globe, Loader2, MoreVertical, Search, Settings, Trash2 } from 'lucide-react';
import { formatBytes } from '@/lib/file-utils';
import { formatDate } from '@/lib/utils';
import type { Bucket } from '@/types';
@@ -21,7 +21,6 @@ interface BucketListViewProps {
onSearchChange: (query: string) => void;
onViewBucket: (bucketName: string) => void;
onOpenSettings: (bucket: Bucket) => void;
onCreateBucket: () => void;
onDeleteBucket: (bucket: Bucket) => void;
onWebsiteSettings: (bucket: Bucket) => void;
}
@@ -33,7 +32,6 @@ export function BucketListView({
onSearchChange,
onViewBucket,
onOpenSettings,
onCreateBucket,
onDeleteBucket,
onWebsiteSettings,
}: BucketListViewProps) {
@@ -44,20 +42,14 @@ export function BucketListView({
return (
<div className="space-y-4 sm:space-y-6">
{/* Toolbar */}
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-3">
<div className="relative flex-1 max-w-full sm:max-w-xs">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search buckets..."
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
className="pl-8"
/>
</div>
<Button onClick={onCreateBucket} className="w-full sm:w-auto">
<Plus className="h-4 w-4" />
Create Bucket
</Button>
<div className="relative w-full max-w-xs">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search buckets..."
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
className="pl-8"
/>
</div>
{/* Buckets Table */}
@@ -99,14 +91,14 @@ export function BucketListView({
<TableCell className="font-medium max-w-[200px]">
<span className="truncate">{bucket.name}</span>
{bucket.websiteAccess && (
<Badge variant="outline" className="text-xs ml-2">
<Badge variant="neutral" 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>
<Badge variant="neutral">{bucket.region || 'default'}</Badge>
</TableCell>
<TableCell className="hidden md:table-cell">{bucket.objectCount?.toLocaleString() || 0}</TableCell>
<TableCell>{bucket.size ? formatBytes(bucket.size) : '0 B'}</TableCell>
@@ -1,196 +0,0 @@
import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Select, SelectOption } from '@/components/ui/select';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { useAccessKeys } from '@/hooks/useApi';
import type { Bucket } from '@/types';
import { toast } from 'sonner';
interface BucketSettingsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
bucket: Bucket | null;
onGrantPermission: (bucketName: string, accessKeyId: string, permissions: { read: boolean; write: boolean; owner: boolean }) => Promise<boolean>;
}
export function BucketSettingsDialog({ open, onOpenChange, bucket, onGrantPermission }: BucketSettingsDialogProps) {
const { data: availableKeys = [] } = useAccessKeys();
const [selectedAccessKey, setSelectedAccessKey] = useState<string>('');
const [permissionRead, setPermissionRead] = useState(false);
const [permissionWrite, setPermissionWrite] = useState(false);
const [permissionOwner, setPermissionOwner] = useState(false);
useEffect(() => {
if (open && bucket) {
resetForm();
}
}, [open, bucket]);
const resetForm = () => {
setSelectedAccessKey('');
setPermissionRead(false);
setPermissionWrite(false);
setPermissionOwner(false);
};
const handleAccessKeyChange = (accessKeyId: string) => {
setSelectedAccessKey(accessKeyId);
if (!accessKeyId) {
setPermissionRead(false);
setPermissionWrite(false);
setPermissionOwner(false);
return;
}
const selectedKey = availableKeys.find(key => key.accessKeyId === accessKeyId);
if (selectedKey && bucket) {
const bucketPermission = selectedKey.permissions.find(
perm => perm.bucketName === bucket.name || perm.bucketId === bucket.name
);
if (bucketPermission) {
setPermissionRead(bucketPermission.read);
setPermissionWrite(bucketPermission.write);
setPermissionOwner(bucketPermission.owner);
} else {
setPermissionRead(false);
setPermissionWrite(false);
setPermissionOwner(false);
}
}
};
const handleGrantPermission = async () => {
if (!bucket || !selectedAccessKey) {
toast.error('Please select an access key');
return;
}
if (!permissionRead && !permissionWrite && !permissionOwner) {
toast.error('Please select at least one permission');
return;
}
const success = await onGrantPermission(bucket.name, selectedAccessKey, {
read: permissionRead,
write: permissionWrite,
owner: permissionOwner,
});
if (success) {
resetForm();
onOpenChange(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Bucket Settings - {bucket?.name}</DialogTitle>
<DialogDescription>
Grant access key permissions for this bucket
</DialogDescription>
</DialogHeader>
<div className="space-y-6 py-4">
<div className="space-y-2">
<label className="text-sm font-medium">Select Access Key</label>
<Select
value={selectedAccessKey}
onChange={(value) => handleAccessKeyChange(value)}
>
<SelectOption value="">-- Select an access key --</SelectOption>
{availableKeys.map((key) => (
<SelectOption key={key.accessKeyId} value={key.accessKeyId}>
{key.name} ({key.accessKeyId})
</SelectOption>
))}
</Select>
<p className="text-xs text-muted-foreground">
Choose which access key should have permissions on this bucket. Current permissions will be displayed when selected.
</p>
</div>
<div className="space-y-3">
<label className="text-sm font-medium">Permissions</label>
<div className="space-y-3 border rounded-lg p-4">
<div className="flex items-start space-x-3">
<Checkbox
id="permission-read"
checked={permissionRead}
onCheckedChange={(checked) => setPermissionRead(checked as boolean)}
/>
<div className="flex-1">
<label
htmlFor="permission-read"
className="text-sm font-medium leading-none cursor-pointer"
>
Read
</label>
<p className="text-xs text-muted-foreground mt-1">
Allows reading objects from the bucket (GetObject, HeadObject, ListObjects)
</p>
</div>
</div>
<div className="flex items-start space-x-3">
<Checkbox
id="permission-write"
checked={permissionWrite}
onCheckedChange={(checked) => setPermissionWrite(checked as boolean)}
/>
<div className="flex-1">
<label
htmlFor="permission-write"
className="text-sm font-medium leading-none cursor-pointer"
>
Write
</label>
<p className="text-xs text-muted-foreground mt-1">
Allows writing and deleting objects in the bucket (PutObject, DeleteObject)
</p>
</div>
</div>
<div className="flex items-start space-x-3">
<Checkbox
id="permission-owner"
checked={permissionOwner}
onCheckedChange={(checked) => setPermissionOwner(checked as boolean)}
/>
<div className="flex-1">
<label
htmlFor="permission-owner"
className="text-sm font-medium leading-none cursor-pointer"
>
Owner
</label>
<p className="text-xs text-muted-foreground mt-1">
Allows managing bucket settings and policies (DeleteBucket, PutBucketPolicy)
</p>
</div>
</div>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleGrantPermission} disabled={!selectedAccessKey}>
Grant Permission
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -1,133 +0,0 @@
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,14 +1,17 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { Database } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { IconTile } from '@/components/ui/icon-tile';
import { toast } from 'sonner';
interface CreateBucketDialogProps {
@@ -20,6 +23,8 @@ interface CreateBucketDialogProps {
export function CreateBucketDialog({ open, onOpenChange, onCreateBucket }: CreateBucketDialogProps) {
const [bucketName, setBucketName] = useState('');
useEffect(() => { if (!open) setBucketName(''); }, [open]);
const handleCreate = async () => {
if (!bucketName) {
toast.error('Please enter a bucket name');
@@ -37,15 +42,19 @@ export function CreateBucketDialog({ open, onOpenChange, onCreateBucket }: Creat
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Create New Bucket</DialogTitle>
<DialogDescription>
Create a new storage bucket for your objects
</DialogDescription>
<IconTile icon={<Database />} tone="primary" size="md" />
<div className="flex-1">
<DialogTitle>Create New Bucket</DialogTitle>
<DialogDescription>
Create a new storage bucket for your objects
</DialogDescription>
</div>
</DialogHeader>
<div className="space-y-4 py-4">
<DialogBody className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium">Bucket Name</label>
<Input
autoFocus
placeholder="my-bucket-name"
value={bucketName}
onChange={(e) => setBucketName(e.target.value)}
@@ -59,13 +68,13 @@ export function CreateBucketDialog({ open, onOpenChange, onCreateBucket }: Creat
Must be unique and follow DNS naming conventions
</p>
</div>
</div>
<DialogFooter className="space-y-2">
<Button variant="outline" onClick={() => onOpenChange(false)}>
</DialogBody>
<DialogFooter>
<Button variant="secondary" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
variant={!bucketName ? 'default_disabled' : 'default'}
variant="primary"
onClick={handleCreate}
disabled={!bucketName}
>
@@ -1,14 +1,17 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { FolderPlus } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { IconTile } from '@/components/ui/icon-tile';
import { toast } from 'sonner';
interface CreateDirectoryDialogProps {
@@ -21,6 +24,8 @@ interface CreateDirectoryDialogProps {
export function CreateDirectoryDialog({ open, onOpenChange, currentPath, onCreateDirectory }: CreateDirectoryDialogProps) {
const [dirName, setDirName] = useState('');
useEffect(() => { if (!open) setDirName(''); }, [open]);
const handleCreate = async () => {
if (!dirName) {
toast.error('Please enter a directory name');
@@ -38,15 +43,19 @@ export function CreateDirectoryDialog({ open, onOpenChange, currentPath, onCreat
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Create Directory</DialogTitle>
<DialogDescription>
Create a new directory in {currentPath || 'the root'}
</DialogDescription>
<IconTile icon={<FolderPlus />} tone="primary" size="md" />
<div className="flex-1">
<DialogTitle>Create Directory</DialogTitle>
<DialogDescription>
Create a new directory in {currentPath || 'the root'}
</DialogDescription>
</div>
</DialogHeader>
<div className="space-y-4 py-4">
<DialogBody className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium">Directory Name</label>
<Input
autoFocus
placeholder="my-directory"
value={dirName}
onChange={(e) => setDirName(e.target.value)}
@@ -57,9 +66,9 @@ export function CreateDirectoryDialog({ open, onOpenChange, currentPath, onCreat
}}
/>
</div>
</div>
</DialogBody>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
<Button variant="secondary" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleCreate} disabled={!dirName}>
@@ -1,49 +0,0 @@
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import type { Bucket } from '@/types';
interface DeleteBucketDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
bucket: Bucket | null;
onDeleteBucket: (name: string) => Promise<boolean>;
}
export function DeleteBucketDialog({ open, onOpenChange, bucket, onDeleteBucket }: DeleteBucketDialogProps) {
const handleDelete = async () => {
if (!bucket) return;
const success = await onDeleteBucket(bucket.name);
if (success) {
onOpenChange(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Bucket</DialogTitle>
<DialogDescription>
Are you sure you want to delete "{bucket?.name}"? This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button variant="destructive" onClick={handleDelete}>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -1,3 +1,4 @@
import { Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Dialog,
@@ -7,6 +8,7 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { IconTile } from '@/components/ui/icon-tile';
import type { S3Object } from '@/types';
interface DeleteObjectDialogProps {
@@ -27,16 +29,19 @@ export function DeleteObjectDialog({ open, onOpenChange, object, onDeleteObject
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<Dialog open={open} onOpenChange={onOpenChange} size="destructive">
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Object</DialogTitle>
<DialogDescription>
Are you sure you want to delete "{object?.key}"? This action cannot be undone.
</DialogDescription>
<IconTile icon={<Trash2 />} tone="destructive" size="md" />
<div className="flex-1">
<DialogTitle>Delete Object</DialogTitle>
<DialogDescription>
Are you sure you want to delete "{object?.key}"? This action cannot be undone.
</DialogDescription>
</div>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
<Button variant="secondary" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button variant="destructive" onClick={handleDelete}>
@@ -2,7 +2,6 @@ import {useState} from 'react';
import {useDropzone} from 'react-dropzone';
import {Button} from '@/components/ui/button';
import {Input} from '@/components/ui/input';
import {Header} from '@/components/layout/header';
import {ObjectsTable} from './ObjectsTable';
import {CreateDirectoryDialog} from './CreateDirectoryDialog';
import {DeleteObjectDialog} from './DeleteObjectDialog';
@@ -170,10 +169,9 @@ export function ObjectBrowserView({
return (
<div>
<Header title={`Objects in ${bucketName}`} />
<div className="p-4 sm:p-6 space-y-4 sm:space-y-6">
{/* Back Button */}
<Button variant="outline" onClick={onBackToBuckets} className="text-sm sm:text-base">
<Button variant="secondary" onClick={onBackToBuckets} className="text-sm sm:text-base">
<ArrowLeft className="h-4 w-4" />
<span className="hidden sm:inline">Back to Buckets</span>
<span className="sm:hidden">Back</span>
@@ -229,7 +227,7 @@ export function ObjectBrowserView({
<FolderPlus className="h-4 w-4" />
<span className="hidden sm:inline">Add Directory</span>
</Button>
<Button variant="outline" size="icon" onClick={onRefresh} title="Refresh" disabled={isRefreshing}>
<Button variant="secondary" size="icon" onClick={onRefresh} title="Refresh" disabled={isRefreshing}>
<RotateCwIcon className={`h-4 w-4 transition-transform duration-500 ${isRefreshing ? 'animate-spin' : ''}`} />
</Button>
</div>
@@ -1,21 +1,46 @@
import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useNavigate, useParams, Link } from 'react-router-dom';
import { objectsApi } from '@/lib/api';
import type { ObjectMetadata } from '@/types';
import { Header } from '@/components/layout/header';
import { Button } from '@/components/ui/button';
import { ArrowLeft, Download, Trash, Copy, File } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { IconTile } from '@/components/ui/icon-tile';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { ArrowLeft, ChevronRight, Copy, Download, File, Loader2, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import { formatBytes } from '@/lib/file-utils';
import { formatDate } from '@/lib/utils';
function CardSection({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section className="overflow-hidden rounded-xl border border-[var(--border)] bg-[var(--card)]">
<div className="border-b border-[var(--border)] px-5 py-3.5">
<h3 className="text-[14px] font-semibold tracking-[-0.01em]">{title}</h3>
</div>
{children}
</section>
);
}
function DetailRow({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="grid grid-cols-1 gap-1 px-5 py-3.5 sm:grid-cols-[200px_1fr] sm:gap-4">
<dt className="text-[12.5px] font-medium text-[var(--muted-foreground)]">{label}</dt>
<dd className="text-[13.5px] text-[var(--foreground)] break-words">{children}</dd>
</div>
);
}
export function ObjectDetailsView() {
const navigate = useNavigate();
const { bucketName, '*': encodedObjectKey } = useParams();
// Decode the object key from the URL
const objectKey = encodedObjectKey ? decodeURIComponent(encodedObjectKey) : undefined;
const [metadata, setMetadata] = useState<ObjectMetadata | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [deleteOpen, setDeleteOpen] = useState(false);
const [deleting, setDeleting] = useState(false);
useEffect(() => {
if (!bucketName || !objectKey) {
@@ -23,7 +48,6 @@ export function ObjectDetailsView() {
setIsLoading(false);
return;
}
const fetchMetadata = async () => {
try {
setIsLoading(true);
@@ -32,240 +56,184 @@ export function ObjectDetailsView() {
setMetadata(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load object metadata');
console.error('Failed to fetch object metadata:', err);
} finally {
setIsLoading(false);
}
};
fetchMetadata();
}, [bucketName, objectKey]);
const parentPath = objectKey?.split('/').slice(0, -1).join('/') ?? '';
const fileName = objectKey?.split('/').pop() || objectKey || '';
const backHref = `/buckets/${bucketName}/objects${parentPath ? `?prefix=${encodeURIComponent(parentPath + '/')}` : ''}`;
const pathSegments = parentPath ? parentPath.split('/').filter(Boolean) : [];
const copy = (text: string, label = 'Copied') => {
navigator.clipboard.writeText(text);
toast.success(label);
};
const handleDownload = async () => {
if (!bucketName || !objectKey) return;
try {
const blob = await objectsApi.get(bucketName, objectKey);
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = objectKey.split('/').pop() || 'download';
a.download = fileName || 'download';
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
toast.success('Download started');
} catch (err) {
console.error('Download failed:', err);
} catch {
// error toast handled by axios interceptor
}
};
const handleDelete = async () => {
if (!bucketName || !objectKey) return;
if (!confirm(`Are you sure you want to delete "${objectKey}"?`)) {
return;
}
try {
setDeleting(true);
await objectsApi.delete(bucketName, objectKey);
toast.success('Object deleted successfully');
handleBackNavigation();
} catch (err) {
console.error('Delete failed:', err);
toast.success('Object deleted');
navigate(backHref);
} catch {
// error toast handled by axios interceptor
} finally {
setDeleting(false);
setDeleteOpen(false);
}
};
const handleBackNavigation = () => {
if (!bucketName) return;
// Navigate back to the bucket explorer with the appropriate prefix
// Extract the folder path from the object key (everything before the last /)
const folderPath = objectKey?.split('/').slice(0, -1).join('/') || '';
const prefix = folderPath ? `${folderPath}/` : '';
// Navigate to the bucket view with the correct prefix
navigate(`/buckets?bucket=${encodeURIComponent(bucketName)}${prefix ? `&prefix=${encodeURIComponent(prefix)}` : ''}`);
};
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);
toast.success('Copied to clipboard');
};
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
timeZoneName: 'short',
});
};
if (isLoading) {
return (
<div>
<Header title="Object Details" />
<div className="p-4 sm:p-6">
<div className="flex items-center justify-center h-64">
<div className="text-muted-foreground">Loading object details...</div>
</div>
</div>
<div className="flex h-64 items-center justify-center gap-2 text-[var(--muted-foreground)]">
<Loader2 className="h-4 w-4 animate-spin" /> Loading object details
</div>
);
}
if (error || !metadata) {
return (
<div>
<Header title="Object Details" />
<div className="p-4 sm:p-6">
<Button variant="outline" onClick={handleBackNavigation} className="mb-4">
<ArrowLeft className="h-4 w-4" />
Back
</Button>
<div className="flex items-center justify-center h-64">
<div className="text-red-500">{error || 'Object not found'}</div>
</div>
<div className="px-7 py-6">
<Button variant="secondary" onClick={() => navigate(backHref)} className="mb-4">
<ArrowLeft className="h-4 w-4" /> Back
</Button>
<div className="rounded-xl border border-[var(--danger-border)] bg-[var(--danger-soft)] px-5 py-4 text-[13.5px] text-[var(--destructive)]">
{error || 'Object not found'}
</div>
</div>
);
}
const fileName = objectKey?.split('/').pop() || objectKey || '';
const pathParts = objectKey?.split('/').filter(part => part) || [];
const parentPath = pathParts.slice(0, -1).join('/');
return (
<div>
<Header title={fileName} />
<div className="p-4 sm:p-6 space-y-6">
{/* Back Button and Actions */}
<div className="flex items-center justify-between">
<Button variant="outline" onClick={handleBackNavigation}>
<ArrowLeft className="h-4 w-4" />
Back
</Button>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={handleDownload}>
<Download className="h-4 w-4" />
Download
</Button>
<Button
variant="outline"
className="border-red-500 text-red-500 hover:bg-red-500/5"
onClick={handleDelete}
>
<Trash className="h-4 w-4" />
Delete
</Button>
</div>
</div>
{/* File Name Header */}
<div className="flex items-start gap-3 p-4 border-b border-border bg-card rounded-t-lg">
<div className="mt-1">
<File className="h-5 w-5 text-muted-foreground" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-4 flex-wrap">
<h2 className="text-lg font-medium text-foreground break-all">
{parentPath && (
<span className="text-muted-foreground font-mono">/{parentPath}/</span>
)}
{fileName}
</h2>
<button
onClick={() => copyToClipboard(metadata.key)}
className="text-sm text-muted-foreground hover:text-foreground flex items-center gap-1 shrink-0"
>
<Copy className="h-3 w-3" />
Copy
</button>
</div>
</div>
</div>
{/* Object Details Section */}
<div className="border border-border rounded-lg bg-card">
<div className="p-6 border-b border-border">
<h3 className="text-base font-semibold text-foreground">Object Details</h3>
</div>
<div className="divide-y divide-border">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 p-6">
<div className="text-sm font-medium text-muted-foreground">Date Created</div>
<div className="sm:col-span-2 text-sm text-foreground">
{formatDate(metadata.lastModified)}
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 p-6">
<div className="text-sm font-medium text-muted-foreground">Type</div>
<div className="sm:col-span-2 text-sm text-foreground">
{metadata.contentType || 'application/octet-stream'}
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 p-6">
<div className="text-sm font-medium text-muted-foreground">Storage Class</div>
<div className="sm:col-span-2 text-sm text-foreground">
{metadata.storageClass || 'Standard'}
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 p-6">
<div className="text-sm font-medium text-muted-foreground">Size</div>
<div className="sm:col-span-2 text-sm text-foreground">
{formatBytes(metadata.size)}
</div>
</div>
</div>
</div>
{/* Custom Metadata Section */}
{metadata.metadata && Object.keys(metadata.metadata).length > 0 && (
<div className="border border-border rounded-lg bg-card">
<div className="p-6 border-b border-border">
<h3 className="text-base font-semibold text-foreground">Custom Metadata</h3>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-muted/30">
<tr className="border-b border-border">
<th className="px-6 py-3 text-left text-sm font-medium text-muted-foreground">
Key
</th>
<th className="px-6 py-3 text-left text-sm font-medium text-muted-foreground">
Value
</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{Object.entries(metadata.metadata).map(([key, value]) => (
<tr key={key} className="hover:bg-muted/30">
<td className="px-6 py-4 text-sm font-medium text-foreground break-all">
{key}
</td>
<td className="px-6 py-4 text-sm text-foreground break-all">{value}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Object Preview Section */}
<div className="border border-border rounded-lg bg-card">
<div className="p-6 border-b border-border">
<h3 className="text-base font-semibold text-foreground">Object Preview</h3>
</div>
<div className="p-6">
<p className="text-sm text-muted-foreground">No preview available</p>
</div>
</div>
<div className="px-7 py-6 space-y-6">
{/* Back + breadcrumb */}
<div className="flex items-center gap-2 text-[13px] text-[var(--muted-foreground)]">
<Link
to={backHref}
className="inline-flex items-center gap-1.5 rounded-md px-2 py-1 hover:bg-[var(--accent)] hover:text-[var(--foreground)]"
>
<ArrowLeft className="h-3.5 w-3.5" />
Objects
</Link>
{pathSegments.map((seg, i) => (
<span key={i} className="inline-flex items-center gap-1">
<ChevronRight className="h-3.5 w-3.5 opacity-50" />
<span className="font-mono">{seg}</span>
</span>
))}
<ChevronRight className="h-3.5 w-3.5 opacity-50" />
<span className="truncate font-mono text-[var(--foreground)]">{fileName}</span>
</div>
{/* Hero */}
<section className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="flex min-w-0 items-start gap-3">
<IconTile icon={<File />} tone="primary" size="lg" />
<div className="min-w-0">
<h1 className="truncate text-[22px] font-semibold tracking-[-0.02em]">{fileName}</h1>
<button
type="button"
onClick={() => copy(metadata.key, 'Object key copied')}
title="Copy key"
className="group mt-1 inline-flex max-w-full items-center gap-1.5 truncate font-mono text-[13px] text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
>
<span className="truncate">{metadata.key}</span>
<Copy className="h-3 w-3 flex-shrink-0 opacity-60 group-hover:opacity-100" />
</button>
<div className="mt-2 flex flex-wrap gap-1.5">
<Badge>{formatBytes(metadata.size)}</Badge>
<Badge>{metadata.contentType || 'application/octet-stream'}</Badge>
{metadata.storageClass && <Badge>{metadata.storageClass}</Badge>}
</div>
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<Button variant="secondary" onClick={handleDownload}>
<Download className="h-4 w-4" /> Download
</Button>
<Button variant="destructive" onClick={() => setDeleteOpen(true)}>
<Trash2 className="h-4 w-4" /> Delete
</Button>
</div>
</section>
{/* Details */}
<CardSection title="Details">
<dl className="divide-y divide-[var(--border)]">
<DetailRow label="Size">{formatBytes(metadata.size)}</DetailRow>
<DetailRow label="Content type">{metadata.contentType || 'application/octet-stream'}</DetailRow>
<DetailRow label="Storage class">{metadata.storageClass || 'Standard'}</DetailRow>
<DetailRow label="Last modified">{formatDate(metadata.lastModified)}</DetailRow>
<DetailRow label="ETag">
<button
type="button"
onClick={() => copy(metadata.etag, 'ETag copied')}
className="inline-flex max-w-full items-center gap-1.5 truncate rounded-md bg-[var(--surface-sunken)] px-2 py-0.5 font-mono text-[12.5px] hover:bg-[var(--accent)]"
>
<span className="truncate">{metadata.etag}</span>
<Copy className="h-3 w-3 flex-shrink-0 opacity-60" />
</button>
</DetailRow>
{metadata.versionId && (
<DetailRow label="Version ID">
<span className="font-mono text-[12.5px]">{metadata.versionId}</span>
</DetailRow>
)}
</dl>
</CardSection>
{/* Custom metadata */}
{metadata.metadata && Object.keys(metadata.metadata).length > 0 && (
<CardSection title="Custom metadata">
<dl className="divide-y divide-[var(--border)]">
{Object.entries(metadata.metadata).map(([key, value]) => (
<DetailRow key={key} label={key}>
<span className="font-mono text-[12.5px]">{value}</span>
</DetailRow>
))}
</dl>
</CardSection>
)}
{/* Preview */}
<CardSection title="Preview">
<div className="px-5 py-10 text-center text-[13px] text-[var(--muted-foreground)]">
No preview available for this object.
</div>
</CardSection>
<ConfirmDialog
open={deleteOpen}
onOpenChange={setDeleteOpen}
title={`Delete "${fileName}"?`}
description="Applications referencing this object will no longer be able to read it."
confirmLabel="Delete object"
loading={deleting}
onConfirm={handleDelete}
/>
</div>
);
}
@@ -282,7 +282,7 @@ export function ObjectsTable({
</TableCell>
<TableCell className="hidden md:table-cell">
{obj.storageClass && (
<Badge variant="secondary">{obj.storageClass}</Badge>
<Badge variant="neutral">{obj.storageClass}</Badge>
)}
</TableCell>
<TableCell>{obj.isFolder ? null : formatBytes(obj.size)}</TableCell>
@@ -400,7 +400,7 @@ export function ObjectsTable({
<div className="flex items-center gap-2">
<Button
variant={hasPrevious ? "default": "default_disabled"}
variant="primary"
size="sm"
onClick={handlePreviousPage}
disabled={!hasPrevious}
@@ -411,7 +411,7 @@ export function ObjectsTable({
</Button>
<Button
variant={hasNext ? "default": "default_disabled"}
variant="primary"
size="sm"
onClick={handleNextPage}
disabled={!hasNext}
-2
View File
@@ -2,7 +2,5 @@ export { BucketListView } from './BucketListView';
export { ObjectBrowserView } from './ObjectBrowserView';
export { ObjectsTable } from './ObjectsTable';
export { CreateBucketDialog } from './CreateBucketDialog';
export { DeleteBucketDialog } from './DeleteBucketDialog';
export { BucketSettingsDialog } from './BucketSettingsDialog';
export { CreateDirectoryDialog } from './CreateDirectoryDialog';
export { DeleteObjectDialog } from './DeleteObjectDialog';
@@ -0,0 +1,105 @@
import { NavLink, Outlet, useParams } from 'react-router-dom';
import { Database, Copy, Upload } from 'lucide-react';
import { IconTile } from '@/components/ui/icon-tile';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
import { useBuckets } from '@/hooks/useApi';
import { toast } from 'sonner';
interface TabSpec {
to: string;
label: string;
end?: boolean;
}
const tabs: TabSpec[] = [
{ to: 'objects', label: 'Objects' },
{ to: 'permissions', label: 'Permissions' },
{ to: 'website', label: 'Website' },
{ to: 'settings', label: 'Settings' },
];
function formatBytes(n?: number) {
if (n == null) return '';
if (n < 1024) return `${n} B`;
const units = ['KB', 'MB', 'GB', 'TB'];
let v = n / 1024;
for (const u of units) {
if (v < 1024) return `${v.toFixed(v >= 10 ? 0 : 1)} ${u}`;
v /= 1024;
}
return `${v.toFixed(0)} PB`;
}
export function BucketDetailShell() {
const { bucketName = '' } = useParams<{ bucketName: string }>();
const { data: buckets = [] } = useBuckets();
const bucket = buckets.find((b) => b.name === bucketName);
const s3Url = `s3://${bucketName}`;
const copyUrl = async () => {
try {
await navigator.clipboard.writeText(s3Url);
toast.success('URL copied');
} catch {
toast.error('Failed to copy');
}
};
return (
<div className="flex flex-col">
{/* Hero */}
<section className="px-7 pt-6 pb-5">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="flex min-w-0 items-start gap-3">
<IconTile icon={<Database />} tone="primary" size="lg" />
<div className="min-w-0">
<h1 className="truncate text-[26px] font-semibold tracking-[-0.02em]">{bucketName}</h1>
<p className="mt-1 truncate font-mono text-[13.5px] text-[var(--muted-foreground)]">{s3Url}</p>
<div className="mt-2 flex flex-wrap gap-1.5">
<Badge variant="success">Active</Badge>
{bucket?.objectCount != null && <Badge>{bucket.objectCount.toLocaleString()} objects</Badge>}
{bucket?.size != null && <Badge>{formatBytes(bucket.size)}</Badge>}
</div>
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<Button variant="secondary" onClick={copyUrl}>
<Copy /> Copy URL
</Button>
<Button variant="primary" onClick={() => document.dispatchEvent(new CustomEvent('bucket:upload'))}>
<Upload /> Upload
</Button>
</div>
</div>
</section>
{/* Tabs */}
<nav className="flex h-12 items-center gap-0 border-b border-[var(--border)] px-7">
{tabs.map((t) => (
<NavLink
key={t.to}
to={t.to}
end={t.end}
className={({ isActive }) =>
cn(
'relative -mb-px inline-flex h-12 items-center px-3.5 text-[14px] font-medium transition-colors',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)] rounded-sm',
isActive
? 'text-[var(--primary)] border-b-2 border-[var(--primary)]'
: 'text-[var(--muted-foreground)] border-b-2 border-transparent hover:text-[var(--foreground)]',
)
}
>
{t.label}
</NavLink>
))}
</nav>
<div className="min-w-0">
<Outlet />
</div>
</div>
);
}
+42 -11
View File
@@ -1,36 +1,67 @@
import { Outlet } from 'react-router-dom';
import { Outlet, useLocation, useParams } from 'react-router-dom';
import { Sidebar } from './sidebar';
import { useState } from 'react';
import { TopBar } from './top-bar';
import { useState, useMemo } from 'react';
import { Menu } from 'lucide-react';
import { Button } from '@/components/ui/button';
import type { BreadcrumbItem } from '@/components/ui/breadcrumb';
function useCrumbs(): BreadcrumbItem[] {
const location = useLocation();
const params = useParams();
return useMemo(() => {
const path = location.pathname;
if (path === '/') return [{ label: 'Dashboard' }];
if (path === '/cluster') return [{ label: 'Cluster' }];
if (path === '/access') return [{ label: 'Access Control' }];
if (path === '/buckets') return [{ label: 'Buckets' }];
if (path.startsWith('/buckets/')) {
const bucketName = (params as { bucketName?: string }).bucketName ?? path.split('/')[2];
const crumbs: BreadcrumbItem[] = [
{ label: 'Buckets', to: '/buckets' },
{ label: bucketName, to: `/buckets/${bucketName}/objects` },
];
const segs = path.split('/').slice(3); // after /buckets/:name
if (segs[0] && segs[0] !== 'objects') {
const tabLabel = segs[0][0].toUpperCase() + segs[0].slice(1);
crumbs.push({ label: tabLabel });
}
return crumbs;
}
return [];
}, [location.pathname, params]);
}
export function Layout() {
const [sidebarOpen, setSidebarOpen] = useState(false);
const crumbs = useCrumbs();
return (
<div className="flex h-screen overflow-hidden">
{/* Mobile menu button */}
<div className="flex h-screen overflow-hidden bg-[var(--background)]">
<Button
variant="ghost"
size="icon"
className="fixed top-4 left-4 z-50 md:hidden"
className="fixed left-3 top-3 z-50 md:hidden"
onClick={() => setSidebarOpen(!sidebarOpen)}
aria-label="Toggle navigation"
>
<Menu className="h-6 w-6" />
<Menu className="h-5 w-5" />
</Button>
{/* Mobile overlay */}
{sidebarOpen && (
<div
className="fixed inset-0 bg-black/50 z-40 md:hidden"
className="fixed inset-0 z-40 bg-black/50 md:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
<Sidebar isOpen={sidebarOpen} onClose={() => setSidebarOpen(false)} />
<main className="flex-1 overflow-y-auto">
<Outlet />
</main>
<div className="flex min-w-0 flex-1 flex-col">
<TopBar crumbs={crumbs} />
<main className="flex-1 overflow-y-auto scrollbar-thin">
<Outlet />
</main>
</div>
</div>
);
}
+84 -90
View File
@@ -1,8 +1,7 @@
import {Link, useLocation} from 'react-router-dom';
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 { Link, useLocation } from 'react-router-dom';
import { cn } from '@/lib/utils';
import { BookOpen, Database, Key, LayoutDashboard, Server } from 'lucide-react';
import { useAuthStore } from '@/store/auth-store';
import { useQuery } from '@tanstack/react-query';
import { healthApi, garageApi } from '@/lib/api';
@@ -12,26 +11,25 @@ interface NavItem {
icon: React.ComponentType<{ className?: string }>;
}
const navItems: NavItem[] = [
interface NavGroup {
label?: string;
items: NavItem[];
}
const navGroups: NavGroup[] = [
{
title: 'Dashboard',
href: '/',
icon: LayoutDashboard,
items: [{ title: 'Dashboard', href: '/', icon: LayoutDashboard }],
},
{
title: 'Buckets',
href: '/buckets',
icon: Database,
label: 'Storage',
items: [{ title: 'Buckets', href: '/buckets', icon: Database }],
},
{
title: 'Cluster',
href: '/cluster',
icon: Server,
},
{
title: 'Access Control',
href: '/access',
icon: Key,
label: 'Cluster',
items: [
{ title: 'Cluster', href: '/cluster', icon: Server },
{ title: 'Access Control', href: '/access', icon: Key },
],
},
];
@@ -42,11 +40,7 @@ interface SidebarProps {
export function Sidebar({ isOpen, onClose }: SidebarProps) {
const location = useLocation();
const { user, config, logout } = useAuthStore();
const handleLogout = () => {
logout();
};
const { config } = useAuthStore();
const { data: uiVersion } = useQuery({
queryKey: ['ui-version'],
@@ -60,80 +54,80 @@ export function Sidebar({ isOpen, onClose }: SidebarProps) {
queryFn: () => garageApi.getNodeInfo('self'),
staleTime: 5 * 60 * 1000,
retry: false,
enabled: !!(config && (config.admin.enabled || config.oidc.enabled)),
});
const garageVersion = nodeInfo
? Object.values(nodeInfo.success)[0]?.garageVersion
: undefined;
const garageVersion = nodeInfo ? Object.values(nodeInfo.success)[0]?.garageVersion : undefined;
const isActive = (href: string) =>
href === '/'
? location.pathname === '/'
: location.pathname === href || location.pathname.startsWith(href + '/');
return (
<div
<aside
className={cn(
'flex h-full w-64 flex-col border-r transition-transform duration-300 ease-in-out md:translate-x-0',
'flex h-full w-64 flex-col border-r border-[var(--border)] bg-[var(--background)] transition-transform duration-300 ease-in-out md:translate-x-0',
'fixed md:static z-50',
isOpen ? 'translate-x-0' : '-translate-x-full'
isOpen ? 'translate-x-0' : '-translate-x-full',
)}
style={{ backgroundColor: 'var(--background)' }}
>
<div className="flex h-16 items-center border-b px-6">
<img src="/garage.png" alt="Garage UI Logo" className="h-8 w-8 mr-2" />
<span className="text-lg font-semibold">Garage UI</span>
<div className="flex h-16 items-center gap-2 border-b border-[var(--border)] px-4">
<img src="/garage.png" alt="" className="h-8 w-8" />
<span className="text-[18px] font-semibold tracking-tight">Garage UI</span>
</div>
<nav className="flex-1 space-y-1 p-4">
{navItems.map((item) => {
const Icon = item.icon;
const isActive = location.pathname === item.href;
return (
<Link
key={item.href}
to={item.href}
onClick={onClose}
className={cn(
'flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
isActive
? 'bg-primary shadow-sm'
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
)}
style={isActive ? { backgroundColor: 'var(--primary)', color: '#000000' } : undefined}
>
<Icon className="h-5 w-5" />
{item.title}
</Link>
);
})}
</nav>
{config && (config.admin.enabled || config.oidc.enabled) && user && (
<div className="border-t p-4 space-y-2">
<div className="flex items-center gap-3 rounded-lg bg-muted px-3 py-2">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary text-primary-foreground text-xs font-semibold">
<User className="h-4 w-4" />
</div>
<div className="flex-1 overflow-hidden">
<p className="text-sm font-medium truncate">{user.name || user.username}</p>
{user.email && (
<p className="text-xs text-muted-foreground truncate">{user.email}</p>
)}
</div>
<nav className="flex-1 overflow-y-auto px-3 py-4 space-y-5 scrollbar-thin">
{navGroups.map((group, gi) => (
<div key={gi}>
{group.label && (
<div className="px-2 pb-1.5 text-[11px] font-medium uppercase tracking-[0.08em] text-[var(--muted-foreground)]">
{group.label}
</div>
)}
<ul className="space-y-0.5">
{group.items.map((item) => {
const Icon = item.icon;
const active = isActive(item.href);
return (
<li key={item.href}>
<Link
to={item.href}
onClick={onClose}
className={cn(
'flex h-9 items-center gap-2 rounded-md px-2.5 text-[14px] transition-colors',
active
? 'bg-[var(--primary)] font-medium text-[var(--primary-foreground)]'
: 'text-[var(--muted-foreground)] hover:bg-[var(--accent)] hover:text-[var(--foreground)]',
)}
>
<Icon className="h-4 w-4" />
{item.title}
</Link>
</li>
);
})}
</ul>
</div>
<Button
variant="outline"
size="sm"
className="w-full justify-start"
onClick={handleLogout}
>
<LogOut className="mr-2 h-4 w-4" />
Logout
</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>
))}
</nav>
<div className="px-3 py-3 flex flex-col items-center gap-1.5">
<a
href="https://garagehq.deuxfleurs.fr/documentation/"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-[12.5px] text-[var(--muted-foreground)] transition-colors hover:bg-[var(--accent)] hover:text-[var(--foreground)]"
>
<BookOpen className="h-3.5 w-3.5" />
Documentation
</a>
{(uiVersion || garageVersion) && (
<div className="flex items-center gap-1.5 border-t border-[var(--border)] pt-2 w-full justify-center text-[12px] text-[var(--muted-foreground)]">
{uiVersion && <span>UI {uiVersion}</span>}
{uiVersion && garageVersion && <span className="opacity-40"></span>}
{garageVersion && <span>Garage {garageVersion}</span>}
</div>
)}
</div>
</aside>
);
}
@@ -14,7 +14,7 @@ export function ThemeToggle() {
return (
<DropdownMenu>
<DropdownMenuTrigger>
<Button variant="outline" size="icon">
<Button variant="secondary" size="icon">
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
@@ -0,0 +1,97 @@
import * as React from 'react';
import { User, LogOut, Monitor, Moon, Sun } from 'lucide-react';
import { Breadcrumb, type BreadcrumbItem } from '@/components/ui/breadcrumb';
import { useTheme } from '@/components/theme-provider';
import { useAuthStore } from '@/store/auth-store';
import { cn } from '@/lib/utils';
interface TopBarProps {
crumbs: BreadcrumbItem[];
}
export function TopBar({ crumbs }: TopBarProps) {
const { theme, setTheme } = useTheme();
const { user, config, logout } = useAuthStore();
const [menuOpen, setMenuOpen] = React.useState(false);
const menuRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
if (!menuOpen) return;
const handler = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) setMenuOpen(false);
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [menuOpen]);
const hasUser = !!(config && (config.admin.enabled || config.oidc.enabled) && user);
return (
<div
className="sticky top-0 z-30 flex h-14 items-center gap-3 border-b border-[var(--border)] bg-[var(--surface-sunken)] px-4 backdrop-blur"
>
<div className="min-w-0 flex-1 pl-8 md:pl-0">
<Breadcrumb items={crumbs} />
</div>
<div className="flex items-center gap-1">
<ThemeMiniToggle theme={theme} setTheme={setTheme} />
{hasUser && (
<div ref={menuRef} className="relative">
<button
type="button"
onClick={() => setMenuOpen((o) => !o)}
className="flex h-8 items-center gap-2 rounded-md px-2 text-[13.5px] text-[var(--foreground)] hover:bg-[var(--accent)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)]"
>
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-[var(--primary)] text-[var(--primary-foreground)]">
<User className="h-3.5 w-3.5" />
</span>
<span className="hidden max-w-[140px] truncate sm:inline">{user?.name || user?.username}</span>
</button>
{menuOpen && (
<div className="absolute right-0 mt-1 w-56 overflow-hidden rounded-md border border-[var(--border)] bg-[var(--popover)] shadow-lg">
<div className="border-b border-[var(--border)] px-3 py-2">
<div className="truncate text-[14px] font-medium">{user?.name || user?.username}</div>
{user?.email && (
<div className="truncate text-[12.5px] text-[var(--muted-foreground)]">{user.email}</div>
)}
</div>
<button
type="button"
onClick={() => { setMenuOpen(false); logout(); }}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-[14px] hover:bg-[var(--accent)]"
>
<LogOut className="h-3.5 w-3.5" /> Logout
</button>
</div>
)}
</div>
)}
</div>
</div>
);
}
function ThemeMiniToggle({
theme,
setTheme,
}: {
theme: 'light' | 'dark' | 'system';
setTheme: (t: 'light' | 'dark' | 'system') => void;
}) {
const next = theme === 'dark' ? 'light' : theme === 'light' ? 'system' : 'dark';
const Icon = theme === 'dark' ? Moon : theme === 'light' ? Sun : Monitor;
return (
<button
type="button"
onClick={() => setTheme(next)}
aria-label={`Switch theme (current: ${theme})`}
className={cn(
'inline-flex h-8 w-8 items-center justify-center rounded-md text-[var(--muted-foreground)]',
'hover:bg-[var(--accent)] hover:text-[var(--foreground)]',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)]',
)}
>
<Icon className="h-4 w-4" />
</button>
);
}
+14 -12
View File
@@ -3,21 +3,23 @@ import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const badgeVariants = cva(
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
'inline-flex items-center rounded-md border px-2 py-0.5 text-[12px] font-medium tracking-tight',
{
variants: {
variant: {
default: 'border-transparent bg-primary text-primary-foreground hover:bg-primary',
secondary:
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary',
destructive:
'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive',
outline: 'text-foreground',
neutral:
'bg-[var(--card)] border-[var(--border)] text-[var(--muted-foreground)]',
success:
'bg-[var(--success-soft)] border-transparent text-[color:#2ca02c] dark:text-[color:#73bf69]',
warning:
'bg-[var(--accent-primary-soft)] border-[var(--accent-primary-border)] text-[var(--primary)]',
danger:
'bg-[var(--danger-soft)] border-[var(--danger-border)] text-[var(--destructive)]',
primary:
'bg-[var(--primary)] border-transparent text-[var(--primary-foreground)]',
},
},
defaultVariants: {
variant: 'default',
},
defaultVariants: { variant: 'neutral' },
}
);
@@ -25,8 +27,8 @@ export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
export function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
}
export { Badge, badgeVariants };
export { badgeVariants };
+44
View File
@@ -0,0 +1,44 @@
import * as React from 'react';
import { Link } from 'react-router-dom';
import { ChevronRight } from 'lucide-react';
import { cn } from '@/lib/utils';
export interface BreadcrumbItem {
label: string;
to?: string;
}
interface BreadcrumbProps extends React.HTMLAttributes<HTMLElement> {
items: BreadcrumbItem[];
}
export function Breadcrumb({ items, className, ...props }: BreadcrumbProps) {
return (
<nav
aria-label="Breadcrumb"
className={cn('flex items-center gap-1.5 text-[13.5px] text-[var(--muted-foreground)]', className)}
{...props}
>
{items.map((item, idx) => {
const isLast = idx === items.length - 1;
return (
<React.Fragment key={`${item.label}-${idx}`}>
{item.to && !isLast ? (
<Link
to={item.to}
className="rounded-sm px-1 text-[var(--foreground)]/80 hover:text-[var(--foreground)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)]"
>
{item.label}
</Link>
) : (
<span className={cn('px-1', isLast && 'font-medium text-[var(--foreground)]')}>
{item.label}
</span>
)}
{!isLast && <ChevronRight className="h-3.5 w-3.5 text-[var(--muted-foreground)]/60" />}
</React.Fragment>
);
})}
</nav>
);
}
+28 -19
View File
@@ -3,28 +3,39 @@ import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none',
[
'inline-flex items-center justify-center gap-2 whitespace-nowrap',
'rounded-md text-[14px] font-medium tracking-tight',
'transition-colors ring-offset-background',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:ring-offset-2',
'disabled:pointer-events-none disabled:opacity-50',
'[&_svg]:h-3.5 [&_svg]:w-3.5 [&_svg]:shrink-0',
].join(' '),
{
variants: {
variant: {
default: 'bg-[#ff9329] text-black hover:bg-[#e58625] cursor-pointer',
default_disabled: 'bg-[#ff9329] text-black opacity-50 cursor-not-allowed',
secondary: 'border border-[#ff9329] text-[#ff9329] cursor-pointer',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive cursor-pointer',
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground cursor-pointer',
outline_disabled: 'border border-input bg-background text-muted-foreground opacity-50 cursor-not-allowed',
ghost: 'hover:bg-accent hover:text-accent-foreground cursor-pointer',
link: 'text-primary underline-offset-4 hover:underline cursor-pointer',
primary:
'bg-[var(--primary)] text-[var(--primary-foreground)] font-semibold hover:brightness-[1.04] cursor-pointer',
secondary:
'bg-transparent border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--accent)] cursor-pointer',
ghost:
'bg-transparent text-[var(--muted-foreground)] hover:bg-[var(--accent)] hover:text-[var(--foreground)] cursor-pointer',
destructive:
'bg-[var(--destructive)] text-[var(--destructive-foreground)] font-semibold hover:brightness-[1.05] cursor-pointer',
link:
'text-[var(--primary)] underline-offset-4 hover:underline cursor-pointer',
},
size: {
default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3',
lg: 'h-11 rounded-md px-8',
icon: 'h-10 w-10',
sm: 'h-8 px-3',
default: 'h-[38px] px-4',
lg: 'h-11 px-5',
'icon-sm': 'h-8 w-8 p-0',
icon: 'h-[38px] w-[38px] p-0',
'icon-lg': 'h-11 w-11 p-0',
},
},
defaultVariants: {
variant: 'default',
variant: 'primary',
size: 'default',
},
}
@@ -35,11 +46,9 @@ export interface ButtonProps
VariantProps<typeof buttonVariants> {}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, ...props }, ref) => {
return (
<button className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
);
}
({ className, variant, size, ...props }, ref) => (
<button className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
)
);
Button.displayName = 'Button';
@@ -0,0 +1,69 @@
import * as React from 'react';
import { Trash2, AlertTriangle } from 'lucide-react';
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from './dialog';
import { IconTile } from './icon-tile';
import { Button } from './button';
interface ConfirmDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
description?: React.ReactNode;
confirmLabel?: string;
cancelLabel?: string;
icon?: React.ReactNode;
tone?: 'destructive' | 'primary';
loading?: boolean;
onConfirm: () => void | Promise<void>;
}
export function ConfirmDialog({
open,
onOpenChange,
title,
description,
confirmLabel = 'Delete',
cancelLabel = 'Cancel',
icon,
tone = 'destructive',
loading = false,
onConfirm,
}: ConfirmDialogProps) {
const defaultIcon = tone === 'destructive' ? <Trash2 /> : <AlertTriangle />;
return (
<Dialog open={open} onOpenChange={onOpenChange} size="destructive">
<DialogContent>
<DialogHeader>
<IconTile icon={icon ?? defaultIcon} tone={tone} size="md" />
<div className="flex-1">
<DialogTitle>{title}</DialogTitle>
{description && <DialogDescription>{description}</DialogDescription>}
</div>
</DialogHeader>
<DialogBody>
<p className="text-[13.5px] text-[var(--muted-foreground)]">This action cannot be undone.</p>
</DialogBody>
<DialogFooter>
<Button variant="secondary" onClick={() => onOpenChange(false)} disabled={loading}>
{cancelLabel}
</Button>
<Button
variant={tone === 'destructive' ? 'destructive' : 'primary'}
onClick={() => onConfirm()}
disabled={loading}
>
{loading ? 'Working…' : confirmLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,91 @@
import * as React from 'react';
import { Trash2 } from 'lucide-react';
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from './dialog';
import { IconTile } from './icon-tile';
import { Button } from './button';
import { Input } from './input';
interface DangerousConfirmDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
description?: React.ReactNode;
/** Exact string the user must type to enable the confirm button. */
confirmationText: string;
confirmLabel?: string;
cancelLabel?: string;
icon?: React.ReactNode;
loading?: boolean;
onConfirm: () => void | Promise<void>;
}
export function DangerousConfirmDialog({
open,
onOpenChange,
title,
description,
confirmationText,
confirmLabel = 'Delete',
cancelLabel = 'Cancel',
icon,
loading = false,
onConfirm,
}: DangerousConfirmDialogProps) {
const [value, setValue] = React.useState('');
React.useEffect(() => { if (!open) setValue(''); }, [open]);
const matches = value === confirmationText;
const submit = () => { if (matches && !loading) onConfirm(); };
return (
<Dialog open={open} onOpenChange={onOpenChange} size="destructive">
<DialogContent>
<DialogHeader>
<IconTile icon={icon ?? <Trash2 />} tone="destructive" size="md" />
<div className="flex-1">
<DialogTitle>{title}</DialogTitle>
{description && <DialogDescription>{description}</DialogDescription>}
</div>
</DialogHeader>
<DialogBody className="space-y-3">
<p className="text-[13.5px] text-[var(--muted-foreground)]">
This action cannot be undone. To confirm, type{' '}
<code className="rounded bg-[var(--surface-sunken)] px-1 py-0.5 font-mono text-[13px] text-[var(--foreground)]">
{confirmationText}
</code>{' '}
below.
</p>
<Input
autoFocus
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && submit()}
placeholder={confirmationText}
aria-label={`Type ${confirmationText} to confirm`}
/>
</DialogBody>
<DialogFooter>
<Button variant="secondary" onClick={() => onOpenChange(false)} disabled={loading}>
{cancelLabel}
</Button>
<Button
variant="destructive"
onClick={submit}
disabled={!matches || loading}
>
{loading ? 'Working…' : confirmLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+132 -79
View File
@@ -1,149 +1,201 @@
import * as React from 'react';
import { createPortal } from 'react-dom';
import { X } from 'lucide-react';
import { cn } from '@/lib/utils';
type DialogSize = 'standard' | 'form' | 'destructive';
interface DialogContextValue {
open: boolean;
onOpenChange: (open: boolean) => void;
size: DialogSize;
}
const DialogContext = React.createContext<DialogContextValue | undefined>(undefined);
function useDialog() {
const context = React.useContext(DialogContext);
if (!context) {
throw new Error('useDialog must be used within a Dialog');
}
return context;
const ctx = React.useContext(DialogContext);
if (!ctx) throw new Error('useDialog must be used within a Dialog');
return ctx;
}
interface DialogProps {
open?: boolean;
onOpenChange?: (open: boolean) => void;
size?: DialogSize;
children: React.ReactNode;
}
const Dialog: React.FC<DialogProps> = ({ open = false, onOpenChange, children }) => {
return (
<DialogContext.Provider value={{ open, onOpenChange: onOpenChange || (() => {}) }}>
{children}
</DialogContext.Provider>
);
};
const Dialog: React.FC<DialogProps> = ({ open = false, onOpenChange, size = 'standard', children }) => (
<DialogContext.Provider value={{ open, onOpenChange: onOpenChange || (() => {}), size }}>
{children}
</DialogContext.Provider>
);
const DialogTrigger = React.forwardRef<
HTMLButtonElement,
React.ButtonHTMLAttributes<HTMLButtonElement>
>(({ onClick, ...props }, ref) => {
const { onOpenChange } = useDialog();
return (
<button
ref={ref}
onClick={(e) => {
onOpenChange(true);
onClick?.(e);
}}
{...props}
/>
);
});
DialogTrigger.displayName = 'DialogTrigger';
const DialogPortal: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { open } = useDialog();
if (!open) return null;
return <>{children}</>;
};
const DialogOverlay = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => {
const DialogTrigger = React.forwardRef<HTMLButtonElement, React.ButtonHTMLAttributes<HTMLButtonElement>>(
({ onClick, ...props }, ref) => {
const { onOpenChange } = useDialog();
return (
<div
<button
ref={ref}
className={cn(
'fixed inset-0 z-50 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className
)}
onClick={() => onOpenChange(false)}
onClick={(e) => { onOpenChange(true); onClick?.(e); }}
{...props}
/>
);
}
);
DialogOverlay.displayName = 'DialogOverlay';
DialogTrigger.displayName = 'DialogTrigger';
const widthClass: Record<DialogSize, string> = {
standard: 'max-w-[480px]',
form: 'max-w-[600px]',
destructive: 'max-w-[440px]',
};
const DialogOverlay: React.FC = () => {
const { onOpenChange } = useDialog();
return (
<div
className="fixed inset-0 z-50 bg-black/55 backdrop-blur-[8px]"
onClick={() => onOpenChange(false)}
/>
);
};
const DialogContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, children, ...props }, ref) => {
const { onOpenChange } = useDialog();
return (
<DialogPortal>
const { open, onOpenChange, size } = useDialog();
const containerRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
if (!open) return;
const previouslyFocused = document.activeElement as HTMLElement | null;
const keyHandler = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.stopPropagation();
onOpenChange(false);
return;
}
if (e.key !== 'Tab' || !containerRef.current) return;
const focusables = containerRef.current.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'
);
if (focusables.length === 0) return;
const first = focusables[0];
const last = focusables[focusables.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
};
document.addEventListener('keydown', keyHandler);
setTimeout(() => {
const first = containerRef.current?.querySelector<HTMLElement>(
'input:not([disabled]), button:not([disabled]), [tabindex]:not([tabindex="-1"])'
);
first?.focus();
}, 0);
return () => {
document.removeEventListener('keydown', keyHandler);
previouslyFocused?.focus?.();
};
}, [open, onOpenChange]);
if (!open) return null;
return createPortal(
<>
<DialogOverlay />
<div className="fixed left-[50%] top-[50%] z-50 translate-x-[-50%] translate-y-[-50%] w-[calc(100%-2rem)] sm:w-full max-w-lg">
<div
ref={containerRef}
role="dialog"
aria-modal="true"
className={cn(
'fixed left-1/2 top-1/2 z-50 w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2',
widthClass[size],
)}
>
<div
ref={ref}
style={{ backgroundColor: 'var(--background)' }}
className={cn(
'relative p-4 sm:p-6 shadow-lg duration-200 rounded-lg border',
'data-[state=open]:animate-in data-[state=closed]:animate-out',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
'data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%]',
'data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]',
className
'relative overflow-hidden rounded-xl border border-[var(--border)]',
'bg-[var(--card)] text-[var(--card-foreground)]',
'shadow-[0_20px_40px_rgba(0,0,0,0.3)]',
className,
)}
{...props}
>
{children}
<button
type="button"
onClick={() => onOpenChange(false)}
className="absolute right-4 top-4 rounded-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 cursor-pointer"
aria-label="Close"
className="absolute right-3 top-3 inline-flex h-7 w-7 items-center justify-center rounded-md text-[var(--muted-foreground)] hover:bg-[var(--accent)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)]"
>
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</button>
</div>
</div>
</DialogPortal>
</>,
document.body,
);
}
);
DialogContent.displayName = 'DialogContent';
const DialogHeader: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({
className,
...props
}) => <div className={cn('flex flex-col space-y-1.5 text-center sm:text-left bg-background', className)} {...props} />;
const DialogHeader: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({ className, ...props }) => (
<div
className={cn(
'flex items-start gap-3 border-b border-[var(--border)] px-6 py-5',
className,
)}
{...props}
/>
);
DialogHeader.displayName = 'DialogHeader';
const DialogFooter: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({
className,
...props
}) => (
const DialogBody: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({ className, ...props }) => (
<div className={cn('px-6 py-5', className)} {...props} />
);
DialogBody.displayName = 'DialogBody';
const DialogFooter: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({ className, ...props }) => (
<div
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end space-y-2 space-y-reverse sm:space-y-0 sm:space-x-2', className)}
className={cn(
'flex justify-end gap-2 border-t border-[var(--border)] bg-[var(--surface-sunken)] px-6 py-3.5',
className,
)}
{...props}
/>
);
DialogFooter.displayName = 'DialogFooter';
const DialogTitle = React.forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement>>(
const DialogTitleText = React.forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h2
ref={ref}
className={cn('text-lg font-semibold leading-none tracking-tight', className)}
className={cn('text-[20px] font-semibold tracking-[-0.015em] leading-tight', className)}
{...props}
/>
)
);
DialogTitle.displayName = 'DialogTitle';
DialogTitleText.displayName = 'DialogTitle';
const DialogDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p ref={ref} className={cn('text-xs text-muted-foreground', className)} {...props} />
));
const DialogDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
({ className, ...props }, ref) => (
<p
ref={ref}
className={cn('mt-1 text-[13.5px] leading-[1.45] text-[var(--muted-foreground)]', className)}
{...props}
/>
)
);
DialogDescription.displayName = 'DialogDescription';
export {
@@ -151,7 +203,8 @@ export {
DialogTrigger,
DialogContent,
DialogHeader,
DialogBody,
DialogFooter,
DialogTitle,
DialogTitleText as DialogTitle,
DialogDescription,
};
@@ -0,0 +1,32 @@
import * as React from 'react';
import { IconTile } from './icon-tile';
import { cn } from '@/lib/utils';
interface EmptyStateProps extends React.HTMLAttributes<HTMLDivElement> {
icon: React.ReactNode;
title: string;
description?: string;
action?: React.ReactNode;
tone?: 'primary' | 'neutral' | 'destructive';
}
export function EmptyState({ icon, title, description, action, tone = 'primary', className, ...props }: EmptyStateProps) {
return (
<div
className={cn(
'flex flex-col items-center justify-center gap-3 rounded-xl border border-[var(--border)] bg-[var(--card)] px-6 py-12 text-center',
className,
)}
{...props}
>
<IconTile icon={icon} tone={tone} size="lg" />
<div className="space-y-1">
<h3 className="text-[17px] font-semibold tracking-tight">{title}</h3>
{description && (
<p className="max-w-sm text-[13.5px] text-[var(--muted-foreground)]">{description}</p>
)}
</div>
{action && <div className="pt-1">{action}</div>}
</div>
);
}
+36
View File
@@ -0,0 +1,36 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
import { cva, type VariantProps } from 'class-variance-authority';
const iconTileVariants = cva(
'inline-flex items-center justify-center shrink-0 border',
{
variants: {
tone: {
primary: 'bg-[var(--accent-primary-soft)] border-[var(--accent-primary-border)] text-[var(--primary)]',
destructive: 'bg-[var(--danger-soft)] border-[var(--danger-border)] text-[var(--destructive)]',
neutral: 'bg-muted border-border text-muted-foreground',
},
size: {
sm: 'h-8 w-8 rounded-md [&>svg]:h-4 [&>svg]:w-4',
md: 'h-10 w-10 rounded-lg [&>svg]:h-5 [&>svg]:w-5',
lg: 'h-14 w-14 rounded-xl [&>svg]:h-7 [&>svg]:w-7',
},
},
defaultVariants: { tone: 'primary', size: 'md' },
}
);
export interface IconTileProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof iconTileVariants> {
icon: React.ReactNode;
}
export function IconTile({ icon, tone, size, className, ...props }: IconTileProps) {
return (
<div className={cn(iconTileVariants({ tone, size }), className)} {...props}>
{icon}
</div>
);
}
+16 -14
View File
@@ -1,22 +1,24 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>;
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed',
className
)}
ref={ref}
{...props}
/>
);
}
({ className, type, ...props }, ref) => (
<input
type={type}
ref={ref}
className={cn(
'flex h-[38px] w-full rounded-md border border-[var(--input)] bg-[var(--background)] px-3 py-1 text-[15px]',
'file:border-0 file:bg-transparent file:text-sm file:font-medium',
'placeholder:text-[var(--muted-foreground)]',
'focus-visible:outline-none focus-visible:border-[var(--primary)] focus-visible:ring-2 focus-visible:ring-[var(--ring)]',
'disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
/>
)
);
Input.displayName = 'Input';
@@ -0,0 +1,28 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
interface PageHeaderProps extends React.HTMLAttributes<HTMLDivElement> {
title: string;
subtitle?: React.ReactNode;
actions?: React.ReactNode;
}
export function PageHeader({ title, subtitle, actions, className, ...props }: PageHeaderProps) {
return (
<div
className={cn(
'flex flex-col gap-4 border-b border-[var(--border)] px-6 py-5 sm:flex-row sm:items-start sm:justify-between sm:gap-6',
className,
)}
{...props}
>
<div className="min-w-0">
<h1 className="text-[26px] font-semibold tracking-[-0.02em] leading-tight truncate">{title}</h1>
{subtitle && (
<div className="mt-1 text-[13.5px] text-[var(--muted-foreground)]">{subtitle}</div>
)}
</div>
{actions && <div className="flex shrink-0 items-center gap-2">{actions}</div>}
</div>
);
}
+6 -5
View File
@@ -53,7 +53,7 @@ const TabsList = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivEl
<div
ref={ref}
className={cn(
'inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground',
'flex h-12 items-center gap-0 border-b border-[var(--border)] text-[14px]',
className
)}
{...props}
@@ -70,15 +70,16 @@ const TabsTrigger = React.forwardRef<HTMLButtonElement, TabsTriggerProps>(
({ className, value: triggerValue, onClick, ...props }, ref) => {
const { value, onValueChange } = useTabs();
const isActive = value === triggerValue;
return (
<button
ref={ref}
className={cn(
'inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none',
'relative h-12 px-3.5 -mb-px inline-flex items-center justify-center',
'text-[14px] font-medium transition-colors cursor-pointer',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)] rounded-sm',
isActive
? 'bg-background text-foreground shadow-sm'
: 'hover:bg-accent hover:text-accent-foreground',
? 'text-[var(--primary)] border-b-2 border-[var(--primary)]'
: 'text-[var(--muted-foreground)] border-b-2 border-transparent hover:text-[var(--foreground)]',
className
)}
onClick={(e) => {
+8 -8
View File
@@ -90,10 +90,7 @@ export function useBucketObjects(bucketName: string | null, currentPath: string
setUploadTasks(tasks);
let successCount = 0;
let errorCount = 0;
for (const task of tasks) {
const results = await Promise.all(tasks.map(async (task) => {
try {
setUploadTasks(prev => prev.map(t =>
t.id === task.id ? { ...t, status: 'uploading' as const } : t
@@ -109,16 +106,19 @@ export function useBucketObjects(bucketName: string | null, currentPath: string
setUploadTasks(prev => prev.map(t =>
t.id === task.id ? { ...t, status: 'completed' as const, progress: 100 } : t
));
successCount++;
return true;
} 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);
return false;
}
}
}));
const successCount = results.filter(Boolean).length;
const errorCount = results.length - successCount;
if (errorCount === 0) {
if (hasRelativePaths && folders.size > 0) {
@@ -182,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([], '.keep'));
await objectsApi.createDirectory(bucketName, dirKey);
toast.success(`Directory "${dirName}" created successfully`);
await fetchObjects(currentContinuationToken, true);
return true;
+81 -22
View File
@@ -1,30 +1,71 @@
@import 'tailwindcss';
@font-face {
font-family: 'Geist Sans';
src: url('/fonts/geist-sans-400.woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Geist Sans';
src: url('/fonts/geist-sans-500.woff2') format('woff2');
font-weight: 500;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Geist Sans';
src: url('/fonts/geist-sans-600.woff2') format('woff2');
font-weight: 600;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Geist Mono';
src: url('/fonts/geist-mono-400.woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Geist Mono';
src: url('/fonts/geist-mono-500.woff2') format('woff2');
font-weight: 500;
font-style: normal;
font-display: swap;
}
@layer base {
:root {
/* Light Theme Colors */
--background: #ffffff;
--foreground: #0a0a0f;
--card: #ffffff;
--card-foreground: #0a0a0f;
--popover: #fafafa;
--popover-foreground: #0a0a0f;
--background: #faf7f2;
--foreground: #1c1917;
--card: #fffdf9;
--card-foreground: #1c1917;
--popover: #fffdf9;
--popover-foreground: #1c1917;
--primary: #ff9447;
--primary-foreground: #ffffff;
--secondary: #f4f4f5;
--secondary-foreground: #18181b;
--muted: #f4f4f5;
--muted-foreground: #71717a;
--accent: #f4f4f5;
--accent-foreground: #18181b;
--secondary: #f3efe8;
--secondary-foreground: #1c1917;
--muted: #f3efe8;
--muted-foreground: #78716c;
--accent: #f3efe8;
--accent-foreground: #1c1917;
--destructive: #ef4444;
--destructive-foreground: #fafafa;
--border: #e4e4e7;
--input: #e4e4e7;
--destructive-foreground: #ffffff;
--border: #e7e2d8;
--input: #e7e2d8;
--ring: #ff9447;
--radius: 0.5rem;
/* Grafana Chart Colors */
--accent-primary-soft: rgba(255, 148, 71, 0.10);
--accent-primary-border: rgba(255, 148, 71, 0.22);
--danger-soft: rgba(239, 68, 68, 0.10);
--danger-border: rgba(239, 68, 68, 0.28);
--success-soft: rgba(34, 197, 94, 0.10);
--surface-sunken: rgba(120, 90, 40, 0.05);
--chart-blue: #1f77b4;
--chart-orange: #ff7f0e;
--chart-green: #2ca02c;
@@ -38,7 +79,6 @@
}
.dark {
/* Dark Theme Colors - VS Code Inspired */
--background: #1a1d29;
--foreground: #e8eaed;
--card: #252834;
@@ -46,7 +86,7 @@
--popover: #2d3142;
--popover-foreground: #e8eaed;
--primary: #ff9447;
--primary-foreground: #ffffff;
--primary-foreground: #1a1d29;
--secondary: #3a3f52;
--secondary-foreground: #e8eaed;
--muted: #4a5064;
@@ -54,12 +94,18 @@
--accent: #3a3f52;
--accent-foreground: #e8eaed;
--destructive: #ef5350;
--destructive-foreground: #e8eaed;
--destructive-foreground: #1a1d29;
--border: #3a3f52;
--input: #3a3f52;
--ring: #ff9447;
/* Grafana Chart Colors */
--accent-primary-soft: rgba(255, 148, 71, 0.12);
--accent-primary-border: rgba(255, 148, 71, 0.22);
--danger-soft: rgba(239, 83, 80, 0.14);
--danger-border: rgba(239, 83, 80, 0.32);
--success-soft: rgba(115, 191, 105, 0.10);
--surface-sunken: rgba(0, 0, 0, 0.18);
--chart-blue: #3eb0ff;
--chart-orange: #ff9830;
--chart-green: #73bf69;
@@ -76,10 +122,23 @@
border-color: var(--border);
}
html {
font-family: 'Geist Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
font-size: 15px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
body {
background-color: var(--background);
color: var(--foreground);
font-feature-settings: 'rlig' 1, 'calt' 1;
font-feature-settings: 'rlig' 1, 'calt' 1, 'ss01' 1;
}
code, pre, kbd, samp, .font-mono {
font-family: 'Geist Mono', ui-monospace, 'SF Mono', 'Menlo', Consolas, monospace;
}
}
+4
View File
@@ -276,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);
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More