Compare commits

...

108 Commits

Author SHA1 Message Date
Noooste b8061e5109 test(handlers): cover upstream error mapping to API response codes 2026-04-20 00:23:55 +02:00
Noooste cfa13be75b refactor(handlers): use apierr.Respond for key/permission upstream errors 2026-04-20 00:21:49 +02:00
Noooste adb430c2b2 refactor(handlers): use apierr.Respond for object upstream errors 2026-04-20 00:20:15 +02:00
Noooste 849840b808 refactor(handlers): use apierr.Respond for bucket upstream errors 2026-04-20 00:18:15 +02:00
Noooste 23d63be980 refactor(services): return *UpstreamError from S3 operations via FromMinio 2026-04-20 00:16:13 +02:00
Noooste 5b12c8121f refactor(services): propagate UpstreamError unchanged from admin callers
Strip redundant fmt.Errorf wrapping at every decodeResponse call site so
the typed *apierr.UpstreamError propagates directly to handlers without
an extra indirection layer.
2026-04-20 00:12:48 +02:00
Noooste 785d21d38c feat(ci): update Dockerfile and CI configuration for backend image and caching
Signed-off-by: Noooste <83548733+Noooste@users.noreply.github.com>
2026-04-20 00:12:07 +02:00
Noooste c2e99afb8b refactor(services): return *UpstreamError from decodeResponse
Replace the ad-hoc fmt.Errorf non-2xx branch with apierr.ParseGarage so
decodeResponse now returns a typed *apierr.UpstreamError on error.
Update two admin_test.go tests that string-matched the old error shape to
assert UpstreamError fields (HTTPStatus, Message) instead.
2026-04-20 00:09:01 +02:00
Noooste 691ed8db4d feat(apierr): map upstream codes to API status/code and add Respond helper 2026-04-20 00:05:59 +02:00
Noooste caf8d9fd2e feat(apierr): convert MinIO errors to UpstreamError via FromMinio 2026-04-20 00:02:53 +02:00
Noooste f2cc660c8f feat(apierr): parse Garage structured error responses into UpstreamError 2026-04-20 00:01:31 +02:00
Noooste 5c2e5dbf6f feat(apierr): add UpstreamError type for typed upstream failures 2026-04-19 23:59:54 +02:00
Noooste adfd044798 feat(api): add BUCKET_NOT_EMPTY/KEY_NOT_FOUND/INVALID_REQUEST error codes 2026-04-19 23:58:34 +02:00
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
Noooste 047a653446 test: add comprehensive test suite for cache, config, logger, and retry functionalities
Signed-off-by: Noooste <83548733+Noooste@users.noreply.github.com>
2026-04-17 17:25:59 +02:00
Noooste f63ce3452e refactor: update handler methods to use interfaces for admin and S3 services
Signed-off-by: Noooste <83548733+Noooste@users.noreply.github.com>
2026-04-17 16:48:25 +02:00
Noooste 5e68a77e15 chore: update .gitignore to include markdown files in docs directory
Signed-off-by: Noooste <83548733+Noooste@users.noreply.github.com>
2026-04-17 16:47:10 +02:00
Noooste 46f904fe59 refactor: optimize state management and filtering in AccessControl and ObjectsTable components
Signed-off-by: Noooste <83548733+Noooste@users.noreply.github.com>
2026-04-17 16:46:55 +02:00
Noooste 0baf31422a chore: update .gitignore to include docs/ and config.yaml
Signed-off-by: Noooste <83548733+Noooste@users.noreply.github.com>
2026-04-17 16:29:02 +02:00
Noooste 98842d7c7e feat: add namespace to metadata in Kubernetes resource files
Signed-off-by: Noooste <83548733+Noooste@users.noreply.github.com>
2026-04-17 16:28:51 +02:00
Noooste bd4c3b1c5f feat: handle zero SessionMaxAge in GenerateSessionToken to prevent immediate expiration
Signed-off-by: Noooste <83548733+Noooste@users.noreply.github.com>
2026-04-17 16:17:51 +02:00
Noooste 68d773d4af feat: add role extraction from access token for Keycloak integration
Signed-off-by: Noooste <83548733+Noooste@users.noreply.github.com>
2026-04-17 16:12:56 +02:00
Noooste 54a5e42db6 chore: bump version to 0.2.2 and update badges in README.md
Signed-off-by: Noooste <83548733+Noooste@users.noreply.github.com>
2026-04-14 00:07:17 +02:00
Noooste 43ba68a4f9 feat: update directory creation to include .keep file for empty directories
Signed-off-by: Noooste <83548733+Noooste@users.noreply.github.com>
2026-04-13 23:42:38 +02:00
Noooste a0458fb5d0 chore: bump version to 0.2.1 and update badges in README.md
Signed-off-by: Noooste <83548733+Noooste@users.noreply.github.com>
2026-04-13 22:23:45 +02:00
Noste 6dc0feb374 Merge pull request #15 from Noooste/14-auth-oidc-configuration
fix: Enforce admin role
2026-04-13 21:58:15 +02:00
Noooste 33d8705431 feat: enforce admin role validation in OIDC authentication flow
Signed-off-by: Noooste <83548733+Noooste@users.noreply.github.com>
2026-04-13 21:56:54 +02:00
Noste e7db44f45d chore: update version badges in README.md to 0.2.0
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2026-03-07 14:41:38 +01:00
Noste 126e138382 chore: bump version to 0.2.0 and update appVersion in Chart.yaml
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2026-03-07 14:25:00 +01:00
Noste 2efb16e248 Merge pull request #11 from Noooste/4-static-website-options
Add static option on the UI
2026-03-07 14:16:57 +01:00
Noste d494c811a0 feat: add website hosting configuration for buckets
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2026-03-07 13:53:52 +01:00
Noste e800ac0ed1 Merge pull request #10 from Noooste/clean-frontend
Clean frontend
2026-03-07 11:20:08 +01:00
Noste 26ae2ef515 Merge pull request #9 from Noooste/8-feature-display-version
Add Garage and UI Version on the dashboard
2026-03-07 11:19:56 +01:00
Noste 4486697ca5 refactor: simplify access key loading in BucketSettingsDialog and enhance last modified tooltip in ObjectsTable
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2026-03-07 11:18:14 +01:00
Noste 94ad142edf feat: add health API to retrieve application version and display in sidebar
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2026-03-07 11:13:33 +01:00
Noste b8b0b6b0fa feat: enhance S3 service to use bucket-specific MinIO client for object retrieval and deletion
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2026-03-07 10:56:48 +01:00
Noste 8c72a7fb75 Merge pull request #6 from mkm29/fix/remediate-vulns
fix(deps): remediate critical and high vulnerabilities
2026-03-06 16:34:30 +01:00
Mitch Murphy 7df74cd7e8 Update dependencies in go.mod and go.sum
- Upgraded github.com/gofiber/fiber/v3 from v3.0.0-rc.3 to v3.1.0
- Upgraded github.com/golang-jwt/jwt/v5 from v5.3.0 to v5.3.1
- Upgraded github.com/minio/minio-go/v7 from v7.0.97 to v7.0.98
- Updated golang.org/x/oauth2 from v0.34.0 to v0.35.0
- Downgraded github.com/cloudflare/circl from v1.6.2 to v1.6.1
- Downgraded github.com/sagikazarmark/locafero from v0.12.0 to v0.11.0
- Updated various indirect dependencies including go-openapi packages and others
2026-03-01 23:31:17 -05:00
Noste 8f81c7ffea refactor: update logging configuration
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2026-01-18 20:36:10 +01:00
Noste f6fe310e3e bump version to 0.1.13 and app version to v0.1.2
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2025-12-26 16:02:04 +01:00
Noste a59caa41da refactor: remove unused bucket policies section from AccessControl and README
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2025-12-26 15:59:17 +01:00
Noste 7d4c7f51e3 bump version to 0.1.12
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2025-12-26 15:44:42 +01:00
Noste 8063ae18a8 feat: enhance Ed25519 private key parsing to support PKCS#8 format
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2025-12-26 15:42:08 +01:00
Noste b8056c6d79 feat: bump version to 0.1.11
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2025-12-26 15:38:08 +01:00
Noste b1b6e02a19 refactor: set jwt secret generation directly in secret.yaml
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2025-12-26 15:37:51 +01:00
Noste 65447b927a feat: refactor JWT key generation to create a Kubernetes secret directly; remove job and RBAC resources
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2025-12-26 15:37:12 +01:00
Noste bde726fedf feat: bump version to 0.1.10; update version badge in README
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2025-12-26 15:31:33 +01:00
Noste 27cca273d5 feat: add JWT key generator job and associated RBAC resources; create Kubernetes secret for JWT private key
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2025-12-26 15:31:12 +01:00
Noste c1a1d64928 feat: bump version to 0.1.9; update JWT key handling in deployment and secret management; enhance README with JWT key management instructions
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2025-12-26 15:28:51 +01:00
Noste 948bd06a3e Create CONTRIBUTING.md for project guidelines
Added a comprehensive CONTRIBUTING.md file to guide contributors on how to participate in the Garage UI project, including sections on reporting bugs, suggesting enhancements, and the code of conduct.
2025-12-26 12:53:12 +01:00
Noste c4f7b0ccbc feat: update README to simplify descriptions and enhance clarity; improve quick start instructions
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2025-12-26 12:43:41 +01:00
Noste f5deb0316b feat: bump version to 0.1.8 and appVersion to 0.1.0; update version badges in README
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2025-12-26 12:30:17 +01:00
Noste 80553c6450 feat: enhance authentication and upload features; add JWT support and upload progress component
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2025-12-26 12:21:49 +01:00
Noste 7b31490488 feat: bump version to 0.1.7 and appVersion to 0.0.11; update version badges in README
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2025-12-22 12:15:45 +01:00
Noste 7419d5a14b feat: update garage image version to 2.1.0 and fix JSON field name for storage nodes
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2025-12-22 12:11:38 +01:00
Noste 4864185183 feat: update README version badge to 0.1.6
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2025-12-22 00:34:53 +01:00
Noste 7aa5aeb4f8 feat: bump Helm chart version to 0.1.6 and update endpoint pattern in values schema
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2025-12-22 00:15:34 +01:00
Noste b4bdd13743 feat: update Helm chart version to 0.1.5 and add values schema for configuration
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2025-12-22 00:10:27 +01:00
Noste 62b76769d0 feat: bump version to 0.1.4 and appVersion to 0.0.10; update authentication secrets in deployment and values
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2025-12-21 23:53:19 +01:00
Noste 31b3aca8f9 feat: enable multi-platform builds in Dockerfile
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2025-12-21 23:48:03 +01:00
Noste 61dae6c605 feat: implement admin and OIDC authentication methods; add login and user info endpoints
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2025-12-21 23:42:02 +01:00
Noste b49c634f17 bump version to 0.1.3 and update appVersion to 0.0.7; refine region comment in config example
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2025-12-21 15:54:06 +01:00
Noste 5910961825 docs: update README
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2025-12-21 15:42:18 +01:00
Noste 7a4e187745 feat: trim protocol from MinIO endpoint and set SSL flag
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2025-12-21 15:29:19 +01:00
Noste ebfdad8898 feat: add region configuration to MinIO client initialization
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2025-12-21 15:24:15 +01:00
Noste c3db42bd76 bump version to 0.1.2 in Chart.yaml
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2025-12-21 14:09:02 +01:00
Noste d36f8915ac feat: add support for using Kubernetes secrets for admin token management
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2025-12-21 14:08:33 +01:00
Noste 6c6a02bcac feat: enhance Docker build process with multi-platform support and caching
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2025-12-20 13:19:08 +01:00
Noste 42261b325b bump version to 0.1.1 and appVersion to v0.0.6 in Chart.yaml
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2025-12-20 13:09:12 +01:00
Noste 09e485572a fix: update Docker image tag patterns in build.yml
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2025-12-20 12:44:23 +01:00
Noste 491e083bb4 bump appVersion to v0.0.5
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2025-12-20 12:28:26 +01:00
Noste 7f636a451c feat: add functionality to create and reveal secret access keys for users
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2025-12-20 12:25:46 +01:00
Noste 4656d1bce9 feat: add functionality to create and reveal secret access keys for users
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2025-12-20 12:24:53 +01:00
164 changed files with 19487 additions and 4043 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 364 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 289 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 344 KiB

+81 -12
View File
@@ -1,4 +1,4 @@
name: Docker Build and Push
name: build
on:
push:
@@ -11,11 +11,22 @@ jobs:
permissions:
contents: read
packages: write
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
arch: amd64
- platform: linux/arm64
arch: arm64
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
@@ -32,21 +43,79 @@ jobs:
with:
images: ${{ secrets.DOCKER_REGISTRY }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=semver,pattern={{raw}}
type=raw,value=latest
- name: Build and push Docker image
- name: Build and push by digest
id: build
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
platforms: linux/amd64,linux/arm64
build-args: VERSION=${{ steps.meta.outputs.version }}
cache-from: |
type=gha,scope=build-${{ matrix.arch }}
type=registry,ref=${{ secrets.DOCKER_REGISTRY }}:buildcache-${{ matrix.arch }}
cache-to: |
type=gha,mode=max,scope=build-${{ matrix.arch }}
type=registry,ref=${{ secrets.DOCKER_REGISTRY }}:buildcache-${{ matrix.arch }},mode=max
outputs: type=image,name=${{ secrets.DOCKER_REGISTRY }},push-by-digest=true,name-canonical=true,push=true
- name: Image digest
run: echo ${{ steps.meta.outputs.digest }}
- name: Export digest
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-${{ strategy.job-index }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
merge:
runs-on: ubuntu-latest
needs: build
permissions:
contents: read
packages: write
steps:
- name: Download digests
uses: actions/download-artifact@v4
with:
path: /tmp/digests
pattern: digests-*
merge-multiple: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Container Registry
uses: docker/login-action@v3
with:
registry: docker.io
username: ${{ secrets.DOCKER_LOGIN }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ secrets.DOCKER_REGISTRY }}
tags: |
type=semver,pattern={{raw}}
type=raw,value=latest
- name: Create manifest list and push
working-directory: /tmp/digests
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '${{ secrets.DOCKER_REGISTRY }}@sha256:%s ' *)
- name: Inspect image
run: |
docker buildx imagetools inspect ${{ secrets.DOCKER_REGISTRY }}:${{ steps.meta.outputs.version }}
+1 -1
View File
@@ -1,4 +1,4 @@
name: Release Charts
name: release
on:
push:
+96
View File
@@ -0,0 +1,96 @@
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@v1.16.4
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: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Pre-build backend image with layer cache
uses: docker/build-push-action@v5
with:
context: .
file: Dockerfile
load: true
tags: garage-ui-smoke-backend:ci
cache-from: type=gha,scope=smoke-backend
cache-to: type=gha,mode=max,scope=smoke-backend
- name: Run smoke test
run: make test-smoke
- 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
+8 -1
View File
@@ -54,9 +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/
backend/docs/
config.yaml
docs/**/*.md
# Superpowers brainstorm / session scratch
.superpowers/
+132
View File
@@ -0,0 +1,132 @@
<!-- omit in toc -->
# Contributing to Garage UI
First off, thanks for taking the time to contribute! ❤️
All types of contributions are encouraged and valued. See the [Table of Contents](#table-of-contents) for different ways to help and details about how this project handles them. Please make sure to read the relevant section before making your contribution. It will make it a lot easier for us maintainers and smooth out the experience for all involved. The community looks forward to your contributions. 🎉
> And if you like the project, but just don't have time to contribute, that's fine. There are other easy ways to support the project and show your appreciation, which we would also be very happy about:
> - Star the project
> - Tweet about it
> - Refer this project in your project's readme
> - Mention the project at local meetups and tell your friends/colleagues
<!-- omit in toc -->
## Table of Contents
- [Code of Conduct](#code-of-conduct)
- [I Have a Question](#i-have-a-question)
- [I Want To Contribute](#i-want-to-contribute)
- [Reporting Bugs](#reporting-bugs)
- [Suggesting Enhancements](#suggesting-enhancements)
- [Your First Code Contribution](#your-first-code-contribution)
- [Improving The Documentation](#improving-the-documentation)
- [Styleguides](#styleguides)
- [Commit Messages](#commit-messages)
- [Join The Project Team](#join-the-project-team)
## Code of Conduct
This project and everyone participating in it is governed by the
[Garage UI Code of Conduct](https://github.com/Noooste/garage-ui/blob//CODE_OF_CONDUCT.md).
By participating, you are expected to uphold this code. Please report unacceptable behavior
to .
## I Have a Question
> If you want to ask a question, we assume that you have read the available [Documentation]().
Before you ask a question, it is best to search for existing [Issues](https://github.com/Noooste/garage-ui/issues) that might help you. In case you have found a suitable issue and still need clarification, you can write your question in this issue. It is also advisable to search the internet for answers first.
If you then still feel the need to ask a question and need clarification, we recommend the following:
- Open an [Issue](https://github.com/Noooste/garage-ui/issues/new).
- Provide as much context as you can about what you're running into.
- Provide project and platform versions (nodejs, npm, etc), depending on what seems relevant.
We will then take care of the issue as soon as possible.
<!--
You might want to create a separate issue tag for questions and include it in this description. People should then tag their issues accordingly.
Depending on how large the project is, you may want to outsource the questioning, e.g. to Stack Overflow or Gitter. You may add additional contact and information possibilities:
- IRC
- Slack
- Gitter
- Stack Overflow tag
- Blog
- FAQ
- Roadmap
- E-Mail List
- Forum
-->
## I Want To Contribute
> ### Legal Notice <!-- omit in toc -->
> When contributing to this project, you must agree that you have authored 100% of the content, that you have the necessary rights to the content and that the content you contribute may be provided under the project licence.
### Reporting Bugs
<!-- omit in toc -->
#### Before Submitting a Bug Report
A good bug report shouldn't leave others needing to chase you up for more information. Therefore, we ask you to investigate carefully, collect information and describe the issue in detail in your report. Please complete the following steps in advance to help us fix any potential bug as fast as possible.
- Make sure that you are using the latest version.
- Determine if your bug is really a bug and not an error on your side e.g. using incompatible environment components/versions (Make sure that you have read the [documentation](). If you are looking for support, you might want to check [this section](#i-have-a-question)).
- To see if other users have experienced (and potentially already solved) the same issue you are having, check if there is not already a bug report existing for your bug or error in the [bug tracker](https://github.com/Noooste/garage-ui/issues?q=label%3Abug).
- Also make sure to search the internet (including Stack Overflow) to see if users outside of the GitHub community have discussed the issue.
- Collect information about the bug:
- Stack trace (Traceback)
- OS, Platform and Version (Windows, Linux, macOS, x86, ARM)
- Version of the interpreter, compiler, SDK, runtime environment, package manager, depending on what seems relevant.
- Possibly your input and the output
- Can you reliably reproduce the issue? And can you also reproduce it with older versions?
<!-- omit in toc -->
#### How Do I Submit a Good Bug Report?
> You must never report security related issues, vulnerabilities or bugs including sensitive information to the issue tracker, or elsewhere in public. Instead sensitive bugs must be sent by email to .
<!-- You may add a PGP key to allow the messages to be sent encrypted as well. -->
We use GitHub issues to track bugs and errors. If you run into an issue with the project:
- Open an [Issue](https://github.com/Noooste/garage-ui/issues/new). (Since we can't be sure at this point whether it is a bug or not, we ask you not to talk about a bug yet and not to label the issue.)
- Explain the behavior you would expect and the actual behavior.
- Please provide as much context as possible and describe the *reproduction steps* that someone else can follow to recreate the issue on their own. This usually includes your code. For good bug reports you should isolate the problem and create a reduced test case.
- Provide the information you collected in the previous section.
Once it's filed:
- The project team will label the issue accordingly.
- A team member will try to reproduce the issue with your provided steps. If there are no reproduction steps or no obvious way to reproduce the issue, the team will ask you for those steps and mark the issue as `needs-repro`. Bugs with the `needs-repro` tag will not be addressed until they are reproduced.
- If the team is able to reproduce the issue, it will be marked `needs-fix`, as well as possibly other tags (such as `critical`), and the issue will be left to be [implemented by someone](#your-first-code-contribution).
<!-- You might want to create an issue template for bugs and errors that can be used as a guide and that defines the structure of the information to be included. If you do so, reference it here in the description. -->
### Suggesting Enhancements
This section guides you through submitting an enhancement suggestion for Garage UI, **including completely new features and minor improvements to existing functionality**. Following these guidelines will help maintainers and the community to understand your suggestion and find related suggestions.
<!-- omit in toc -->
#### Before Submitting an Enhancement
- Make sure that you are using the latest version.
- Read the [documentation]() carefully and find out if the functionality is already covered, maybe by an individual configuration.
- Perform a [search](https://github.com/Noooste/garage-ui/issues) to see if the enhancement has already been suggested. If it has, add a comment to the existing issue instead of opening a new one.
- Find out whether your idea fits with the scope and aims of the project. It's up to you to make a strong case to convince the project's developers of the merits of this feature. Keep in mind that we want features that will be useful to the majority of our users and not just a small subset. If you're just targeting a minority of users, consider writing an add-on/plugin library.
<!-- omit in toc -->
#### How Do I Submit a Good Enhancement Suggestion?
Enhancement suggestions are tracked as [GitHub issues](https://github.com/Noooste/garage-ui/issues).
- Use a **clear and descriptive title** for the issue to identify the suggestion.
- Provide a **step-by-step description of the suggested enhancement** in as many details as possible.
- **Describe the current behavior** and **explain which behavior you expected to see instead** and why. At this point you can also tell which alternatives do not work for you.
- You may want to **include screenshots or screen recordings** which help you demonstrate the steps or point out the part which the suggestion is related to. You can use [LICEcap](https://www.cockos.com/licecap/) to record GIFs on macOS and Windows, and the built-in [screen recorder in GNOME](https://help.gnome.org/users/gnome-help/stable/screen-shot-record.html.en) or [SimpleScreenRecorder](https://github.com/MaartenBaert/ssr) on Linux. <!-- this should only be included if the project has a GUI -->
- **Explain why this enhancement would be useful** to most Garage UI users. You may also want to point out the other projects that solved it better and which could serve as inspiration.
+21 -9
View File
@@ -1,32 +1,44 @@
FROM node:25-alpine3.22 AS frontend-builder
FROM --platform=$BUILDPLATFORM node:25-alpine3.23 AS frontend-builder
WORKDIR /app/frontend
COPY frontend/package.json frontend/package-lock.json* ./
RUN npm ci
RUN --mount=type=cache,target=/root/.npm \
npm install
COPY frontend/ .
RUN npm run build
FROM golang:1.25.4-alpine3.22 AS backend-builder
FROM --platform=$BUILDPLATFORM golang:1.26.0-alpine3.23 AS backend-builder
ARG TARGETOS
ARG TARGETARCH
WORKDIR /app
RUN go install github.com/swaggo/swag/cmd/swag@latest
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
go install github.com/swaggo/swag/cmd/swag@v1.16.4
COPY backend/go.mod backend/go.sum ./
RUN go mod download
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
COPY backend .
RUN swag init
ARG VERSION=dev
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o garage-ui .
RUN --mount=type=cache,target=/root/.cache/go-build \
swag init
FROM alpine:3.22
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -a -installsuffix cgo -ldflags "-X main.version=${VERSION}" -o garage-ui .
FROM alpine:3.23.3
WORKDIR /app
@@ -43,7 +55,7 @@ USER garageui
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1
CMD ["./garage-ui"]
+156
View File
@@ -0,0 +1,156 @@
.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
IMAGE_NAME = noooste/garage-ui
IMAGE_TAG = latest
## help: Show this help message
help:
@echo 'Usage:'
@sed -n 's/^##//p' ${MAKEFILE_LIST} | column -t -s ':' | sed -e 's/^/ /'
## build: Build the Docker image locally
build:
docker build -t $(IMAGE_NAME):$(IMAGE_TAG) .
## build-no-cache: Build the Docker image without cache
build-no-cache:
docker build --no-cache -t $(IMAGE_NAME):$(IMAGE_TAG) .
## push: Push the Docker image to registry
push: build
docker push $(IMAGE_NAME):$(IMAGE_TAG)
## dev-build: Build and start development environment
dev-build:
$(DOCKER_COMPOSE_DEV) build
## dev-up: Start development environment
dev-up:
$(DOCKER_COMPOSE_DEV) up -d
## dev-down: Stop development environment
dev-down:
$(DOCKER_COMPOSE_DEV) down
## dev-restart: Restart development environment
dev-restart: dev-down dev-up
## dev-logs: Show logs for development environment
dev-logs:
$(DOCKER_COMPOSE_DEV) logs -f
## dev-logs-ui: Show logs for garage-ui in development
dev-logs-ui:
$(DOCKER_COMPOSE_DEV) logs -f garage-ui
## dev-logs-garage: Show logs for garage in development
dev-logs-garage:
$(DOCKER_COMPOSE_DEV) logs -f garage
## dev-shell: Open shell in garage-ui container (development)
dev-shell:
$(DOCKER_COMPOSE_DEV) exec garage-ui sh
## prod-up: Start production environment
prod-up:
$(DOCKER_COMPOSE_PROD) up -d
## prod-down: Stop production environment
prod-down:
$(DOCKER_COMPOSE_PROD) down
## prod-restart: Restart production environment
prod-restart: prod-down prod-up
## prod-logs: Show logs for production environment
prod-logs:
$(DOCKER_COMPOSE_PROD) logs -f
## prod-logs-ui: Show logs for garage-ui in production
prod-logs-ui:
$(DOCKER_COMPOSE_PROD) logs -f garage-ui
## prod-logs-garage: Show logs for garage in production
prod-logs-garage:
$(DOCKER_COMPOSE_PROD) logs -f garage
## prod-pull: Pull latest images for production
prod-pull:
$(DOCKER_COMPOSE_PROD) pull
## up: Start default (production) environment
up: prod-up
## down: Stop all environments
down:
$(DOCKER_COMPOSE_PROD) down
$(DOCKER_COMPOSE_DEV) down
## stop: Stop all containers
stop:
$(DOCKER_COMPOSE_PROD) stop
$(DOCKER_COMPOSE_DEV) stop
## restart: Restart default (production) environment
restart: prod-restart
## logs: Show logs for default (production) environment
logs: prod-logs
## ps: Show running containers
ps:
docker ps -a --filter "name=garage"
## health: Check health status of containers
health:
@echo "=== Container Status ==="
@docker ps --filter "name=garage" --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"
## clean: Remove all containers, volumes, and images
clean:
$(DOCKER_COMPOSE_PROD) down -v --remove-orphans
$(DOCKER_COMPOSE_DEV) down -v --remove-orphans
docker system prune -f
## clean-volumes: Remove all volumes (WARNING: deletes data)
clean-volumes:
$(DOCKER_COMPOSE_PROD) down -v
$(DOCKER_COMPOSE_DEV) down -v
rm -rf ./meta ./data
## rebuild: Clean rebuild of development environment
rebuild: dev-down dev-build dev-up
## install: Initial setup - create necessary directories
install:
@echo "Creating necessary directories..."
@mkdir -p meta data
@echo "Setup complete. Edit garage.toml and config.yaml before starting."
## update: Pull latest changes and rebuild
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/...
+242
View File
@@ -0,0 +1,242 @@
# Garage UI
A web interface for managing [Garage](https://garagehq.deuxfleurs.fr/) object storage clusters.
[![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/)
[![Artifact Hub](https://img.shields.io/endpoint?url=https://artifacthub.io/badge/repository/garage-ui)](https://artifacthub.io/packages/search?repo=garage-ui)
---
<table>
<tr>
<td><img src=".github/assets/dashboard.png" alt="Dashboard" /></td>
<td><img src=".github/assets/buckets.png" alt="Buckets" /></td>
</tr>
<tr>
<td><img src=".github/assets/cluster.png" alt="Cluster" /></td>
<td><img src=".github/assets/access-control.png" alt="Access Control" /></td>
</tr>
</table>
## Features
- Bucket and object management
- User access control
- Cluster monitoring
- Multiple authentication options (none/basic/OIDC)
- Drag-and-drop file uploads
## Quick Start
### Prerequisites
- Docker & Docker Compose
- Garage S3 cluster (v2.1.0+) or use the included setup
### 1. Clone & Setup
```bash
git clone https://github.com/Noooste/garage-ui.git
cd garage-ui
```
### 2. Start Garage
If you don't have Garage running:
```bash
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
# Create admin key
docker compose exec garage garage key create admin-key
```
Save the access key and secret key from the output.
### 3. Configure
```bash
cp config.yaml.example config.yaml
```
Edit `config.yaml` with your Garage endpoints and admin token (from `garage.toml`).
### 4. Start UI
```bash
docker compose up -d garage-ui
```
Access at http://localhost:8080
## Configuration
Minimum required config:
```yaml
server:
port: 8080
garage:
endpoint: "http://garage:3900"
admin_endpoint: "http://garage:3903"
admin_token: "your-admin-token"
region: "garage"
```
Enable authentication (optional):
```yaml
auth:
admin:
enabled: true
username: "admin"
password: "your-password"
```
See [config.yaml.example](config.yaml.example) for all options.
### Environment Variables
Override any config value with `GARAGE_UI_` prefix:
```bash
GARAGE_UI_SERVER_PORT=8080
GARAGE_UI_GARAGE_ENDPOINT=http://garage:3900
GARAGE_UI_GARAGE_ADMIN_TOKEN=your-token
```
## Deployment
### Docker
```bash
docker run -d -p 8080:8080 \
-v $(pwd)/config.yaml:/app/config.yaml \
noooste/garage-ui:latest
```
### Kubernetes
```bash
helm repo add garage-ui https://helm.noste.dev/
helm install garage-ui garage-ui/garage-ui \
--set garage.endpoint=http://garage:3900 \
--set garage.adminEndpoint=http://garage:3903 \
--set garage.adminToken=your-token
```
## Development
Backend (Go 1.25+):
```bash
cd backend
go run main.go --config ../config.yaml
```
Frontend (Node.js 25+):
```bash
cd frontend
npm install
npm run dev
```
API docs: http://localhost:8080/api/v1/
## Garage Configuration
Garage UI requires these settings in your `garage.toml`:
```toml
# Admin API (required for Garage UI)
[admin]
api_bind_addr = "0.0.0.0:3903" # Default: 127.0.0.1:3903
admin_token = "your-admin-token" # Generate with: openssl rand -base64 32
# S3 API
[s3_api]
s3_region = "garage" # Default: "garage"
api_bind_addr = "[::]:3900" # Default: 127.0.0.1:3900
```
**Important:** The `admin_token` and `s3_region` in `garage.toml` must match your Garage UI `config.yaml`.
For complete Garage configuration, see the [official documentation](https://garagehq.deuxfleurs.fr/documentation/reference-manual/configuration/).
## Troubleshooting
**Connection failed:**
```bash
curl http://localhost:3903/status -H "Authorization: Bearer your-token"
```
**Enable debug logs:**
```yaml
logging:
level: "debug"
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)
## Links
- [Issues](https://github.com/Noooste/garage-ui/issues)
- [Contributing](CONTRIBUTING.md)
- [Garage Docs](https://garagehq.deuxfleurs.fr/documentation/)
+44 -44
View File
@@ -3,82 +3,82 @@ module Noooste/garage-ui
go 1.25.3
require (
github.com/Noooste/azuretls-client v1.12.10
github.com/Noooste/azuretls-client v1.13.2
github.com/Noooste/swagger v1.2.0
github.com/coreos/go-oidc/v3 v3.17.0
github.com/gofiber/fiber/v3 v3.0.0-rc.3
github.com/minio/minio-go/v7 v7.0.97
github.com/rs/zerolog v1.34.0
github.com/coreos/go-oidc/v3 v3.18.0
github.com/gofiber/fiber/v3 v3.1.0
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/google/uuid v1.6.0
github.com/minio/minio-go/v7 v7.0.100
github.com/rs/zerolog v1.35.0
github.com/spf13/viper v1.21.0
github.com/swaggo/swag v1.16.6
golang.org/x/oauth2 v0.33.0
golang.org/x/oauth2 v0.36.0
)
require (
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/Noooste/fhttp v1.0.15 // indirect
github.com/Noooste/go-socks4 v0.0.2 // indirect
github.com/Noooste/uquic-go v1.0.3 // indirect
github.com/Noooste/utls v1.3.20 // indirect
github.com/Noooste/uquic-go v1.0.5 // indirect
github.com/Noooste/utls v1.3.21 // indirect
github.com/Noooste/websocket v1.0.3 // indirect
github.com/andybalholm/brotli v1.2.0 // indirect
github.com/andybalholm/brotli v1.2.1 // indirect
github.com/bdandy/go-errors v1.2.2 // indirect
github.com/cloudflare/circl v1.6.1 // indirect
github.com/cloudflare/circl v1.6.3 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fatih/color v1.18.0 // indirect
github.com/fatih/color v1.19.0 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/gaukas/clienthellod v0.4.2 // indirect
github.com/gaukas/godicttls v0.0.4 // indirect
github.com/go-ini/ini v1.67.0 // indirect
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
github.com/go-openapi/jsonpointer v0.22.3 // indirect
github.com/go-openapi/jsonreference v0.21.3 // indirect
github.com/go-openapi/spec v0.22.1 // indirect
github.com/go-openapi/swag/conv v0.25.4 // indirect
github.com/go-openapi/swag/jsonname v0.25.4 // indirect
github.com/go-openapi/swag/jsonutils v0.25.4 // indirect
github.com/go-openapi/swag/loading v0.25.4 // indirect
github.com/go-openapi/swag/stringutils v0.25.4 // indirect
github.com/go-openapi/swag/typeutils v0.25.4 // indirect
github.com/go-openapi/swag/yamlutils v0.25.4 // indirect
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/gofiber/schema v1.6.0 // indirect
github.com/gofiber/utils/v2 v2.0.0-rc.3 // indirect
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
github.com/go-openapi/jsonpointer v0.23.1 // indirect
github.com/go-openapi/jsonreference v0.21.5 // indirect
github.com/go-openapi/spec v0.22.4 // indirect
github.com/go-openapi/swag v0.26.0 // indirect
github.com/go-openapi/swag/conv v0.26.0 // indirect
github.com/go-openapi/swag/jsonname v0.26.0 // indirect
github.com/go-openapi/swag/jsonutils v0.26.0 // indirect
github.com/go-openapi/swag/loading v0.26.0 // indirect
github.com/go-openapi/swag/stringutils v0.26.0 // indirect
github.com/go-openapi/swag/typeutils v0.26.0 // indirect
github.com/go-openapi/swag/yamlutils v0.26.0 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/gofiber/schema v1.7.1 // indirect
github.com/gofiber/utils/v2 v2.0.3 // indirect
github.com/google/gopacket v1.1.19 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/klauspost/compress v1.18.2 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/klauspost/compress v1.18.5 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/klauspost/crc32 v1.3.0 // indirect
github.com/mailru/easyjson v0.9.2 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-isatty v0.0.21 // indirect
github.com/minio/crc64nvme v1.1.1 // indirect
github.com/minio/md5-simd v1.1.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pelletier/go-toml/v2 v2.3.0 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/refraction-networking/utls v1.8.1 // indirect
github.com/refraction-networking/utls v1.8.2 // indirect
github.com/rs/xid v1.6.0 // indirect
github.com/sagikazarmark/locafero v0.12.0 // indirect
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/swaggo/files/v2 v2.0.2 // indirect
github.com/tinylib/msgp v1.6.1 // indirect
github.com/tinylib/msgp v1.6.4 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.68.0 // indirect
go.uber.org/automaxprocs v1.6.0 // indirect
github.com/valyala/fasthttp v1.70.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.45.0 // indirect
golang.org/x/mod v0.30.0 // indirect
golang.org/x/net v0.47.0 // indirect
golang.org/x/sync v0.18.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.31.0 // indirect
golang.org/x/tools v0.39.0 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
golang.org/x/crypto v0.50.0 // indirect
golang.org/x/mod v0.35.0 // indirect
golang.org/x/net v0.53.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/text v0.36.0 // indirect
golang.org/x/tools v0.44.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+147 -97
View File
@@ -1,31 +1,41 @@
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
github.com/Noooste/azuretls-client v1.12.9 h1:w6XVao95irzge8k+yF5ZovIKf7UBe0GVE3sTRKs/4wE=
github.com/Noooste/azuretls-client v1.12.9/go.mod h1:GHqLaS+vjBk+D3fMA0d+gNgDS2ahtic+6c+7JyMfrdU=
github.com/Noooste/azuretls-client v1.12.10 h1:wD+hSokcB1DCFSSLpj05bMGUCBV0wbPB/Z4uPFNEpoI=
github.com/Noooste/azuretls-client v1.12.10/go.mod h1:nVPwYA6UgHrOinlpvj6t/zDTBV+UfT3t8vmab7WYxF0=
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/Noooste/azuretls-client v1.12.11 h1:8IvtfPf+K6wOqiRROL/APGkxQCO/+jyjH0S39rnItfQ=
github.com/Noooste/azuretls-client v1.12.11/go.mod h1:lvXW8wpaOwrwtDrSt8nv/Dd8NAbCMVNRoU4sFrAaxYs=
github.com/Noooste/azuretls-client v1.13.2 h1:8Dli5aKP5O6qN/FNSGFVkpQ1V1F1gGawAkLIE2Nrk+U=
github.com/Noooste/azuretls-client v1.13.2/go.mod h1:ON+SmiBm4Zy5vAhJmBNZk61Y7nqf4iM/b1MC1lN47Bk=
github.com/Noooste/fhttp v1.0.15 h1:sYRWOKgr1x4L+wA6REMJCs4Z/lFOSJmuQHSIXMXCcPs=
github.com/Noooste/fhttp v1.0.15/go.mod h1:YZtq+i2M11Y22UiOR6gjNSLMNLiPhURh6M44oFVQ1TE=
github.com/Noooste/go-socks4 v0.0.2 h1:DwHCYiCEAdjfNrQOFIid7qgKCll7ubhGS1ji5O8FYng=
github.com/Noooste/go-socks4 v0.0.2/go.mod h1:+oOgtOFRsU8FoK7NBOhHSjiH5pveY8LgYNF5XcqVgjE=
github.com/Noooste/swagger v1.2.0 h1:zGHin8k2V9mXDB1gxXOdKe4V8zhw79ycsw+/L2hH/pk=
github.com/Noooste/swagger v1.2.0/go.mod h1:5N+iUZlFA43k2Paf42EZ+SFndBG1niSA1FAnwiNP1PM=
github.com/Noooste/uquic-go v1.0.1 h1:12ARejbnh0R5FLGoHhz4p+qT/8BKkRNTPsJlkX4/pg8=
github.com/Noooste/uquic-go v1.0.1/go.mod h1:6AUIck22N0wQ5+CY/5pLmW3IzdITlf7ABtjbRsaZq2A=
github.com/Noooste/uquic-go v1.0.3 h1:VP8npQmU4lkVLm9Ug5Q18SJ8ExFDfUZIzd13YjYaLHE=
github.com/Noooste/uquic-go v1.0.3/go.mod h1:MxkrvgpNcbIOSQxqglC3e/798O/6zuL3mBhlFN+04w4=
github.com/Noooste/uquic-go v1.0.5 h1:HWfrxhxgB1a9Y2Au5mfFs2Y5Dy13OQIwa86D/kULPtE=
github.com/Noooste/uquic-go v1.0.5/go.mod h1:1y+qiy23PqLKudi4kQiJ0b3zXXYcyctEBRfZPTuyBz4=
github.com/Noooste/utls v1.3.20 h1:QzBNGGJ184bNMLodOzvM9YWc4vZ36QodIjqFQOHoZ88=
github.com/Noooste/utls v1.3.20/go.mod h1:XEy+VEbTxmH6krfSG5YT7wDbjHTEi2zUXTG33R0PAAg=
github.com/Noooste/utls v1.3.21 h1:5yEzTibikzF0/d0REfbjXURGHxJDKCrRghVAU/OQBko=
github.com/Noooste/utls v1.3.21/go.mod h1:XEy+VEbTxmH6krfSG5YT7wDbjHTEi2zUXTG33R0PAAg=
github.com/Noooste/websocket v1.0.3 h1:drW7tvZ3YqzqI9wApnaH1Q0syFMXO7gbLlsBWjZvMNA=
github.com/Noooste/websocket v1.0.3/go.mod h1:Qhw0Rtuju/fPPbcb3R5XGq7poa51qPDL462jTltl9nQ=
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/bdandy/go-errors v1.2.2 h1:WdFv/oukjTJCLa79UfkGmwX7ZxONAihKu4V0mLIs11Q=
github.com/bdandy/go-errors v1.2.2/go.mod h1:NkYHl4Fey9oRRdbB1CoC6e84tuqQHiqrOcZpqFEkBxM=
github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0=
github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc=
github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
github.com/coreos/go-oidc/v3 v3.18.0 h1:V9orjXynvu5wiC9SemFTWnG4F45v403aIcjWo0d41+A=
github.com/coreos/go-oidc/v3 v3.18.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -33,14 +43,15 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/francoispqt/gojay v1.2.13 h1:d2m3sFjloqoIUQU3TsHBgj6qg/BVGlTBeHDUmyJnXKk=
github.com/francoispqt/gojay v1.2.13/go.mod h1:ehT5mTG4ua4581f1++1WLG0vPdaA9HaiDsoyrBGkyDY=
github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/fxamacker/cbor/v2 v2.9.1 h1:2rWm8B193Ll4VdjsJY28jxs70IdDsHRWgQYAI80+rMQ=
github.com/gaukas/clienthellod v0.4.2 h1:LPJ+LSeqt99pqeCV4C0cllk+pyWmERisP7w6qWr7eqE=
github.com/gaukas/clienthellod v0.4.2/go.mod h1:M57+dsu0ZScvmdnNxaxsDPM46WhSEdPYAOdNgfL7IKA=
github.com/gaukas/godicttls v0.0.4 h1:NlRaXb3J6hAnTmWdsEKb9bcSBD6BvcIjdGdeb0zfXbk=
@@ -49,54 +60,60 @@ github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-logr/logr v1.2.4 h1:g01GSCwiDw2xSZfjJ2/T9M+S6pFdcNtFYsp+Y43HYDQ=
github.com/go-logr/logr v1.2.4/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-openapi/jsonpointer v0.22.3 h1:dKMwfV4fmt6Ah90zloTbUKWMD+0he+12XYAsPotrkn8=
github.com/go-openapi/jsonpointer v0.22.3/go.mod h1:0lBbqeRsQ5lIanv3LHZBrmRGHLHcQoOXQnf88fHlGWo=
github.com/go-openapi/jsonreference v0.21.3 h1:96Dn+MRPa0nYAR8DR1E03SblB5FJvh7W6krPI0Z7qMc=
github.com/go-openapi/jsonreference v0.21.3/go.mod h1:RqkUP0MrLf37HqxZxrIAtTWW4ZJIK1VzduhXYBEeGc4=
github.com/go-openapi/spec v0.22.1 h1:beZMa5AVQzRspNjvhe5aG1/XyBSMeX1eEOs7dMoXh/k=
github.com/go-openapi/spec v0.22.1/go.mod h1:c7aeIQT175dVowfp7FeCvXXnjN/MrpaONStibD2WtDA=
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
github.com/go-openapi/jsonpointer v0.23.1 h1:1HBACs7XIwR2RcmItfdSFlALhGbe6S92p0ry4d1GWg4=
github.com/go-openapi/jsonpointer v0.23.1/go.mod h1:iWRmZTrGn7XwYhtPt/fvdSFj1OfNBngqRT2UG3BxSqY=
github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ=
github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4=
github.com/go-openapi/jsonreference v0.21.5 h1:6uCGVXU/aNF13AQNggxfysJ+5ZcU4nEAe+pJyVWRdiE=
github.com/go-openapi/jsonreference v0.21.5/go.mod h1:u25Bw85sX4E2jzFodh1FOKMTZLcfifd1Q+iKKOUxExw=
github.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9ZY=
github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk=
github.com/go-openapi/spec v0.22.4 h1:4pxGjipMKu0FzFiu/DPwN3CTBRlVM2yLf/YTWorYfDQ=
github.com/go-openapi/spec v0.22.4/go.mod h1:WQ6Ai0VPWMZgMT4XySjlRIE6GP1bGQOtEThn3gcWLtQ=
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
github.com/go-openapi/swag/conv v0.25.4 h1:/Dd7p0LZXczgUcC/Ikm1+YqVzkEeCc9LnOWjfkpkfe4=
github.com/go-openapi/swag/conv v0.25.4/go.mod h1:3LXfie/lwoAv0NHoEuY1hjoFAYkvlqI/Bn5EQDD3PPU=
github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI=
github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag=
github.com/go-openapi/swag/jsonutils v0.25.4 h1:VSchfbGhD4UTf4vCdR2F4TLBdLwHyUDTd1/q4i+jGZA=
github.com/go-openapi/swag/jsonutils v0.25.4/go.mod h1:7OYGXpvVFPn4PpaSdPHJBtF0iGnbEaTk8AvBkoWnaAY=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4 h1:IACsSvBhiNJwlDix7wq39SS2Fh7lUOCJRmx/4SN4sVo=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4/go.mod h1:Mt0Ost9l3cUzVv4OEZG+WSeoHwjWLnarzMePNDAOBiM=
github.com/go-openapi/swag/loading v0.25.4 h1:jN4MvLj0X6yhCDduRsxDDw1aHe+ZWoLjW+9ZQWIKn2s=
github.com/go-openapi/swag/loading v0.25.4/go.mod h1:rpUM1ZiyEP9+mNLIQUdMiD7dCETXvkkC30z53i+ftTE=
github.com/go-openapi/swag/stringutils v0.25.4 h1:O6dU1Rd8bej4HPA3/CLPciNBBDwZj9HiEpdVsb8B5A8=
github.com/go-openapi/swag/stringutils v0.25.4/go.mod h1:GTsRvhJW5xM5gkgiFe0fV3PUlFm0dr8vki6/VSRaZK0=
github.com/go-openapi/swag/typeutils v0.25.4 h1:1/fbZOUN472NTc39zpa+YGHn3jzHWhv42wAJSN91wRw=
github.com/go-openapi/swag/typeutils v0.25.4/go.mod h1:Ou7g//Wx8tTLS9vG0UmzfCsjZjKhpjxayRKTHXf2pTE=
github.com/go-openapi/swag/yamlutils v0.25.4 h1:6jdaeSItEUb7ioS9lFoCZ65Cne1/RZtPBZ9A56h92Sw=
github.com/go-openapi/swag/yamlutils v0.25.4/go.mod h1:MNzq1ulQu+yd8Kl7wPOut/YHAAU/H6hL91fF+E2RFwc=
github.com/go-openapi/testify/enable/yaml/v2 v2.0.2 h1:0+Y41Pz1NkbTHz8NngxTuAXxEodtNSI1WG1c/m5Akw4=
github.com/go-openapi/testify/enable/yaml/v2 v2.0.2/go.mod h1:kme83333GCtJQHXQ8UKX3IBZu6z8T5Dvy5+CW3NLUUg=
github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls=
github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54=
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
github.com/go-openapi/swag v0.26.0 h1:GVDXCmfvhfu1BxiHo8/FA+BbKmhecHnG3varjON5/RI=
github.com/go-openapi/swag v0.26.0/go.mod h1:82g3193sZJRbocs7bNCqGfIgq8pkuwVwCfhKIRlEQF0=
github.com/go-openapi/swag/conv v0.26.0 h1:5yGGsPYI1ZCva93U0AoKi/iZrNhaJEjr324YVsiD89I=
github.com/go-openapi/swag/conv v0.26.0/go.mod h1:tpAmIL7X58VPnHHiSO4uE3jBeRamGsFsfdDeDtb5ECE=
github.com/go-openapi/swag/jsonname v0.26.0 h1:gV1NFX9M8avo0YSpmWogqfQISigCmpaiNci8cGECU5w=
github.com/go-openapi/swag/jsonname v0.26.0/go.mod h1:urBBR8bZNoDYGr653ynhIx+gTeIz0ARZxHkAPktJK2M=
github.com/go-openapi/swag/jsonutils v0.26.0 h1:FawFML2iAXsPqmERscuMPIHmFsoP1tOqWkxBaKNMsnA=
github.com/go-openapi/swag/jsonutils v0.26.0/go.mod h1:2VmA0CJlyFqgawOaPI9psnjFDqzyivIqLYN34t9p91E=
github.com/go-openapi/swag/loading v0.26.0 h1:Apg6zaKhCJurpJer0DCxq99qwmhFddBhaMX7kilDcko=
github.com/go-openapi/swag/loading v0.26.0/go.mod h1:dBxQ/6V2uBaAQdevN18VELE6xSpJWZxLX4txe12JwDg=
github.com/go-openapi/swag/stringutils v0.26.0 h1:qZQngLxs5s7SLijc3N2ZO+fUq2o8LjuWAASSrJuh+xg=
github.com/go-openapi/swag/stringutils v0.26.0/go.mod h1:sWn5uY+QIIspwPhvgnqJsH8xqFT2ZbYcvbcFanRyhFE=
github.com/go-openapi/swag/typeutils v0.26.0 h1:2kdEwdiNWy+JJdOvu5MA2IIg2SylWAFuuyQIKYybfq4=
github.com/go-openapi/swag/typeutils v0.26.0/go.mod h1:oovDuIUvTrEHVMqWilQzKzV4YlSKgyZmFh7AlfABNVE=
github.com/go-openapi/swag/yamlutils v0.26.0 h1:H7O8l/8NJJQ/oiReEN+oMpnGMyt8G0hl460nRZxhLMQ=
github.com/go-openapi/swag/yamlutils v0.26.0/go.mod h1:1evKEGAtP37Pkwcc7EWMF0hedX0/x3Rkvei2wtG/TbU=
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI=
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gofiber/fiber/v3 v3.0.0-rc.3 h1:h0KXuRHbivSslIpoHD1R/XjUsjcGwt+2vK0avFiYonA=
github.com/gofiber/fiber/v3 v3.0.0-rc.3/go.mod h1:LNBPuS/rGoUFlOyy03fXsWAeWfdGoT1QytwjRVNSVWo=
github.com/gofiber/schema v1.6.0 h1:rAgVDFwhndtC+hgV7Vu5ItQCn7eC2mBA4Eu1/ZTiEYY=
github.com/gofiber/schema v1.6.0/go.mod h1:WNZWpQx8LlPSK7ZaX0OqOh+nQo/eW2OevsXs1VZfs/s=
github.com/gofiber/utils/v2 v2.0.0-rc.2 h1:NvJTf7yMafTq16lUOJv70nr+HIOLNQcvGme/X+ftbW8=
github.com/gofiber/utils/v2 v2.0.0-rc.2/go.mod h1:gXins5o7up+BQFiubmO8aUJc/+Mhd7EKXIiAK5GBomI=
github.com/gofiber/utils/v2 v2.0.0-rc.3 h1:gOL5jAEGUT2UbQkTkgMJctYt4rYewnTIt0Y7YaDATDc=
github.com/gofiber/utils/v2 v2.0.0-rc.3/go.mod h1:gXins5o7up+BQFiubmO8aUJc/+Mhd7EKXIiAK5GBomI=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/gofiber/fiber/v3 v3.1.0 h1:1p4I820pIa+FGxfwWuQZ5rAyX0WlGZbGT6Hnuxt6hKY=
github.com/gofiber/fiber/v3 v3.1.0/go.mod h1:n2nYQovvL9z3Too/FGOfgtERjW3GQcAUqgfoezGBZdU=
github.com/gofiber/schema v1.7.0 h1:yNM+FNRZjyYEli9Ey0AXRBrAY9jTnb+kmGs3lJGPvKg=
github.com/gofiber/schema v1.7.0/go.mod h1:A/X5Ffyru4p9eBdp99qu+nzviHzQiZ7odLT+TwxWhbk=
github.com/gofiber/schema v1.7.1 h1:oSJBKdgP8JeIME4TQSAqlNKTU2iBB+2RNmKi8Nsc+TI=
github.com/gofiber/schema v1.7.1/go.mod h1:A/X5Ffyru4p9eBdp99qu+nzviHzQiZ7odLT+TwxWhbk=
github.com/gofiber/utils/v2 v2.0.2 h1:ShRRssz0F3AhTlAQcuEj54OEDtWF7+HJDwEi/aa6QLI=
github.com/gofiber/utils/v2 v2.0.2/go.mod h1:+9Ub4NqQ+IaJoTliq5LfdmOJAA/Hzwf4pXOxOa3RrJ0=
github.com/gofiber/utils/v2 v2.0.3 h1:qJyfS/t7s7Z4+/zlU1i1pafYNP2+xLupVPgkW8ce1uI=
github.com/gofiber/utils/v2 v2.0.3/go.mod h1:GGERKU3Vhj5z6hS8YKvxL99A54DjOvTFZ0cjZnG4Lj4=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8=
@@ -105,24 +122,27 @@ github.com/google/pprof v0.0.0-20250607225305-033d6d78b36a h1://KbezygeMJZCSHH+H
github.com/google/pprof v0.0.0-20250607225305-033d6d78b36a/go.mod h1:5hDyRhoBCxViHszMt12TnOpEI4VVi+U8Gm9iphldiMA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co=
github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0=
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.8 h1:+StwCXwm9PdpiEkPyzBXIy+M9KUb4ODm0Zarf1kS5BM=
github.com/klauspost/cpuid/v2 v2.2.8/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4OxRzU=
github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM=
github.com/klauspost/crc32 v1.3.0/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4=
github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/mailru/easyjson v0.9.2 h1:dX8U45hQsZpxd80nLvDGihsQ/OxlvTkVUXH2r/8cb2M=
github.com/mailru/easyjson v0.9.2/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
@@ -130,20 +150,24 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI=
github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
github.com/minio/minio-go/v7 v7.0.80 h1:2mdUHXEykRdY/BigLt3Iuu1otL0JTogT0Nmltg0wujk=
github.com/minio/minio-go/v7 v7.0.80/go.mod h1:84gmIilaX4zcvAWWzJ5Z1WI5axN+hAbM5w25xf8xvC0=
github.com/minio/minio-go/v7 v7.0.97 h1:lqhREPyfgHTB/ciX8k2r8k0D93WaFqxbJX36UZq5occ=
github.com/minio/minio-go/v7 v7.0.97/go.mod h1:re5VXuo0pwEtoNLsNuSr0RrLfT/MBtohwdaSmPPSRSk=
github.com/onsi/ginkgo/v2 v2.23.4 h1:ktYTpKJAVZnDT4VjxSbiBenUjmlL/5QkBEocaWXiQus=
github.com/onsi/ginkgo/v2 v2.23.4/go.mod h1:Bt66ApGPBFzHyR+JO10Zbt0Gsp4uWxu5mIOTusL46e8=
github.com/onsi/gomega v1.37.0 h1:CdEG8g0S133B4OswTDC/5XPSzE1OeP29QOioj2PID2Y=
github.com/onsi/gomega v1.37.0/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0=
github.com/minio/minio-go/v7 v7.0.98 h1:MeAVKjLVz+XJ28zFcuYyImNSAh8Mq725uNW4beRisi0=
github.com/minio/minio-go/v7 v7.0.98/go.mod h1:cY0Y+W7yozf0mdIclrttzo1Iiu7mEf9y7nk2uXqMOvM=
github.com/minio/minio-go/v7 v7.0.100 h1:ShkWi8Tyj9RtU57OQB2HIXKz4bFgtVib0bbT1sbtLI8=
github.com/minio/minio-go/v7 v7.0.100/go.mod h1:EtGNKtlX20iL2yaYnxEigaIvj0G0GwSDnifnG8ClIdw=
github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns=
github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A=
github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM=
github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
@@ -153,16 +177,25 @@ github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/refraction-networking/utls v1.8.1 h1:yNY1kapmQU8JeM1sSw2H2asfTIwWxIkrMJI0pRUOCAo=
github.com/refraction-networking/utls v1.8.1/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo=
github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
github.com/rs/zerolog v1.35.0 h1:VD0ykx7HMiMJytqINBsKcbLS+BJ4WYjz+05us+LRTdI=
github.com/rs/zerolog v1.35.0/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw=
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4=
github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI=
github.com/shamaton/msgpack/v2 v2.4.0 h1:O5Z08MRmbo0lA9o2xnQ4TXx6teJbPqEurqcCOQ8Oi/4=
github.com/shamaton/msgpack/v2 v2.4.0/go.mod h1:6khjYnkx73f7VQU7wjcFS9DFjs+59naVWJv1TB7qdOI=
github.com/shamaton/msgpack/v3 v3.1.0 h1:jsk0vEAqVvvS9+fTZ5/EcQ9tz860c9pWxJ4Iwecz8gU=
github.com/shamaton/msgpack/v3 v3.1.0/go.mod h1:DcQG8jrdrQCIxr3HlMYkiXdMhK+KfN2CitkyzsQV4uc=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
@@ -179,61 +212,78 @@ github.com/swaggo/files/v2 v2.0.2 h1:Bq4tgS/yxLB/3nwOMcul5oLEUKa877Ykgz3CJMVbQKU
github.com/swaggo/files/v2 v2.0.2/go.mod h1:TVqetIzZsO9OhHX1Am9sRf9LdrFZqoK49N37KON/jr0=
github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
github.com/tinylib/msgp v1.5.0 h1:GWnqAE54wmnlFazjq2+vgr736Akg58iiHImh+kPY2pc=
github.com/tinylib/msgp v1.5.0/go.mod h1:cvjFkb4RiC8qSBOPMGPSzSAx47nAsfhLVTCZZNuHv5o=
github.com/tinylib/msgp v1.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY=
github.com/tinylib/msgp v1.6.1/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
github.com/tinylib/msgp v1.6.3 h1:bCSxiTz386UTgyT1i0MSCvdbWjVW+8sG3PjkGsZQt4s=
github.com/tinylib/msgp v1.6.3/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.68.0 h1:v12Nx16iepr8r9ySOwqI+5RBJ/DqTxhOy1HrHoDFnok=
github.com/valyala/fasthttp v1.68.0/go.mod h1:5EXiRfYQAoiO/khu4oU9VISC/eVY6JqmSpPJoHCKsz4=
github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZyVI=
github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw=
github.com/valyala/fasthttp v1.70.0 h1:LAhMGcWk13QZWm85+eg8ZBNbrq5mnkWFGbHMUJHIdXA=
github.com/valyala/fasthttp v1.70.0/go.mod h1:oDZEHHkJ/Buyklg6uURmYs19442zFSnCIfX3j1FY3pE=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs=
go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8=
go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko=
go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 h1:y5zboxd6LQAqYIhHnB48p0ByQ/GnQx2BE33L8BOHQkI=
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6/go.mod h1:U6Lno4MTRCDY+Ba7aCcauB9T60gsv5s4ralQzP72ZoQ=
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo=
golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+23
View File
@@ -0,0 +1,23 @@
// Package apierr translates upstream (Garage Admin API, S3/MinIO) errors into
// backend API error responses with correct HTTP status codes and stable codes.
package apierr
import "fmt"
// UpstreamError is a typed error describing a failure reported by an upstream
// service. Returned from the services layer and consumed by handlers via Map
// or Respond.
type UpstreamError struct {
HTTPStatus int
Code string // upstream code, e.g. "BucketNotEmpty", "NoSuchKey"
Message string // human-readable upstream message
Source string // "garage" or "s3"
Details map[string]string // optional: region, path, bucket, key
}
func (e *UpstreamError) Error() string {
if e.Code == "" {
return fmt.Sprintf("%s: %s", e.Source, e.Message)
}
return fmt.Sprintf("%s %s: %s", e.Source, e.Code, e.Message)
}
+40
View File
@@ -0,0 +1,40 @@
package apierr
import (
"errors"
"testing"
)
func TestUpstreamError_Error(t *testing.T) {
e := &UpstreamError{
HTTPStatus: 409,
Code: "BucketNotEmpty",
Message: "Tried to delete a non-empty bucket",
Source: "garage",
}
got := e.Error()
want := "garage BucketNotEmpty: Tried to delete a non-empty bucket"
if got != want {
t.Fatalf("Error() = %q, want %q", got, want)
}
}
func TestUpstreamError_ErrorWithoutCode(t *testing.T) {
e := &UpstreamError{HTTPStatus: 500, Source: "garage", Message: "server went boom"}
got := e.Error()
want := "garage: server went boom"
if got != want {
t.Fatalf("Error() = %q, want %q", got, want)
}
}
func TestUpstreamError_ErrorsAs(t *testing.T) {
var base error = &UpstreamError{HTTPStatus: 404, Code: "NoSuchBucket", Source: "garage"}
var target *UpstreamError
if !errors.As(base, &target) {
t.Fatal("errors.As should have matched *UpstreamError")
}
if target.Code != "NoSuchBucket" {
t.Fatalf("Code = %q, want NoSuchBucket", target.Code)
}
}
+59
View File
@@ -0,0 +1,59 @@
package apierr
import (
"errors"
"Noooste/garage-ui/internal/models"
"github.com/gofiber/fiber/v3"
)
// codeEntry defines how an upstream code translates to the API surface.
type codeEntry struct {
HTTPStatus int
APICode string
}
// upstreamCodeTable maps upstream codes (Garage + S3 share the same naming for
// most codes) to (httpStatus, apiCode). Extend here as new upstream codes are
// discovered.
var upstreamCodeTable = map[string]codeEntry{
"BucketNotEmpty": {409, models.ErrCodeBucketNotEmpty},
"NoSuchBucket": {404, models.ErrCodeBucketNotFound},
"BucketAlreadyExists": {409, models.ErrCodeBucketExists},
"BucketAlreadyOwnedByYou": {409, models.ErrCodeBucketExists},
"NoSuchKey": {404, models.ErrCodeObjectNotFound},
"InvalidBucketName": {400, models.ErrCodeInvalidBucketName},
"AccessDenied": {403, models.ErrCodeForbidden},
}
// Map translates an error into (httpStatus, apiCode, message) for the API
// response. Falls back to 500 / INTERNAL_ERROR for non-UpstreamError values.
func Map(err error) (int, string, string) {
var ue *UpstreamError
if !errors.As(err, &ue) {
return 500, models.ErrCodeInternalError, err.Error()
}
if entry, ok := upstreamCodeTable[ue.Code]; ok {
return entry.HTTPStatus, entry.APICode, ue.Message
}
status := ue.HTTPStatus
if status == 0 {
status = 500
}
msg := ue.Message
if msg == "" {
msg = ue.Error()
}
return status, models.ErrCodeInternalError, msg
}
// Respond writes the mapped error as a Fiber JSON response using the standard
// APIResponse envelope. Handlers should call this from their err != nil
// branches for all upstream failures.
func Respond(c fiber.Ctx, err error) error {
status, code, msg := Map(err)
return c.Status(status).JSON(models.ErrorResponse(code, msg))
}
+104
View File
@@ -0,0 +1,104 @@
package apierr
import (
"errors"
"testing"
"Noooste/garage-ui/internal/models"
)
func TestMap_TableDriven(t *testing.T) {
cases := []struct {
name string
err error
wantStatus int
wantCode string
wantMsg string
}{
{
name: "BucketNotEmpty",
err: &UpstreamError{HTTPStatus: 409, Code: "BucketNotEmpty", Message: "Tried to delete a non-empty bucket", Source: "garage"},
wantStatus: 409,
wantCode: models.ErrCodeBucketNotEmpty,
wantMsg: "Tried to delete a non-empty bucket",
},
{
name: "NoSuchBucket",
err: &UpstreamError{HTTPStatus: 404, Code: "NoSuchBucket", Message: "missing", Source: "s3"},
wantStatus: 404,
wantCode: models.ErrCodeBucketNotFound,
wantMsg: "missing",
},
{
name: "BucketAlreadyExists",
err: &UpstreamError{HTTPStatus: 409, Code: "BucketAlreadyExists", Message: "dup", Source: "s3"},
wantStatus: 409,
wantCode: models.ErrCodeBucketExists,
wantMsg: "dup",
},
{
name: "BucketAlreadyOwnedByYou",
err: &UpstreamError{HTTPStatus: 409, Code: "BucketAlreadyOwnedByYou", Message: "yours", Source: "s3"},
wantStatus: 409,
wantCode: models.ErrCodeBucketExists,
wantMsg: "yours",
},
{
name: "NoSuchKey",
err: &UpstreamError{HTTPStatus: 404, Code: "NoSuchKey", Message: "gone", Source: "s3"},
wantStatus: 404,
wantCode: models.ErrCodeObjectNotFound,
wantMsg: "gone",
},
{
name: "InvalidBucketName",
err: &UpstreamError{HTTPStatus: 400, Code: "InvalidBucketName", Message: "bad", Source: "s3"},
wantStatus: 400,
wantCode: models.ErrCodeInvalidBucketName,
wantMsg: "bad",
},
{
name: "AccessDenied",
err: &UpstreamError{HTTPStatus: 403, Code: "AccessDenied", Message: "nope", Source: "garage"},
wantStatus: 403,
wantCode: models.ErrCodeForbidden,
wantMsg: "nope",
},
{
name: "UnknownCodeWithStatus",
err: &UpstreamError{HTTPStatus: 503, Code: "SomethingWeird", Message: "weird", Source: "garage"},
wantStatus: 503,
wantCode: models.ErrCodeInternalError,
wantMsg: "weird",
},
{
name: "UnknownCodeNoStatus",
err: &UpstreamError{HTTPStatus: 0, Code: "", Message: "boom", Source: "garage"},
wantStatus: 500,
wantCode: models.ErrCodeInternalError,
wantMsg: "boom",
},
{
name: "NonUpstreamError",
err: errors.New("plain"),
wantStatus: 500,
wantCode: models.ErrCodeInternalError,
wantMsg: "plain",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
status, code, msg := Map(tc.err)
if status != tc.wantStatus {
t.Errorf("status = %d, want %d", status, tc.wantStatus)
}
if code != tc.wantCode {
t.Errorf("code = %q, want %q", code, tc.wantCode)
}
if msg != tc.wantMsg {
t.Errorf("msg = %q, want %q", msg, tc.wantMsg)
}
})
}
}
+92
View File
@@ -0,0 +1,92 @@
package apierr
import (
"encoding/json"
"errors"
"io"
"github.com/Noooste/azuretls-client"
"github.com/minio/minio-go/v7"
)
// garageErrBody mirrors Garage's JSON error envelope.
type garageErrBody struct {
Code string `json:"code"`
Message string `json:"message"`
Region string `json:"region"`
Path string `json:"path"`
}
// ParseGarage returns nil for 2xx responses. For non-2xx it reads the body
// once, JSON-decodes the structured Garage error envelope, and returns a
// *UpstreamError. Malformed bodies are preserved verbatim in Message.
//
// ParseGarage DOES NOT close resp.RawBody on the success path — callers that
// decode the success body (decodeResponse in services/admin.go) still need
// access and are responsible for closing. On the error path the body is fully
// consumed before return.
func ParseGarage(resp *azuretls.Response) error {
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return nil
}
bodyBytes, _ := io.ReadAll(resp.RawBody)
ue := &UpstreamError{
HTTPStatus: resp.StatusCode,
Source: "garage",
}
var parsed garageErrBody
if len(bodyBytes) > 0 && json.Unmarshal(bodyBytes, &parsed) == nil && parsed.Code != "" {
ue.Code = parsed.Code
ue.Message = parsed.Message
ue.Details = map[string]string{}
if parsed.Region != "" {
ue.Details["region"] = parsed.Region
}
if parsed.Path != "" {
ue.Details["path"] = parsed.Path
}
} else {
ue.Message = string(bodyBytes)
}
return ue
}
// FromMinio converts a MinIO/S3 error into an *UpstreamError. Returns nil when
// err is nil. If err is not a minio.ErrorResponse (e.g. a network error), a
// generic 500 *UpstreamError is returned with the raw error string as Message.
func FromMinio(err error) *UpstreamError {
if err == nil {
return nil
}
var mer minio.ErrorResponse
if errors.As(err, &mer) {
details := map[string]string{}
if mer.BucketName != "" {
details["bucket"] = mer.BucketName
}
if mer.Key != "" {
details["key"] = mer.Key
}
status := mer.StatusCode
if status == 0 {
status = 500
}
return &UpstreamError{
HTTPStatus: status,
Code: mer.Code,
Message: mer.Message,
Source: "s3",
Details: details,
}
}
return &UpstreamError{
HTTPStatus: 500,
Source: "s3",
Message: err.Error(),
}
}
+134
View File
@@ -0,0 +1,134 @@
package apierr
import (
"bytes"
"errors"
"io"
"strings"
"testing"
"github.com/Noooste/azuretls-client"
"github.com/minio/minio-go/v7"
)
func fakeResp(status int, body string) *azuretls.Response {
return &azuretls.Response{
StatusCode: status,
RawBody: io.NopCloser(bytes.NewBufferString(body)),
}
}
func TestParseGarage_Success(t *testing.T) {
resp := fakeResp(200, `{"foo":"bar"}`)
if err := ParseGarage(resp); err != nil {
t.Fatalf("ParseGarage(2xx) returned %v, want nil", err)
}
}
func TestParseGarage_StructuredError(t *testing.T) {
body := `{"code":"BucketNotEmpty","message":"Tried to delete a non-empty bucket","region":"eu-west-1","path":"/v2/DeleteBucket"}`
resp := fakeResp(409, body)
err := ParseGarage(resp)
ue, ok := err.(*UpstreamError)
if !ok {
t.Fatalf("ParseGarage returned %T, want *UpstreamError", err)
}
if ue.HTTPStatus != 409 {
t.Errorf("HTTPStatus = %d, want 409", ue.HTTPStatus)
}
if ue.Code != "BucketNotEmpty" {
t.Errorf("Code = %q, want BucketNotEmpty", ue.Code)
}
if ue.Message != "Tried to delete a non-empty bucket" {
t.Errorf("Message = %q", ue.Message)
}
if ue.Source != "garage" {
t.Errorf("Source = %q, want garage", ue.Source)
}
if ue.Details["region"] != "eu-west-1" || ue.Details["path"] != "/v2/DeleteBucket" {
t.Errorf("Details = %v", ue.Details)
}
}
func TestParseGarage_MalformedBody(t *testing.T) {
resp := fakeResp(500, "not json at all")
err := ParseGarage(resp)
ue, ok := err.(*UpstreamError)
if !ok {
t.Fatalf("ParseGarage returned %T, want *UpstreamError", err)
}
if ue.HTTPStatus != 500 || ue.Code != "" {
t.Errorf("HTTPStatus=%d Code=%q, want 500 and empty code", ue.HTTPStatus, ue.Code)
}
if !strings.Contains(ue.Message, "not json at all") {
t.Errorf("Message = %q, expected raw body", ue.Message)
}
}
func TestParseGarage_EmptyBody(t *testing.T) {
resp := fakeResp(502, "")
err := ParseGarage(resp)
ue, ok := err.(*UpstreamError)
if !ok {
t.Fatalf("ParseGarage returned %T, want *UpstreamError", err)
}
if ue.HTTPStatus != 502 {
t.Errorf("HTTPStatus = %d, want 502", ue.HTTPStatus)
}
}
func TestFromMinio_NilInput(t *testing.T) {
if got := FromMinio(nil); got != nil {
t.Fatalf("FromMinio(nil) = %v, want nil", got)
}
}
func TestFromMinio_MinioErrorResponse(t *testing.T) {
in := minio.ErrorResponse{
StatusCode: 404,
Code: "NoSuchBucket",
Message: "The specified bucket does not exist",
BucketName: "missing",
}
got := FromMinio(in)
if got == nil {
t.Fatal("FromMinio returned nil")
}
if got.HTTPStatus != 404 {
t.Errorf("HTTPStatus = %d, want 404", got.HTTPStatus)
}
if got.Code != "NoSuchBucket" {
t.Errorf("Code = %q, want NoSuchBucket", got.Code)
}
if got.Message != "The specified bucket does not exist" {
t.Errorf("Message = %q", got.Message)
}
if got.Source != "s3" {
t.Errorf("Source = %q, want s3", got.Source)
}
if got.Details["bucket"] != "missing" {
t.Errorf("Details[bucket] = %q, want missing", got.Details["bucket"])
}
}
func TestFromMinio_NonMinioError(t *testing.T) {
got := FromMinio(errors.New("connection refused"))
if got == nil {
t.Fatal("FromMinio returned nil")
}
if got.HTTPStatus != 500 {
t.Errorf("HTTPStatus = %d, want 500", got.HTTPStatus)
}
if got.Code != "" {
t.Errorf("Code = %q, want empty", got.Code)
}
if !strings.Contains(got.Message, "connection refused") {
t.Errorf("Message = %q", got.Message)
}
if got.Source != "s3" {
t.Errorf("Source = %q, want s3", got.Source)
}
}
+93 -72
View File
@@ -1,9 +1,11 @@
package auth
import (
"Noooste/garage-ui/pkg/logger"
"context"
"crypto/subtle"
"encoding/base64"
"encoding/json"
"fmt"
"strings"
@@ -13,9 +15,10 @@ import (
"golang.org/x/oauth2"
)
// AuthService handles authentication operations
type AuthService struct {
config *config.AuthConfig
// Service handles authentication operations
type Service struct {
authConfig *config.AuthConfig
serverConfig *config.ServerConfig
oidcProvider *oidc.Provider
oidcVerifier *oidc.IDTokenVerifier
oauth2Config *oauth2.Config
@@ -31,19 +34,20 @@ type UserInfo struct {
}
// NewAuthService creates a new authentication service
func NewAuthService(cfg *config.AuthConfig) (*AuthService, error) {
jwtService, err := NewJWTService()
func NewAuthService(authCfg *config.AuthConfig, serverCfg *config.ServerConfig) (*Service, error) {
jwtService, err := NewJWTServiceWithKey(authCfg.JWTPrivKey)
if err != nil {
return nil, fmt.Errorf("failed to initialize JWT service: %w", err)
}
service := &AuthService{
config: cfg,
jwtService: jwtService,
service := &Service{
authConfig: authCfg,
serverConfig: serverCfg,
jwtService: jwtService,
}
// Initialize OIDC if enabled
if cfg.Mode == "oidc" && cfg.OIDC.Enabled {
if authCfg.OIDC.Enabled {
if err := service.initOIDC(); err != nil {
return nil, fmt.Errorf("failed to initialize OIDC: %w", err)
}
@@ -53,11 +57,11 @@ func NewAuthService(cfg *config.AuthConfig) (*AuthService, error) {
}
// initOIDC initializes the OIDC provider and configuration
func (a *AuthService) initOIDC() error {
func (a *Service) initOIDC() error {
ctx := context.Background()
// Create OIDC provider
provider, err := oidc.NewProvider(ctx, a.config.OIDC.IssuerURL)
provider, err := oidc.NewProvider(ctx, a.authConfig.OIDC.IssuerURL)
if err != nil {
return fmt.Errorf("failed to create OIDC provider: %w", err)
}
@@ -66,70 +70,46 @@ func (a *AuthService) initOIDC() error {
// Create ID token verifier
verifierConfig := &oidc.Config{
ClientID: a.config.OIDC.ClientID,
SkipIssuerCheck: a.config.OIDC.SkipIssuerCheck,
SkipExpiryCheck: a.config.OIDC.SkipExpiryCheck,
ClientID: a.authConfig.OIDC.ClientID,
SkipIssuerCheck: a.authConfig.OIDC.SkipIssuerCheck,
SkipExpiryCheck: a.authConfig.OIDC.SkipExpiryCheck,
}
a.oidcVerifier = provider.Verifier(verifierConfig)
// Construct redirect URL from server config
// Use root_url if set, otherwise construct from protocol/domain
redirectURL := a.serverConfig.RootURL + "/auth/oidc/callback"
// Create OAuth2 config
a.oauth2Config = &oauth2.Config{
ClientID: a.config.OIDC.ClientID,
ClientSecret: a.config.OIDC.ClientSecret,
ClientID: a.authConfig.OIDC.ClientID,
ClientSecret: a.authConfig.OIDC.ClientSecret,
RedirectURL: redirectURL,
Endpoint: provider.Endpoint(),
Scopes: a.config.OIDC.Scopes,
Scopes: a.authConfig.OIDC.Scopes,
}
return nil
}
// ValidateBasicAuth validates basic authentication credentials
func (a *AuthService) ValidateBasicAuth(username, password string) bool {
func (a *Service) ValidateBasicAuth(username, password string) bool {
// Use constant-time comparison to prevent timing attacks
usernameMatch := subtle.ConstantTimeCompare(
[]byte(username),
[]byte(a.config.Basic.Username),
[]byte(a.authConfig.Admin.Username),
) == 1
passwordMatch := subtle.ConstantTimeCompare(
[]byte(password),
[]byte(a.config.Basic.Password),
[]byte(a.authConfig.Admin.Password),
) == 1
return usernameMatch && passwordMatch
}
// ParseBasicAuth parses the Authorization header for basic auth
func ParseBasicAuth(authHeader string) (username, password string, ok bool) {
if authHeader == "" {
return "", "", false
}
// Check if it's a Basic auth header
const prefix = "Basic "
if !strings.HasPrefix(authHeader, prefix) {
return "", "", false
}
// Decode base64 credentials
encoded := authHeader[len(prefix):]
decoded, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
return "", "", false
}
// Split username:password
credentials := string(decoded)
parts := strings.SplitN(credentials, ":", 2)
if len(parts) != 2 {
return "", "", false
}
return parts[0], parts[1], true
}
// GetAuthorizationURL returns the OIDC authorization URL for login
func (a *AuthService) GetAuthorizationURL(state string) (string, error) {
func (a *Service) GetAuthorizationURL(state string) (string, error) {
if a.oauth2Config == nil {
return "", fmt.Errorf("OIDC not initialized")
}
@@ -138,7 +118,7 @@ func (a *AuthService) GetAuthorizationURL(state string) (string, error) {
}
// ExchangeCode exchanges an authorization code for tokens
func (a *AuthService) ExchangeCode(ctx context.Context, code string) (*oauth2.Token, error) {
func (a *Service) ExchangeCode(ctx context.Context, code string) (*oauth2.Token, error) {
if a.oauth2Config == nil {
return nil, fmt.Errorf("OIDC not initialized")
}
@@ -152,7 +132,7 @@ func (a *AuthService) ExchangeCode(ctx context.Context, code string) (*oauth2.To
}
// VerifyIDToken verifies an OIDC ID token and extracts user info
func (a *AuthService) VerifyIDToken(ctx context.Context, rawIDToken string) (*UserInfo, error) {
func (a *Service) VerifyIDToken(ctx context.Context, rawIDToken string) (*UserInfo, error) {
if a.oidcVerifier == nil {
return nil, fmt.Errorf("OIDC not initialized")
}
@@ -171,21 +151,21 @@ func (a *AuthService) VerifyIDToken(ctx context.Context, rawIDToken string) (*Us
// Extract user information using configured attributes
userInfo := &UserInfo{
Username: extractClaim(claims, a.config.OIDC.UsernameAttribute),
Email: extractClaim(claims, a.config.OIDC.EmailAttribute),
Name: extractClaim(claims, a.config.OIDC.NameAttribute),
Username: extractClaim(claims, a.authConfig.OIDC.UsernameAttribute),
Email: extractClaim(claims, a.authConfig.OIDC.EmailAttribute),
Name: extractClaim(claims, a.authConfig.OIDC.NameAttribute),
}
// Extract roles if configured
if a.config.OIDC.RoleAttributePath != "" {
userInfo.Roles = extractRoles(claims, a.config.OIDC.RoleAttributePath)
if a.authConfig.OIDC.RoleAttributePath != "" {
userInfo.Roles = extractRoles(claims, a.authConfig.OIDC.RoleAttributePath)
}
return userInfo, nil
}
// GetUserInfo retrieves user information from the OIDC provider
func (a *AuthService) GetUserInfo(ctx context.Context, token *oauth2.Token) (*UserInfo, error) {
func (a *Service) GetUserInfo(ctx context.Context, token *oauth2.Token) (*UserInfo, error) {
if a.oidcProvider == nil {
return nil, fmt.Errorf("OIDC not initialized")
}
@@ -205,29 +185,61 @@ func (a *AuthService) GetUserInfo(ctx context.Context, token *oauth2.Token) (*Us
return nil, fmt.Errorf("failed to parse user info claims: %w", err)
}
logger.Debug().Interface("claims", claims).Msg("Extracted user info claims")
// Build user info
userInfo := &UserInfo{
Username: extractClaim(claims, a.config.OIDC.UsernameAttribute),
Email: extractClaim(claims, a.config.OIDC.EmailAttribute),
Name: extractClaim(claims, a.config.OIDC.NameAttribute),
Username: extractClaim(claims, a.authConfig.OIDC.UsernameAttribute),
Email: extractClaim(claims, a.authConfig.OIDC.EmailAttribute),
Name: extractClaim(claims, a.authConfig.OIDC.NameAttribute),
}
// Extract roles if configured
if a.config.OIDC.RoleAttributePath != "" {
userInfo.Roles = extractRoles(claims, a.config.OIDC.RoleAttributePath)
if a.authConfig.OIDC.RoleAttributePath != "" {
userInfo.Roles = extractRoles(claims, a.authConfig.OIDC.RoleAttributePath)
}
return userInfo, nil
}
// ExtractRolesFromAccessToken parses the access token JWT payload and extracts
// roles using the configured role_attribute_path. Keycloak emits resource_access
// claims only in the access token by default, so this is required to support
// the common Keycloak client-role setup without extra mapper configuration.
//
// The access token was obtained via a verified code exchange with the provider,
// so parsing its claims without re-verifying the signature is safe here.
func (a *Service) ExtractRolesFromAccessToken(accessToken string) []string {
if accessToken == "" || a.authConfig.OIDC.RoleAttributePath == "" {
return nil
}
parts := strings.Split(accessToken, ".")
if len(parts) < 2 {
return nil
}
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return nil
}
var claims map[string]interface{}
if err := json.Unmarshal(payload, &claims); err != nil {
return nil
}
return extractRoles(claims, a.authConfig.OIDC.RoleAttributePath)
}
// IsAdmin checks if the user has admin role
func (a *AuthService) IsAdmin(userInfo *UserInfo) bool {
if a.config.OIDC.AdminRole == "" {
func (a *Service) IsAdmin(userInfo *UserInfo) bool {
if a.authConfig.OIDC.AdminRole == "" {
return false
}
for _, role := range userInfo.Roles {
if role == a.config.OIDC.AdminRole {
if role == a.authConfig.OIDC.AdminRole {
return true
}
}
@@ -309,22 +321,31 @@ func extractStringArray(value interface{}) []string {
}
// GenerateStateToken generates a secure CSRF state token
func (a *AuthService) GenerateStateToken() (string, error) {
func (a *Service) GenerateStateToken() (string, error) {
return a.jwtService.GenerateStateToken()
}
// ValidateAndConsumeState validates and consumes a CSRF state token
func (a *AuthService) ValidateAndConsumeState(token string) bool {
func (a *Service) ValidateAndConsumeState(token string) bool {
return a.jwtService.ValidateAndConsumeState(token)
}
// GenerateSessionToken generates a JWT session token for the user
func (a *AuthService) GenerateSessionToken(userInfo *UserInfo) (string, error) {
return a.jwtService.GenerateToken(userInfo, a.config.OIDC.SessionMaxAge)
// GenerateSessionToken generates a JWT session token for the user.
//
// SessionMaxAge lives under the OIDC config block, but the JWT is shared with
// admin-only deployments that never set it. Treat a non-positive value as
// "not configured" and fall back to 24h so the token isn't issued already
// expired (which would make every subsequent request fail with 401).
func (a *Service) GenerateSessionToken(userInfo *UserInfo) (string, error) {
maxAge := a.authConfig.OIDC.SessionMaxAge
if maxAge <= 0 {
maxAge = 86400
}
return a.jwtService.GenerateToken(userInfo, maxAge)
}
// ValidateSessionToken validates a JWT session token and returns user info
func (a *AuthService) ValidateSessionToken(tokenString string) (*UserInfo, error) {
func (a *Service) ValidateSessionToken(tokenString string) (*UserInfo, error) {
claims, err := a.jwtService.ValidateToken(tokenString)
if err != nil {
return nil, err
+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")
}
}
+556
View File
@@ -0,0 +1,556 @@
package auth
import (
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"Noooste/garage-ui/internal/config"
)
func TestExtractRolesFromAccessToken_Keycloak(t *testing.T) {
payload := map[string]interface{}{
"resource_access": map[string]interface{}{
"GarageAdminUi": map[string]interface{}{
"roles": []interface{}{"admin"},
},
"account": map[string]interface{}{
"roles": []interface{}{"view-profile"},
},
},
}
raw, err := json.Marshal(payload)
if err != nil {
t.Fatal(err)
}
token := "header." + base64.RawURLEncoding.EncodeToString(raw) + ".sig"
svc := &Service{
authConfig: &config.AuthConfig{
OIDC: config.OIDCConfig{
RoleAttributePath: "resource_access.GarageAdminUi.roles",
},
},
}
roles := svc.ExtractRolesFromAccessToken(token)
if len(roles) != 1 || roles[0] != "admin" {
t.Fatalf("expected [admin], got %v", roles)
}
}
func TestExtractRolesFromAccessToken_NoRoleClaim(t *testing.T) {
payload := map[string]interface{}{"sub": "user"}
raw, _ := json.Marshal(payload)
token := "header." + base64.RawURLEncoding.EncodeToString(raw) + ".sig"
svc := &Service{
authConfig: &config.AuthConfig{
OIDC: config.OIDCConfig{RoleAttributePath: "resource_access.GarageAdminUi.roles"},
},
}
if roles := svc.ExtractRolesFromAccessToken(token); roles != nil {
t.Fatalf("expected nil, got %v", roles)
}
}
func TestExtractRolesFromAccessToken_Malformed(t *testing.T) {
svc := &Service{
authConfig: &config.AuthConfig{
OIDC: config.OIDCConfig{RoleAttributePath: "resource_access.GarageAdminUi.roles"},
},
}
if roles := svc.ExtractRolesFromAccessToken("not-a-jwt"); roles != nil {
t.Fatalf("expected nil for malformed token, got %v", roles)
}
if roles := svc.ExtractRolesFromAccessToken(""); roles != nil {
t.Fatalf("expected nil for empty token, got %v", roles)
}
}
// Regression for issue #16: admin-only deployments never set
// OIDC.SessionMaxAge, leaving it at 0. Before the fix, that produced a JWT
// whose exp == iat, so every request after /auth/login was rejected as
// expired and the client saw UNAUTHORIZED.
func TestGenerateSessionToken_ZeroSessionMaxAge_IsNotImmediatelyExpired(t *testing.T) {
jwtSvc, err := NewJWTService()
if err != nil {
t.Fatalf("NewJWTService: %v", err)
}
svc := &Service{
authConfig: &config.AuthConfig{
Admin: config.AdminAuthConfig{Enabled: true, Username: "admin", Password: "pw"},
// OIDC disabled; SessionMaxAge left at zero value.
},
jwtService: jwtSvc,
}
token, err := svc.GenerateSessionToken(&UserInfo{Username: "admin"})
if err != nil {
t.Fatalf("GenerateSessionToken: %v", err)
}
if _, err := svc.ValidateSessionToken(token); err != nil {
t.Fatalf("freshly issued admin session token failed validation: %v", err)
}
}
// ---------------------------------------------------------------------------
// Task 5: ValidateBasicAuth
// (ParseBasicAuth was removed from production in commit d0040be; nothing to test.)
// ---------------------------------------------------------------------------
func TestValidateBasicAuth(t *testing.T) {
svc := &Service{
authConfig: &config.AuthConfig{
Admin: config.AdminAuthConfig{
Enabled: true,
Username: "admin",
Password: "correct-horse",
},
},
}
tests := []struct {
name string
user string
pass string
want bool
}{
{"correct credentials", "admin", "correct-horse", true},
{"wrong password", "admin", "nope", false},
{"wrong username", "root", "correct-horse", false},
{"both wrong", "x", "y", false},
{"empty username", "", "correct-horse", false},
{"empty password", "admin", "", false},
{"both empty", "", "", false},
{"username prefix attack", "admi", "correct-horse", false},
{"password prefix attack", "admin", "correct-hors", false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := svc.ValidateBasicAuth(tc.user, tc.pass); got != tc.want {
t.Errorf("ValidateBasicAuth(%q,%q) = %v, want %v", tc.user, tc.pass, got, tc.want)
}
})
}
}
func TestValidateBasicAuth_AdminDisabledStillComparesAgainstEmpty(t *testing.T) {
// When admin is disabled, the configured username/password are typically
// empty strings. ValidateBasicAuth itself does not gate on Enabled (that
// happens in middleware). Pin that behavior so a future refactor can't
// silently change semantics.
svc := &Service{
authConfig: &config.AuthConfig{
Admin: config.AdminAuthConfig{Enabled: false},
},
}
if !svc.ValidateBasicAuth("", "") {
t.Error("empty creds should match empty configured creds")
}
if svc.ValidateBasicAuth("anything", "") {
t.Error("non-empty user should not match empty configured user")
}
}
// ---------------------------------------------------------------------------
// Task 6: OIDC initOIDC cases
// ---------------------------------------------------------------------------
// newDiscoveryServer returns an httptest.Server that serves a minimal but
// valid OIDC discovery document and a JWKS endpoint (empty key set is fine
// for init — we are not verifying any token here). The discovery document's
// `issuer` field MUST equal the server's URL or oidc.NewProvider rejects it.
func newDiscoveryServer(t *testing.T) *httptest.Server {
t.Helper()
mux := http.NewServeMux()
var srv *httptest.Server
mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) {
doc := map[string]any{
"issuer": srv.URL,
"authorization_endpoint": srv.URL + "/auth",
"token_endpoint": srv.URL + "/token",
"jwks_uri": srv.URL + "/jwks",
"userinfo_endpoint": srv.URL + "/userinfo",
"id_token_signing_alg_values_supported": []string{"RS256", "EdDSA"},
"response_types_supported": []string{"code"},
"subject_types_supported": []string{"public"},
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(doc)
})
mux.HandleFunc("/jwks", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"keys":[]}`))
})
srv = httptest.NewServer(mux)
t.Cleanup(srv.Close)
return srv
}
func TestNewAuthService_OIDCDisabled_DoesNotInitProvider(t *testing.T) {
svc, err := NewAuthService(
&config.AuthConfig{
Admin: config.AdminAuthConfig{Enabled: true, Username: "u", Password: "p"},
OIDC: config.OIDCConfig{Enabled: false},
},
&config.ServerConfig{},
)
if err != nil {
t.Fatalf("NewAuthService: %v", err)
}
if svc.oidcProvider != nil {
t.Error("oidcProvider should be nil when OIDC disabled")
}
if svc.oidcVerifier != nil {
t.Error("oidcVerifier should be nil when OIDC disabled")
}
if svc.oauth2Config != nil {
t.Error("oauth2Config should be nil when OIDC disabled")
}
if svc.jwtService == nil {
t.Error("jwtService must always be initialized")
}
}
func TestNewAuthService_OIDCEnabled_DiscoversProvider(t *testing.T) {
disco := newDiscoveryServer(t)
authCfg := &config.AuthConfig{
OIDC: config.OIDCConfig{
Enabled: true,
ClientID: "test-client",
IssuerURL: disco.URL,
Scopes: []string{"openid", "profile"},
AdminRole: "admin",
},
}
srvCfg := &config.ServerConfig{
RootURL: "https://garage-ui.example",
}
svc, err := NewAuthService(authCfg, srvCfg)
if err != nil {
t.Fatalf("NewAuthService: %v", err)
}
if svc.oidcProvider == nil {
t.Fatal("oidcProvider not initialized")
}
if svc.oidcVerifier == nil {
t.Fatal("oidcVerifier not initialized")
}
if svc.oauth2Config == nil {
t.Fatal("oauth2Config not initialized")
}
if svc.oauth2Config.ClientID != "test-client" {
t.Errorf("ClientID = %q, want test-client", svc.oauth2Config.ClientID)
}
wantRedirect := "https://garage-ui.example/auth/oidc/callback"
if svc.oauth2Config.RedirectURL != wantRedirect {
t.Errorf("RedirectURL = %q, want %q", svc.oauth2Config.RedirectURL, wantRedirect)
}
if len(svc.oauth2Config.Scopes) != 2 {
t.Errorf("Scopes length = %d, want 2", len(svc.oauth2Config.Scopes))
}
// Endpoint should be wired from the discovery doc.
if svc.oauth2Config.Endpoint.AuthURL != disco.URL+"/auth" {
t.Errorf("Endpoint.AuthURL = %q", svc.oauth2Config.Endpoint.AuthURL)
}
if svc.oauth2Config.Endpoint.TokenURL != disco.URL+"/token" {
t.Errorf("Endpoint.TokenURL = %q", svc.oauth2Config.Endpoint.TokenURL)
}
}
func TestNewAuthService_OIDCEnabled_BadIssuerURLReturnsError(t *testing.T) {
authCfg := &config.AuthConfig{
OIDC: config.OIDCConfig{
Enabled: true,
ClientID: "test-client",
IssuerURL: "http://127.0.0.1:1", // refused — nothing listens on port 1
Scopes: []string{"openid"},
AdminRole: "admin",
},
}
srvCfg := &config.ServerConfig{RootURL: "https://garage-ui.example"}
_, err := NewAuthService(authCfg, srvCfg)
if err == nil {
t.Fatal("expected error for unreachable issuer, got nil")
}
if !strings.Contains(err.Error(), "failed to initialize OIDC") {
t.Errorf("expected wrapping error, got %v", err)
}
}
func TestGetAuthorizationURL_OIDCDisabledReturnsError(t *testing.T) {
svc := &Service{authConfig: &config.AuthConfig{}, serverConfig: &config.ServerConfig{}}
if _, err := svc.GetAuthorizationURL("state-x"); err == nil {
t.Error("expected error when OIDC not initialized")
}
}
func TestGetAuthorizationURL_OIDCEnabledIncludesState(t *testing.T) {
disco := newDiscoveryServer(t)
svc, err := NewAuthService(
&config.AuthConfig{
OIDC: config.OIDCConfig{
Enabled: true,
ClientID: "test-client",
IssuerURL: disco.URL,
Scopes: []string{"openid"},
AdminRole: "admin",
},
},
&config.ServerConfig{RootURL: "https://garage-ui.example"},
)
if err != nil {
t.Fatalf("NewAuthService: %v", err)
}
url, err := svc.GetAuthorizationURL("my-state-token")
if err != nil {
t.Fatalf("GetAuthorizationURL: %v", err)
}
if !strings.Contains(url, "state=my-state-token") {
t.Errorf("URL missing state param: %s", url)
}
if !strings.Contains(url, "client_id=test-client") {
t.Errorf("URL missing client_id: %s", url)
}
if !strings.Contains(url, "redirect_uri=") {
t.Errorf("URL missing redirect_uri: %s", url)
}
}
// ---------------------------------------------------------------------------
// Task 7: ValidateSessionToken and expanded ExtractRolesFromAccessToken
// ---------------------------------------------------------------------------
// newServiceWithJWT wires a Service with a real JWTService so the session
// helpers can be exercised end-to-end. OIDC is left disabled.
func newServiceWithJWT(t *testing.T) *Service {
t.Helper()
jwtSvc, err := NewJWTService()
if err != nil {
t.Fatalf("NewJWTService: %v", err)
}
return &Service{
authConfig: &config.AuthConfig{},
serverConfig: &config.ServerConfig{},
jwtService: jwtSvc,
}
}
func TestValidateSessionToken_HappyPath(t *testing.T) {
svc := newServiceWithJWT(t)
user := &UserInfo{
Username: "alice",
Email: "alice@example.com",
Name: "Alice",
Roles: []string{"admin"},
}
tok, err := svc.GenerateSessionToken(user)
if err != nil {
t.Fatalf("GenerateSessionToken: %v", err)
}
got, err := svc.ValidateSessionToken(tok)
if err != nil {
t.Fatalf("ValidateSessionToken: %v", err)
}
if got.Username != user.Username || got.Email != user.Email || got.Name != user.Name {
t.Errorf("got %+v, want %+v", got, user)
}
if len(got.Roles) != 1 || got.Roles[0] != "admin" {
t.Errorf("Roles = %v, want [admin]", got.Roles)
}
}
func TestValidateSessionToken_Expired(t *testing.T) {
svc := newServiceWithJWT(t)
// Bypass GenerateSessionToken's "fall back to 24h on non-positive" guard
// by going straight through the JWT service with a negative TTL.
tok, err := svc.jwtService.GenerateToken(&UserInfo{Username: "a"}, -1)
if err != nil {
t.Fatalf("GenerateToken: %v", err)
}
if _, err := svc.ValidateSessionToken(tok); err == nil {
t.Error("expected expired-token error, got nil")
}
}
func TestValidateSessionToken_BadSignatureRejected(t *testing.T) {
signer := newServiceWithJWT(t)
verifier := newServiceWithJWT(t)
tok, err := signer.GenerateSessionToken(&UserInfo{Username: "a"})
if err != nil {
t.Fatalf("GenerateSessionToken: %v", err)
}
if _, err := verifier.ValidateSessionToken(tok); err == nil {
t.Error("expected signature-mismatch error, got nil")
}
}
func TestValidateSessionToken_EmptyTokenRejected(t *testing.T) {
svc := newServiceWithJWT(t)
if _, err := svc.ValidateSessionToken(""); err == nil {
t.Error("expected error for empty token, got nil")
}
}
// makeAccessToken builds a JWT-shaped string with arbitrary claims. The
// signature segment is junk because ExtractRolesFromAccessToken does NOT
// verify the signature (per the doc-comment: "obtained via a verified code
// exchange, so parsing without re-verifying is safe").
func makeAccessToken(t *testing.T, claims map[string]any) string {
t.Helper()
raw, err := json.Marshal(claims)
if err != nil {
t.Fatalf("marshal: %v", err)
}
return "header." + base64.RawURLEncoding.EncodeToString(raw) + ".sig"
}
func TestExtractRolesFromAccessToken_DeeplyNestedPath(t *testing.T) {
tok := makeAccessToken(t, map[string]any{
"a": map[string]any{
"b": map[string]any{
"c": map[string]any{
"roles": []any{"r1", "r2", "r3"},
},
},
},
})
svc := &Service{
authConfig: &config.AuthConfig{
OIDC: config.OIDCConfig{RoleAttributePath: "a.b.c.roles"},
},
}
got := svc.ExtractRolesFromAccessToken(tok)
if len(got) != 3 || got[0] != "r1" || got[2] != "r3" {
t.Errorf("got %v, want [r1 r2 r3]", got)
}
}
func TestExtractRolesFromAccessToken_MixedTypeArrayDropsNonStrings(t *testing.T) {
tok := makeAccessToken(t, map[string]any{
"roles": []any{"admin", 42, "viewer", true, nil, "writer"},
})
svc := &Service{
authConfig: &config.AuthConfig{
OIDC: config.OIDCConfig{RoleAttributePath: "roles"},
},
}
got := svc.ExtractRolesFromAccessToken(tok)
want := []string{"admin", "viewer", "writer"}
if len(got) != len(want) {
t.Fatalf("got %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Errorf("got[%d] = %q, want %q", i, got[i], want[i])
}
}
}
func TestExtractRolesFromAccessToken_EmptyPathReturnsNil(t *testing.T) {
tok := makeAccessToken(t, map[string]any{"roles": []any{"admin"}})
svc := &Service{
authConfig: &config.AuthConfig{OIDC: config.OIDCConfig{RoleAttributePath: ""}},
}
if got := svc.ExtractRolesFromAccessToken(tok); got != nil {
t.Errorf("expected nil for empty path, got %v", got)
}
}
func TestExtractRolesFromAccessToken_IntermediateNodeNotMap(t *testing.T) {
// Path tries to descend through a string — extractRoles must bail with nil.
tok := makeAccessToken(t, map[string]any{
"resource_access": "this-should-be-a-map",
})
svc := &Service{
authConfig: &config.AuthConfig{
OIDC: config.OIDCConfig{RoleAttributePath: "resource_access.client.roles"},
},
}
if got := svc.ExtractRolesFromAccessToken(tok); got != nil {
t.Errorf("expected nil when path traverses non-map, got %v", got)
}
}
func TestExtractRolesFromAccessToken_FinalValueWrongType(t *testing.T) {
// Final value is a plain string, not an array — extractStringArray returns nil.
tok := makeAccessToken(t, map[string]any{
"roles": "admin",
})
svc := &Service{
authConfig: &config.AuthConfig{
OIDC: config.OIDCConfig{RoleAttributePath: "roles"},
},
}
if got := svc.ExtractRolesFromAccessToken(tok); got != nil {
t.Errorf("expected nil for non-array roles, got %v", got)
}
}
func TestExtractRolesFromAccessToken_BadBase64InPayload(t *testing.T) {
svc := &Service{
authConfig: &config.AuthConfig{
OIDC: config.OIDCConfig{RoleAttributePath: "roles"},
},
}
if got := svc.ExtractRolesFromAccessToken("hdr.!!!not-base64!!!.sig"); got != nil {
t.Errorf("expected nil for bad base64, got %v", got)
}
}
func TestExtractRolesFromAccessToken_BadJSONInPayload(t *testing.T) {
svc := &Service{
authConfig: &config.AuthConfig{
OIDC: config.OIDCConfig{RoleAttributePath: "roles"},
},
}
// Valid base64 of "not json"
tok := "hdr." + base64.RawURLEncoding.EncodeToString([]byte("not json")) + ".sig"
if got := svc.ExtractRolesFromAccessToken(tok); got != nil {
t.Errorf("expected nil for non-JSON payload, got %v", got)
}
}
// ---------------------------------------------------------------------------
// Task 8: IsAdmin coverage
// ---------------------------------------------------------------------------
func TestIsAdmin(t *testing.T) {
tests := []struct {
name string
adminRole string
userRoles []string
want bool
}{
{"empty admin role config returns false", "", []string{"admin"}, false},
{"user has admin role", "admin", []string{"viewer", "admin"}, true},
{"user lacks admin role", "admin", []string{"viewer"}, false},
{"user has no roles", "admin", nil, false},
{"role match is exact (case-sensitive)", "admin", []string{"Admin"}, false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
svc := &Service{
authConfig: &config.AuthConfig{
OIDC: config.OIDCConfig{AdminRole: tc.adminRole},
},
}
if got := svc.IsAdmin(&UserInfo{Roles: tc.userRoles}); got != tc.want {
t.Errorf("IsAdmin = %v, want %v", got, tc.want)
}
})
}
}
+86 -14
View File
@@ -1,8 +1,8 @@
package auth
import (
"crypto/ed25519"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/base64"
"encoding/pem"
@@ -14,9 +14,10 @@ import (
)
type JWTService struct {
privateKey *rsa.PrivateKey
publicKey *rsa.PublicKey
privateKey ed25519.PrivateKey
publicKey ed25519.PublicKey
stateStore *StateStore
mu sync.RWMutex
}
type StateStore struct {
@@ -38,20 +39,63 @@ type SessionClaims struct {
}
func NewJWTService() (*JWTService, error) {
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return nil, fmt.Errorf("failed to generate RSA key: %w", err)
return NewJWTServiceWithKey("")
}
func NewJWTServiceWithKey(privateKeyPEM string) (*JWTService, error) {
var privateKey ed25519.PrivateKey
var publicKey ed25519.PublicKey
var err error
if privateKeyPEM != "" {
// Parse the provided PEM-encoded private key
privateKey, err = parseEd25519PrivateKeyFromPEM(privateKeyPEM)
if err != nil {
return nil, fmt.Errorf("failed to parse Ed25519 private key: %w", err)
}
publicKey = privateKey.Public().(ed25519.PublicKey)
} else {
// Generate a new Ed25519 key pair if no key is provided
publicKey, privateKey, err = ed25519.GenerateKey(rand.Reader)
if err != nil {
return nil, fmt.Errorf("failed to generate Ed25519 key: %w", err)
}
}
return &JWTService{
privateKey: privateKey,
publicKey: &privateKey.PublicKey,
publicKey: publicKey,
stateStore: &StateStore{
states: make(map[string]StateData),
},
}, nil
}
func parseEd25519PrivateKeyFromPEM(privateKeyPEM string) (ed25519.PrivateKey, error) {
block, _ := pem.Decode([]byte(privateKeyPEM))
if block == nil {
return nil, fmt.Errorf("failed to decode PEM block")
}
// Try to parse as PKCS#8 format (standard format from openssl genpkey -algorithm ED25519)
key, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err == nil {
// Successfully parsed as PKCS#8, check if it's an Ed25519 key
if ed25519Key, ok := key.(ed25519.PrivateKey); ok {
return ed25519Key, nil
}
return nil, fmt.Errorf("PKCS#8 key is not an Ed25519 key")
}
// Fallback: Check if it's raw Ed25519 private key bytes (64 bytes)
if len(block.Bytes) == ed25519.PrivateKeySize {
return block.Bytes, nil
}
return nil, fmt.Errorf("invalid Ed25519 private key format: not PKCS#8 and not raw %d bytes (got %d bytes)",
ed25519.PrivateKeySize, len(block.Bytes))
}
func (j *JWTService) GenerateStateToken() (string, error) {
tokenBytes := make([]byte, 32)
if _, err := rand.Read(tokenBytes); err != nil {
@@ -105,6 +149,13 @@ func (j *JWTService) cleanupExpiredStates() {
}
func (j *JWTService) GenerateToken(userInfo *UserInfo, sessionMaxAge int) (string, error) {
j.mu.RLock()
defer j.mu.RUnlock()
if j.privateKey == nil {
return "", fmt.Errorf("private key not initialized")
}
now := time.Now()
expiresAt := now.Add(time.Duration(sessionMaxAge) * time.Second)
@@ -120,7 +171,7 @@ func (j *JWTService) GenerateToken(userInfo *UserInfo, sessionMaxAge int) (strin
},
}
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
token := jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims)
tokenString, err := token.SignedString(j.privateKey)
if err != nil {
return "", fmt.Errorf("failed to sign token: %w", err)
@@ -130,8 +181,15 @@ func (j *JWTService) GenerateToken(userInfo *UserInfo, sessionMaxAge int) (strin
}
func (j *JWTService) ValidateToken(tokenString string) (*SessionClaims, error) {
j.mu.RLock()
defer j.mu.RUnlock()
if j.publicKey == nil {
return nil, fmt.Errorf("public key not initialized")
}
token, err := jwt.ParseWithClaims(tokenString, &SessionClaims{}, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok {
if _, ok := token.Method.(*jwt.SigningMethodEd25519); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return j.publicKey, nil
@@ -149,15 +207,29 @@ func (j *JWTService) ValidateToken(tokenString string) (*SessionClaims, error) {
}
func (j *JWTService) GetPublicKeyPEM() (string, error) {
pubKeyBytes, err := x509.MarshalPKIXPublicKey(j.publicKey)
if err != nil {
return "", fmt.Errorf("failed to marshal public key: %w", err)
j.mu.RLock()
defer j.mu.RUnlock()
if j.publicKey == nil {
return "", fmt.Errorf("public key not initialized")
}
pubKeyPEM := pem.EncodeToMemory(&pem.Block{
Type: "RSA PUBLIC KEY",
Bytes: pubKeyBytes,
Type: "PUBLIC KEY",
Bytes: []byte(j.publicKey),
})
return string(pubKeyPEM), nil
}
// GetPublicKeyBase64 returns the base64url-encoded public key for JWKS
func (j *JWTService) GetPublicKeyBase64() (string, error) {
j.mu.RLock()
defer j.mu.RUnlock()
if j.publicKey == nil {
return "", fmt.Errorf("public key not initialized")
}
return base64.RawURLEncoding.EncodeToString(j.publicKey), nil
}
+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)
}
+47 -33
View File
@@ -19,10 +19,17 @@ type Config struct {
// ServerConfig contains server-related configuration
type ServerConfig struct {
Host string `mapstructure:"host"`
Port int `mapstructure:"port"`
Environment string `mapstructure:"environment"`
FrontendPath string `mapstructure:"frontend_path"` // Path to frontend dist directory
Host string `mapstructure:"host"`
Port int `mapstructure:"port"`
Environment string `mapstructure:"environment"`
FrontendPath string `mapstructure:"frontend_path"` // Path to frontend dist directory
Domain string `mapstructure:"domain"` // Domain name (e.g., garage-ui.example.com)
Protocol string `mapstructure:"protocol"` // Protocol for internal communication (http/https)
RootURL string `mapstructure:"root_url"` // Full external URL for redirects (e.g., https://garage-ui.example.com)
MaxBodySize int64 `mapstructure:"max_body_size"` // Maximum request body size in bytes (default: 300MB)
MaxHeaderSize int `mapstructure:"max_header_size"` // Maximum request header size in bytes (default: 1MB)
ReadBufferSize int `mapstructure:"read_buffer_size"` // Read buffer size in bytes (default: 4KB)
WriteBufferSize int `mapstructure:"write_buffer_size"` // Write buffer size in bytes (default: 4KB)
}
// GarageConfig contains Garage S3 connection settings
@@ -37,13 +44,14 @@ type GarageConfig struct {
// AuthConfig contains authentication configuration
type AuthConfig struct {
Mode string `mapstructure:"mode"` // "none", "basic", or "oidc"
Basic BasicAuthConfig `mapstructure:"basic"`
OIDC OIDCConfig `mapstructure:"oidc"`
Admin AdminAuthConfig `mapstructure:"admin"`
OIDC OIDCConfig `mapstructure:"oidc"`
JWTPrivKey string `mapstructure:"jwt_private_key"` // Ed25519 private key in PEM format for JWT signing (64 bytes)
}
// BasicAuthConfig contains basic authentication settings
type BasicAuthConfig struct {
// AdminAuthConfig contains admin authentication settings
type AdminAuthConfig struct {
Enabled bool `mapstructure:"enabled"`
Username string `mapstructure:"username"`
Password string `mapstructure:"password"`
}
@@ -56,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"`
@@ -107,8 +112,7 @@ func Load(configPath string) (*Config, error) {
viper.SetEnvPrefix("GARAGE_UI")
viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
// Bind environment variables to config keys
// This ensures env vars override config file values
// Env vars override config file values
bindEnvVars()
// Read the config file (optional - will use defaults and env vars if not found)
@@ -139,6 +143,13 @@ func bindEnvVars() {
viper.BindEnv("server.port", "GARAGE_UI_SERVER_PORT")
viper.BindEnv("server.environment", "GARAGE_UI_SERVER_ENVIRONMENT")
viper.BindEnv("server.frontend_path", "GARAGE_UI_SERVER_FRONTEND_PATH")
viper.BindEnv("server.domain", "GARAGE_UI_SERVER_DOMAIN")
viper.BindEnv("server.protocol", "GARAGE_UI_SERVER_PROTOCOL")
viper.BindEnv("server.root_url", "GARAGE_UI_SERVER_ROOT_URL")
viper.BindEnv("server.max_body_size", "GARAGE_UI_SERVER_MAX_BODY_SIZE")
viper.BindEnv("server.max_header_size", "GARAGE_UI_SERVER_MAX_HEADER_SIZE")
viper.BindEnv("server.read_buffer_size", "GARAGE_UI_SERVER_READ_BUFFER_SIZE")
viper.BindEnv("server.write_buffer_size", "GARAGE_UI_SERVER_WRITE_BUFFER_SIZE")
// Garage config
viper.BindEnv("garage.endpoint", "GARAGE_UI_GARAGE_ENDPOINT")
@@ -149,9 +160,10 @@ func bindEnvVars() {
viper.BindEnv("garage.admin_token", "GARAGE_UI_GARAGE_ADMIN_TOKEN")
// Auth config
viper.BindEnv("auth.mode", "GARAGE_UI_AUTH_MODE")
viper.BindEnv("auth.basic.username", "GARAGE_UI_AUTH_BASIC_USERNAME")
viper.BindEnv("auth.basic.password", "GARAGE_UI_AUTH_BASIC_PASSWORD")
viper.BindEnv("auth.admin.enabled", "GARAGE_UI_AUTH_ADMIN_ENABLED")
viper.BindEnv("auth.admin.username", "GARAGE_UI_AUTH_ADMIN_USERNAME")
viper.BindEnv("auth.admin.password", "GARAGE_UI_AUTH_ADMIN_PASSWORD")
viper.BindEnv("auth.jwt_private_key", "GARAGE_UI_AUTH_JWT_PRIVATE_KEY")
// OIDC config
viper.BindEnv("auth.oidc.enabled", "GARAGE_UI_AUTH_OIDC_ENABLED")
@@ -160,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")
@@ -208,28 +217,33 @@ func (c *Config) Validate() error {
return fmt.Errorf("garage admin_token is required")
}
// Validate auth mode
if c.Auth.Mode != "none" && c.Auth.Mode != "basic" && c.Auth.Mode != "oidc" {
return fmt.Errorf("auth mode must be 'none', 'basic', or 'oidc', got: %s", c.Auth.Mode)
}
// Validate basic auth if enabled
if c.Auth.Mode == "basic" {
if c.Auth.Basic.Username == "" || c.Auth.Basic.Password == "" {
return fmt.Errorf("basic auth username and password are required when auth mode is 'basic'")
// Validate admin auth if enabled
if c.Auth.Admin.Enabled {
if c.Auth.Admin.Username == "" || c.Auth.Admin.Password == "" {
return fmt.Errorf("admin auth username and password are required when admin auth is enabled")
}
}
// Validate OIDC config if enabled
if c.Auth.Mode == "oidc" {
if c.Auth.OIDC.Enabled {
if c.Auth.OIDC.ClientID == "" {
return fmt.Errorf("oidc client_id is required when auth mode is 'oidc'")
return fmt.Errorf("oidc client_id is required when oidc is enabled")
}
if c.Auth.OIDC.IssuerURL == "" {
return fmt.Errorf("oidc issuer_url is required when auth mode is 'oidc'")
return fmt.Errorf("oidc issuer_url is required when oidc is enabled")
}
if c.Server.RootURL == "" {
return fmt.Errorf("server.root_url is required when oidc is enabled")
}
if len(c.Auth.OIDC.Scopes) == 0 {
return fmt.Errorf("oidc scopes are required when auth mode is 'oidc'")
return fmt.Errorf("oidc scopes are required when oidc is enabled")
}
// Every authenticated route on this service grants full admin
// access — there is no separate authorization layer. An empty
// admin_role would therefore promote every user in the IdP realm
// to cluster admin. Require operators to opt in explicitly.
if c.Auth.OIDC.AdminRole == "" {
return fmt.Errorf("oidc admin_role is required when oidc is enabled: leaving it empty would grant cluster-admin access to any authenticated IdP user")
}
}
+396
View File
@@ -0,0 +1,396 @@
package config
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/spf13/viper"
)
// writeConfigFile writes yaml content to a temp path and returns it.
func writeConfigFile(t *testing.T, yaml string) string {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
if err := os.WriteFile(path, []byte(yaml), 0600); err != nil {
t.Fatalf("write config: %v", err)
}
return path
}
// resetViper clears all global viper state between tests.
func resetViper(t *testing.T) {
t.Helper()
viper.Reset()
}
// minimalValidYAML is the smallest configuration that passes Validate.
const minimalValidYAML = `
server:
host: "0.0.0.0"
port: 8080
environment: development
garage:
endpoint: http://garage:3900
admin_endpoint: http://garage:3903
admin_token: supersecret
`
func TestLoad_YAMLOnly(t *testing.T) {
resetViper(t)
path := writeConfigFile(t, minimalValidYAML)
cfg, err := Load(path)
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Server.Host != "0.0.0.0" {
t.Errorf("Server.Host = %q, want 0.0.0.0", cfg.Server.Host)
}
if cfg.Server.Port != 8080 {
t.Errorf("Server.Port = %d, want 8080", cfg.Server.Port)
}
if cfg.Server.Environment != "development" {
t.Errorf("Server.Environment = %q, want development", cfg.Server.Environment)
}
if cfg.Garage.Endpoint != "http://garage:3900" {
t.Errorf("Garage.Endpoint = %q", cfg.Garage.Endpoint)
}
if cfg.Garage.AdminToken != "supersecret" {
t.Errorf("Garage.AdminToken = %q", cfg.Garage.AdminToken)
}
}
func TestLoad_EnvOnly_MissingFile(t *testing.T) {
resetViper(t)
// Point at a path that definitely does not exist. Load tolerates missing
// files and falls back to env + viper defaults.
missing := filepath.Join(t.TempDir(), "does-not-exist.yaml")
// Every required field provided via env.
t.Setenv("GARAGE_UI_SERVER_PORT", "9090")
t.Setenv("GARAGE_UI_GARAGE_ENDPOINT", "http://g:3900")
t.Setenv("GARAGE_UI_GARAGE_ADMIN_ENDPOINT", "http://g:3903")
t.Setenv("GARAGE_UI_GARAGE_ADMIN_TOKEN", "env-token")
cfg, err := Load(missing)
if err != nil {
t.Fatalf("Load with env-only: %v", err)
}
if cfg.Server.Port != 9090 {
t.Errorf("Server.Port = %d, want 9090 (from env)", cfg.Server.Port)
}
if cfg.Garage.AdminToken != "env-token" {
t.Errorf("Garage.AdminToken = %q, want env-token", cfg.Garage.AdminToken)
}
}
func TestLoad_EnvOverridesYAML(t *testing.T) {
resetViper(t)
path := writeConfigFile(t, minimalValidYAML)
// YAML has port=8080; env should win.
t.Setenv("GARAGE_UI_SERVER_PORT", "9090")
t.Setenv("GARAGE_UI_GARAGE_ADMIN_TOKEN", "env-wins")
cfg, err := Load(path)
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Server.Port != 9090 {
t.Errorf("Server.Port = %d, want 9090 (env override)", cfg.Server.Port)
}
if cfg.Garage.AdminToken != "env-wins" {
t.Errorf("Garage.AdminToken = %q, want env-wins", cfg.Garage.AdminToken)
}
// Host was not overridden; YAML value should persist.
if cfg.Server.Host != "0.0.0.0" {
t.Errorf("Server.Host = %q, want 0.0.0.0 (from YAML)", cfg.Server.Host)
}
}
func TestLoad_MalformedYAMLReturnsError(t *testing.T) {
resetViper(t)
// Deliberately broken YAML: unindented key after a mapping start.
path := writeConfigFile(t, "server:\n port: 8080\n:: not: valid ::\n")
_, err := Load(path)
if err == nil {
t.Fatal("expected error for malformed YAML, got nil")
}
if !strings.Contains(err.Error(), "error reading config file") {
t.Errorf("unexpected error: %v", err)
}
}
func TestLoad_ValidationFailurePropagates(t *testing.T) {
resetViper(t)
// Valid YAML syntax but Garage.Endpoint is blank → Validate must fail.
path := writeConfigFile(t, `
server:
port: 8080
garage:
endpoint: ""
admin_endpoint: http://g:3903
admin_token: t
`)
_, err := Load(path)
if err == nil {
t.Fatal("expected validation error, got nil")
}
if !strings.Contains(err.Error(), "invalid configuration") {
t.Errorf("expected wrapped invalid-config error, got %v", err)
}
if !strings.Contains(err.Error(), "garage endpoint is required") {
t.Errorf("expected endpoint-required message, got %v", err)
}
}
// validBaseConfig returns a deep copy of a minimal Config that passes Validate.
func validBaseConfig() Config {
return Config{
Server: ServerConfig{Port: 8080},
Garage: GarageConfig{
Endpoint: "http://g:3900",
AdminEndpoint: "http://g:3903",
AdminToken: "t",
},
}
}
// applyValidOIDC fills OIDC with all required fields.
func applyValidOIDC(c *Config) {
c.Auth.OIDC.Enabled = true
c.Auth.OIDC.ClientID = "client-xyz"
c.Auth.OIDC.IssuerURL = "https://idp.example/realms/test"
c.Auth.OIDC.Scopes = []string{"openid"}
c.Auth.OIDC.AdminRole = "admin"
c.Server.RootURL = "https://garage-ui.example"
}
// Note on spec coverage: spec/2026-04-17-backend-test-suite-design.md lists
// "invalid log level/format" as a Validate case, but the current Validate does
// not check Logging.Level or Logging.Format. That's a code-vs-spec gap to
// resolve in a follow-up plan; Stage 2 tests the current behavior only.
func TestValidate(t *testing.T) {
tests := []struct {
name string
mutate func(*Config)
wantErrContains string // empty = expect no error
}{
{
name: "valid minimal config",
mutate: func(c *Config) {},
},
{
name: "port zero is invalid",
mutate: func(c *Config) { c.Server.Port = 0 },
wantErrContains: "invalid server port",
},
{
name: "port negative is invalid",
mutate: func(c *Config) { c.Server.Port = -1 },
wantErrContains: "invalid server port",
},
{
name: "port above 65535 is invalid",
mutate: func(c *Config) { c.Server.Port = 70000 },
wantErrContains: "invalid server port",
},
{
name: "port at 65535 is valid",
mutate: func(c *Config) { c.Server.Port = 65535 },
wantErrContains: "",
},
{
name: "missing garage endpoint",
mutate: func(c *Config) { c.Garage.Endpoint = "" },
wantErrContains: "garage endpoint is required",
},
{
name: "missing garage admin_endpoint",
mutate: func(c *Config) { c.Garage.AdminEndpoint = "" },
wantErrContains: "admin_endpoint is required",
},
{
name: "missing garage admin_token",
mutate: func(c *Config) { c.Garage.AdminToken = "" },
wantErrContains: "admin_token is required",
},
{
name: "admin auth enabled without username",
mutate: func(c *Config) {
c.Auth.Admin.Enabled = true
c.Auth.Admin.Password = "p"
},
wantErrContains: "admin auth username and password are required",
},
{
name: "admin auth enabled without password",
mutate: func(c *Config) {
c.Auth.Admin.Enabled = true
c.Auth.Admin.Username = "u"
},
wantErrContains: "admin auth username and password are required",
},
{
name: "admin auth enabled with both set is valid",
mutate: func(c *Config) {
c.Auth.Admin.Enabled = true
c.Auth.Admin.Username = "u"
c.Auth.Admin.Password = "p"
},
wantErrContains: "",
},
{
name: "admin auth disabled ignores missing credentials",
mutate: func(c *Config) {
c.Auth.Admin.Enabled = false
c.Auth.Admin.Username = ""
c.Auth.Admin.Password = ""
},
wantErrContains: "",
},
{
name: "oidc enabled without client_id",
mutate: func(c *Config) {
applyValidOIDC(c)
c.Auth.OIDC.ClientID = ""
},
wantErrContains: "oidc client_id is required",
},
{
name: "oidc enabled without issuer_url",
mutate: func(c *Config) {
applyValidOIDC(c)
c.Auth.OIDC.IssuerURL = ""
},
wantErrContains: "oidc issuer_url is required",
},
{
name: "oidc enabled without server.root_url",
mutate: func(c *Config) {
applyValidOIDC(c)
c.Server.RootURL = ""
},
wantErrContains: "server.root_url is required",
},
{
name: "oidc enabled without scopes",
mutate: func(c *Config) {
applyValidOIDC(c)
c.Auth.OIDC.Scopes = nil
},
wantErrContains: "oidc scopes are required",
},
{
name: "oidc enabled without admin_role rejected for safety",
mutate: func(c *Config) {
applyValidOIDC(c)
c.Auth.OIDC.AdminRole = ""
},
wantErrContains: "oidc admin_role is required",
},
{
name: "oidc fully configured is valid",
mutate: applyValidOIDC,
wantErrContains: "",
},
{
name: "oidc disabled ignores missing client_id",
mutate: func(c *Config) {
c.Auth.OIDC.Enabled = false
c.Auth.OIDC.ClientID = ""
},
wantErrContains: "",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
cfg := validBaseConfig()
tc.mutate(&cfg)
err := cfg.Validate()
if tc.wantErrContains == "" {
if err != nil {
t.Errorf("expected no error, got %v", err)
}
return
}
if err == nil {
t.Fatalf("expected error containing %q, got nil", tc.wantErrContains)
}
if !strings.Contains(err.Error(), tc.wantErrContains) {
t.Errorf("error %q does not contain %q", err.Error(), tc.wantErrContains)
}
})
}
}
func TestGetAddress(t *testing.T) {
tests := []struct {
host string
port int
want string
}{
{"localhost", 8080, "localhost:8080"},
{"0.0.0.0", 80, "0.0.0.0:80"},
{"", 443, ":443"},
}
for _, tc := range tests {
t.Run(tc.want, func(t *testing.T) {
cfg := &Config{Server: ServerConfig{Host: tc.host, Port: tc.port}}
if got := cfg.GetAddress(); got != tc.want {
t.Errorf("GetAddress() = %q, want %q", got, tc.want)
}
})
}
}
func TestIsDevelopment(t *testing.T) {
tests := []struct {
env string
want bool
}{
{"development", true},
{"production", false},
{"", false},
// Case-sensitive per current impl; lock in that behavior.
{"Development", false},
{"DEV", false},
}
for _, tc := range tests {
t.Run(tc.env, func(t *testing.T) {
cfg := &Config{Server: ServerConfig{Environment: tc.env}}
if got := cfg.IsDevelopment(); got != tc.want {
t.Errorf("IsDevelopment(%q) = %v, want %v", tc.env, got, tc.want)
}
})
}
}
func TestIsProduction(t *testing.T) {
tests := []struct {
env string
want bool
}{
{"production", true},
{"development", false},
{"", false},
{"Production", false},
{"PROD", false},
}
for _, tc := range tests {
t.Run(tc.env, func(t *testing.T) {
cfg := &Config{Server: ServerConfig{Environment: tc.env}}
if got := cfg.IsProduction(); got != tc.want {
t.Errorf("IsProduction(%q) = %v, want %v", tc.env, got, tc.want)
}
})
}
}
+155
View File
@@ -0,0 +1,155 @@
package handlers
import (
"Noooste/garage-ui/internal/auth"
"Noooste/garage-ui/internal/config"
"Noooste/garage-ui/internal/models"
"github.com/gofiber/fiber/v3"
)
// AuthHandler handles authentication-related requests
type AuthHandler struct {
cfg *config.Config
authService *auth.Service
}
// NewAuthHandler creates a new auth handler
func NewAuthHandler(cfg *config.Config, authService *auth.Service) *AuthHandler {
return &AuthHandler{
cfg: cfg,
authService: authService,
}
}
// GetAuthConfig returns the current authentication configuration
//
// @Summary Get authentication configuration
// @Description Returns the current auth configuration (admin and/or OIDC)
// @Tags auth
// @Produce json
// @Success 200 {object} object{admin=object,oidc=object} "Auth config"
// @Router /auth/config [get]
func (h *AuthHandler) GetAuthConfig(c fiber.Ctx) error {
response := fiber.Map{
"admin": fiber.Map{
"enabled": h.cfg.Auth.Admin.Enabled,
},
"oidc": fiber.Map{
"enabled": h.cfg.Auth.OIDC.Enabled,
},
}
// Add provider name if OIDC is enabled
if h.cfg.Auth.OIDC.Enabled {
provider := h.cfg.Auth.OIDC.ProviderName
if provider == "" {
provider = "OIDC Provider"
}
response["oidc"].(fiber.Map)["provider"] = provider
}
return c.JSON(response)
}
// LoginBasicRequest represents the basic auth login request
type LoginBasicRequest struct {
Username string `json:"username" validate:"required"`
Password string `json:"password" validate:"required"`
}
// LoginAdmin handles admin authentication login
//
// @Summary Admin auth login
// @Description Authenticate with admin username and password, returns JWT token
// @Tags auth
// @Accept json
// @Produce json
// @Param credentials body LoginBasicRequest true "Login credentials"
// @Success 200 {object} object{success=bool,token=string,user=object} "Login successful"
// @Failure 400 {object} models.APIResponse "Invalid request"
// @Failure 401 {object} models.APIResponse "Invalid credentials"
// @Router /auth/login [post]
func (h *AuthHandler) LoginAdmin(c fiber.Ctx) error {
// Parse request body
var req LoginBasicRequest
if err := c.Bind().JSON(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(
models.ErrorResponse(models.ErrCodeBadRequest, "Invalid request body"),
)
}
// Validate credentials against admin config
if req.Username != h.cfg.Auth.Admin.Username || req.Password != h.cfg.Auth.Admin.Password {
return c.Status(fiber.StatusUnauthorized).JSON(
models.ErrorResponse(models.ErrCodeUnauthorized, "Invalid credentials"),
)
}
// Create user info object
userInfo := &auth.UserInfo{
Username: req.Username,
}
// Generate JWT session token
sessionToken, err := h.authService.GenerateSessionToken(userInfo)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to create session"),
)
}
return c.JSON(fiber.Map{
"success": true,
"token": sessionToken,
"user": fiber.Map{
"username": userInfo.Username,
},
})
}
// GetMe returns the current authenticated user's information
//
// @Summary Get current user
// @Description Returns information about the currently authenticated user
// @Tags auth
// @Produce json
// @Security ApiKeyAuth
// @Success 200 {object} object{success=bool,user=object} "User information"
// @Failure 401 {object} models.APIResponse "Not authenticated"
// @Router /auth/me [get]
func (h *AuthHandler) GetMe(c fiber.Ctx) error {
// Try to get user info from OIDC context
userInfoInterface := c.Locals("userInfo")
if userInfoInterface != nil {
userInfo, ok := userInfoInterface.(*auth.UserInfo)
if ok {
return c.JSON(fiber.Map{
"success": true,
"user": fiber.Map{
"username": userInfo.Username,
"email": userInfo.Email,
"name": userInfo.Name,
},
})
}
}
// Try to get username from basic auth context
usernameInterface := c.Locals("username")
if usernameInterface != nil {
username, ok := usernameInterface.(string)
if ok {
return c.JSON(fiber.Map{
"success": true,
"user": fiber.Map{
"username": username,
},
})
}
}
return c.Status(fiber.StatusUnauthorized).JSON(
models.ErrorResponse(models.ErrCodeUnauthorized, "Not authenticated"),
)
}
+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)
}
}
+148 -64
View File
@@ -1,20 +1,21 @@
package handlers
import (
"Noooste/garage-ui/internal/apierr"
"Noooste/garage-ui/internal/models"
"Noooste/garage-ui/internal/services"
"github.com/gofiber/fiber/v3"
)
// BucketHandler handles bucket-related operations
// BucketHandler handles bucket-related HTTP requests.
type BucketHandler struct {
adminService *services.GarageAdminService
s3Service *services.S3Service
adminService services.AdminService
s3Service services.S3Storage
}
// NewBucketHandler creates a new bucket handler
func NewBucketHandler(adminService *services.GarageAdminService, s3Service *services.S3Service) *BucketHandler {
// NewBucketHandler creates a new bucket handler.
func NewBucketHandler(adminService services.AdminService, s3Service services.S3Storage) *BucketHandler {
return &BucketHandler{
adminService: adminService,
s3Service: s3Service,
@@ -37,9 +38,7 @@ func (h *BucketHandler) ListBuckets(c fiber.Ctx) error {
// List all buckets from Garage Admin API
adminBuckets, err := h.adminService.ListBuckets(ctx)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeListFailed, "Failed to list buckets: "+err.Error()),
)
return apierr.Respond(c, err)
}
// Convert admin bucket response to BucketInfo
@@ -54,19 +53,26 @@ func (h *BucketHandler) ListBuckets(c fiber.Ctx) error {
continue
}
bucketInfo := models.BucketInfo{
Name: bucketName,
CreationDate: adminBucket.Created,
Region: "", // Garage doesn't have regions
// Get detailed bucket info from Admin API to retrieve object count and size
detailedInfo, err := h.adminService.GetBucketInfoByAlias(ctx, bucketName)
if err != nil {
// If we can't get detailed info, return basic info without stats
buckets = append(buckets, models.BucketInfo{
Name: bucketName,
CreationDate: adminBucket.Created,
Region: "",
})
continue
}
// Try to get bucket statistics (object count and size)
// This is done asynchronously to avoid blocking the response
// If it fails, we still return the bucket info without stats
stats, err := h.s3Service.GetBucketStatistics(ctx, bucketName)
if err == nil && stats != nil {
bucketInfo.ObjectCount = &stats.ObjectCount
bucketInfo.Size = &stats.TotalSize
bucketInfo := models.BucketInfo{
Name: bucketName,
CreationDate: adminBucket.Created,
Region: "",
ObjectCount: &detailedInfo.Objects,
Size: &detailedInfo.Bytes,
WebsiteAccess: detailedInfo.WebsiteAccess,
WebsiteConfig: detailedInfo.WebsiteConfig,
}
buckets = append(buckets, bucketInfo)
@@ -111,28 +117,13 @@ func (h *BucketHandler) CreateBucket(c fiber.Ctx) error {
)
}
// Check if bucket already exists
bucketInfo, err := h.adminService.GetBucketInfoByAlias(ctx, req.Name)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to check bucket existence: "+err.Error()),
)
}
if bucketInfo != nil {
return c.Status(fiber.StatusConflict).JSON(
models.ErrorResponse(models.ErrCodeBucketExists, "Bucket already exists"),
)
}
// Create the bucket
createBucketReq := models.CreateBucketAdminRequest{
GlobalAlias: &req.Name,
}
if bucketInfo, err = h.adminService.CreateBucket(ctx, createBucketReq); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to create bucket: "+err.Error()),
)
if _, err := h.adminService.CreateBucket(ctx, createBucketReq); err != nil {
return apierr.Respond(c, err)
}
// Return success response
@@ -171,9 +162,7 @@ func (h *BucketHandler) DeleteBucket(c fiber.Ctx) error {
// Check if bucket already exists
bucketInfo, err := h.adminService.GetBucketInfoByAlias(ctx, bucketName)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to check bucket existence: "+err.Error()),
)
return apierr.Respond(c, err)
}
if bucketInfo == nil {
@@ -184,9 +173,7 @@ func (h *BucketHandler) DeleteBucket(c fiber.Ctx) error {
// Delete the bucket
if err := h.adminService.DeleteBucket(ctx, bucketInfo.ID); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeDeleteFailed, "Failed to delete bucket: "+err.Error()),
)
return apierr.Respond(c, err)
}
// Return success response
@@ -225,9 +212,7 @@ func (h *BucketHandler) GetBucketInfo(c fiber.Ctx) error {
// Check if bucket already exists
bucketInfo, err := h.adminService.GetBucketInfoByAlias(ctx, bucketName)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to check bucket existence: "+err.Error()),
)
return apierr.Respond(c, err)
}
if bucketInfo == nil {
@@ -282,9 +267,7 @@ func (h *BucketHandler) GrantBucketPermission(c fiber.Ctx) error {
// Get bucket info to retrieve bucket ID
bucketInfo, err := h.adminService.GetBucketInfoByAlias(ctx, bucketName)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to get bucket info: "+err.Error()),
)
return apierr.Respond(c, err)
}
if bucketInfo == nil {
@@ -293,23 +276,124 @@ 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 apierr.Respond(c, err)
}
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 apierr.Respond(c, err)
}
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 apierr.Respond(c, err)
}
result = r
}
return c.JSON(models.SuccessResponse(result))
}
// UpdateBucketWebsite updates the website access configuration for a bucket
//
// @Summary Update bucket website configuration
// @Description Enables or disables static website hosting for a bucket
// @Tags Buckets
// @Accept json
// @Produce json
// @Param name path string true "Name of the bucket"
// @Param request body models.UpdateBucketWebsiteRequest true "Website configuration"
// @Success 200 {object} models.APIResponse{data=models.GarageBucketInfo} "Website configuration updated"
// @Failure 400 {object} models.APIResponse{error=models.APIError} "Invalid request"
// @Failure 404 {object} models.APIResponse{error=models.APIError} "Bucket not found"
// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to update bucket"
// @Router /api/v1/buckets/{name}/website [put]
func (h *BucketHandler) UpdateBucketWebsite(c fiber.Ctx) error {
ctx := c.Context()
bucketName := c.Params("name")
if bucketName == "" {
return c.Status(fiber.StatusBadRequest).JSON(
models.ErrorResponse(models.ErrCodeBadRequest, "Bucket name is required"),
)
}
var req models.UpdateBucketWebsiteRequest
if err := c.Bind().JSON(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(
models.ErrorResponse(models.ErrCodeBadRequest, "Invalid request body: "+err.Error()),
)
}
if req.Enabled && req.IndexDocument == "" {
return c.Status(fiber.StatusBadRequest).JSON(
models.ErrorResponse(models.ErrCodeBadRequest, "indexDocument is required when enabling website access"),
)
}
bucketInfo, err := h.adminService.GetBucketInfoByAlias(ctx, bucketName)
if err != nil {
return apierr.Respond(c, err)
}
if bucketInfo == nil {
return c.Status(fiber.StatusNotFound).JSON(
models.ErrorResponse(models.ErrCodeBucketNotFound, "Bucket does not exist"),
)
}
websiteAccess := &models.UpdateBucketWebsiteAccess{
Enabled: req.Enabled,
}
if req.Enabled {
websiteAccess.IndexDocument = &req.IndexDocument
if req.ErrorDocument != "" {
websiteAccess.ErrorDocument = &req.ErrorDocument
}
}
updateReq := models.UpdateBucketRequest{
WebsiteAccess: websiteAccess,
}
result, err := h.adminService.UpdateBucket(ctx, bucketInfo.ID, updateReq)
if err != nil {
return apierr.Respond(c, err)
}
return c.JSON(models.SuccessResponse(result))
+463
View File
@@ -0,0 +1,463 @@
package handlers
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"Noooste/garage-ui/internal/apierr"
"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)
}
}
func TestDeleteBucket_UpstreamBucketNotEmpty(t *testing.T) {
app, admin := newBucketsTestApp(t)
admin.GetBucketInfoByAliasFn = func(_ context.Context, _ string) (*models.GarageBucketInfo, error) {
return &models.GarageBucketInfo{ID: "id-1"}, nil
}
admin.DeleteBucketFn = func(_ context.Context, _ string) error {
return &apierr.UpstreamError{
HTTPStatus: 409,
Code: "BucketNotEmpty",
Message: "Tried to delete a non-empty bucket",
Source: "garage",
}
}
resp, err := app.Test(httptest.NewRequest(http.MethodDelete, "/buckets/alpha", nil))
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusConflict {
t.Fatalf("status = %d, want 409", resp.StatusCode)
}
var body models.APIResponse
decodeJSON(t, resp.Body, &body)
if body.Error == nil {
t.Fatal("error missing from response body")
}
if body.Error.Code != models.ErrCodeBucketNotEmpty {
t.Errorf("code = %q, want %s", body.Error.Code, models.ErrCodeBucketNotEmpty)
}
if body.Error.Message != "Tried to delete a non-empty bucket" {
t.Errorf("message = %q", body.Error.Message)
}
}
+4 -4
View File
@@ -7,13 +7,13 @@ import (
"github.com/gofiber/fiber/v3"
)
// ClusterHandler handles cluster management operations
// ClusterHandler handles cluster-status HTTP requests.
type ClusterHandler struct {
adminService *services.GarageAdminService
adminService services.AdminService
}
// NewClusterHandler creates a new cluster handler
func NewClusterHandler(adminService *services.GarageAdminService) *ClusterHandler {
// NewClusterHandler creates a new cluster handler.
func NewClusterHandler(adminService services.AdminService) *ClusterHandler {
return &ClusterHandler{
adminService: adminService,
}
+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)
}
}
+5 -5
View File
@@ -7,14 +7,14 @@ import (
"github.com/gofiber/fiber/v3"
)
// MonitoringHandler handles monitoring operations
// MonitoringHandler handles metrics and dashboard HTTP requests.
type MonitoringHandler struct {
adminService *services.GarageAdminService
s3Service *services.S3Service
adminService services.AdminService
s3Service services.S3Storage
}
// NewMonitoringHandler creates a new monitoring handler
func NewMonitoringHandler(adminService *services.GarageAdminService, s3Service *services.S3Service) *MonitoringHandler {
// NewMonitoringHandler creates a new monitoring handler.
func NewMonitoringHandler(adminService services.AdminService, s3Service services.S3Storage) *MonitoringHandler {
return &MonitoringHandler{
adminService: adminService,
s3Service: s3Service,
@@ -0,0 +1,215 @@
package handlers
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"testing"
"time"
"Noooste/garage-ui/internal/models"
"Noooste/garage-ui/internal/services/mocks"
"github.com/gofiber/fiber/v3"
)
func newMonitoringTestApp(t *testing.T) (*fiber.App, *mocks.AdminMock) {
t.Helper()
admin := &mocks.AdminMock{}
h := NewMonitoringHandler(admin, nil) // s3Service unused by this handler
app := fiber.New()
app.Get("/monitoring/metrics", h.GetMetrics)
app.Get("/monitoring/admin-health", h.CheckAdminHealth)
app.Get("/monitoring/dashboard", h.GetDashboardMetrics)
return app, admin
}
func TestMonitoring_GetMetrics_PassesThroughAsPlainText(t *testing.T) {
app, admin := newMonitoringTestApp(t)
admin.GetMetricsFn = func(_ context.Context) (string, error) {
return "# HELP foo\nfoo 1\n", nil
}
req := httptest.NewRequest(http.MethodGet, "/monitoring/metrics", nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
if ct := resp.Header.Get("Content-Type"); ct != "text/plain; charset=utf-8" {
t.Errorf("Content-Type = %q", ct)
}
b, _ := io.ReadAll(resp.Body)
if string(b) != "# HELP foo\nfoo 1\n" {
t.Errorf("body = %q", b)
}
}
func TestMonitoring_GetMetrics_ServiceErrorReturns500JSON(t *testing.T) {
app, admin := newMonitoringTestApp(t)
admin.GetMetricsFn = func(_ context.Context) (string, error) {
return "", errors.New("scrape failed")
}
req := httptest.NewRequest(http.MethodGet, "/monitoring/metrics", nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusInternalServerError {
t.Fatalf("status = %d, want 500", resp.StatusCode)
}
}
func TestMonitoring_CheckAdminHealth_Healthy(t *testing.T) {
app, admin := newMonitoringTestApp(t)
admin.HealthCheckFn = func(_ context.Context) error { return nil }
req := httptest.NewRequest(http.MethodGet, "/monitoring/admin-health", nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
var body struct {
Success bool `json:"success"`
Data struct {
Status string `json:"status"`
Message string `json:"message"`
} `json:"data"`
}
_ = json.NewDecoder(resp.Body).Decode(&body)
if !body.Success || body.Data.Status != "healthy" {
t.Errorf("body = %+v", body)
}
}
func TestMonitoring_CheckAdminHealth_Unhealthy503(t *testing.T) {
app, admin := newMonitoringTestApp(t)
admin.HealthCheckFn = func(_ context.Context) error { return errors.New("down") }
req := httptest.NewRequest(http.MethodGet, "/monitoring/admin-health", nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusServiceUnavailable {
t.Fatalf("status = %d, want 503", resp.StatusCode)
}
}
func TestMonitoring_GetDashboardMetrics_AggregatesSizesAndPercentages(t *testing.T) {
app, admin := newMonitoringTestApp(t)
admin.ListBucketsFn = func(_ context.Context) ([]models.ListBucketsResponseItem, error) {
return []models.ListBucketsResponseItem{
{ID: "b1", Created: time.Unix(0, 0), GlobalAliases: []string{"alpha"}},
{ID: "b2", Created: time.Unix(0, 0), GlobalAliases: []string{"beta"}},
}, nil
}
admin.GetBucketInfoFn = func(_ context.Context, id string) (*models.GarageBucketInfo, error) {
switch id {
case "b1":
return &models.GarageBucketInfo{ID: "b1", Bytes: 300, Objects: 3}, nil
case "b2":
return &models.GarageBucketInfo{ID: "b2", Bytes: 100, Objects: 1}, nil
}
return nil, errors.New("unexpected id: " + id)
}
req := httptest.NewRequest(http.MethodGet, "/monitoring/dashboard", nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
var body struct {
Data models.DashboardMetrics `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
t.Fatalf("decode: %v", err)
}
if body.Data.TotalSize != 400 {
t.Errorf("TotalSize = %d, want 400", body.Data.TotalSize)
}
if body.Data.ObjectCount != 4 {
t.Errorf("ObjectCount = %d, want 4", body.Data.ObjectCount)
}
if body.Data.BucketCount != 2 {
t.Errorf("BucketCount = %d, want 2", body.Data.BucketCount)
}
if len(body.Data.UsageByBucket) != 2 {
t.Fatalf("UsageByBucket len = %d, want 2", len(body.Data.UsageByBucket))
}
// Percentages: 300/400 = 75, 100/400 = 25.
for _, u := range body.Data.UsageByBucket {
if u.BucketName == "alpha" && u.Percentage != 75 {
t.Errorf("alpha pct = %v, want 75", u.Percentage)
}
if u.BucketName == "beta" && u.Percentage != 25 {
t.Errorf("beta pct = %v, want 25", u.Percentage)
}
}
}
func TestMonitoring_GetDashboardMetrics_SkipsInaccessibleBuckets(t *testing.T) {
app, admin := newMonitoringTestApp(t)
admin.ListBucketsFn = func(_ context.Context) ([]models.ListBucketsResponseItem, error) {
return []models.ListBucketsResponseItem{
{ID: "good", GlobalAliases: []string{"good"}},
{ID: "bad", GlobalAliases: []string{"bad"}},
}, nil
}
admin.GetBucketInfoFn = func(_ context.Context, id string) (*models.GarageBucketInfo, error) {
if id == "bad" {
return nil, errors.New("access denied")
}
return &models.GarageBucketInfo{ID: "good", Bytes: 10, Objects: 1}, nil
}
req := httptest.NewRequest(http.MethodGet, "/monitoring/dashboard", nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
var body struct {
Data models.DashboardMetrics `json:"data"`
}
_ = json.NewDecoder(resp.Body).Decode(&body)
if body.Data.BucketCount != 2 {
t.Errorf("BucketCount = %d, want 2 (count includes inaccessible)", body.Data.BucketCount)
}
if len(body.Data.UsageByBucket) != 1 {
t.Errorf("UsageByBucket = %d, want 1 (inaccessible skipped)", len(body.Data.UsageByBucket))
}
}
func TestMonitoring_GetDashboardMetrics_ListBucketsErrorReturns500(t *testing.T) {
app, admin := newMonitoringTestApp(t)
admin.ListBucketsFn = func(_ context.Context) ([]models.ListBucketsResponseItem, error) {
return nil, errors.New("upstream")
}
req := httptest.NewRequest(http.MethodGet, "/monitoring/dashboard", nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusInternalServerError {
t.Fatalf("status = %d, want 500", resp.StatusCode)
}
}
+166 -48
View File
@@ -1,23 +1,81 @@
package handlers
import (
"bufio"
"io"
"net/url"
"path"
"strconv"
"strings"
"time"
"Noooste/garage-ui/internal/apierr"
"Noooste/garage-ui/internal/models"
"Noooste/garage-ui/internal/services"
"github.com/gofiber/fiber/v3"
)
// ObjectHandler handles object-related operations
type ObjectHandler struct {
s3Service *services.S3Service
// unsafeInlineContentTypes are MIME types that a browser can execute as
// JavaScript in the response's origin when rendered inline. Since the SPA is
// served from the same origin as the API, any uploader could otherwise plant
// stored XSS by uploading a file with one of these Content-Types.
var unsafeInlineContentTypes = map[string]struct{}{
"text/html": {},
"application/xhtml+xml": {},
"image/svg+xml": {},
"application/xml": {},
"text/xml": {},
"application/javascript": {},
"text/javascript": {},
}
// NewObjectHandler creates a new object handler
func NewObjectHandler(s3Service *services.S3Service) *ObjectHandler {
// safeContentType rewrites Content-Types that the browser would treat as
// executable to application/octet-stream.
func safeContentType(ct string) string {
base := strings.TrimSpace(strings.ToLower(ct))
if i := strings.IndexByte(base, ';'); i >= 0 {
base = strings.TrimSpace(base[:i])
}
if _, bad := unsafeInlineContentTypes[base]; bad {
return "application/octet-stream"
}
return ct
}
// contentDispositionHeader builds an RFC 6266 / RFC 5987 Content-Disposition
// header value with the user-controlled object key safely encoded. Strips
// path components and control characters before emitting the ASCII fallback,
// then appends the percent-encoded UTF-8 filename*= for full fidelity.
func contentDispositionHeader(disposition, key string) string {
name := path.Base(key)
if name == "." || name == "/" || name == "" {
name = "download"
}
// ASCII-safe fallback: drop anything that could break the quoted value.
var asciiFallback strings.Builder
for _, r := range name {
if r < 0x20 || r == 0x7f || r == '"' || r == '\\' || r > 0x7e {
asciiFallback.WriteByte('_')
continue
}
asciiFallback.WriteRune(r)
}
fallback := asciiFallback.String()
if fallback == "" {
fallback = "download"
}
encoded := url.PathEscape(name)
return disposition + "; filename=\"" + fallback + "\"; filename*=UTF-8''" + encoded
}
// ObjectHandler handles object-related HTTP requests.
type ObjectHandler struct {
s3Service services.S3Storage
}
// NewObjectHandler creates a new object handler.
func NewObjectHandler(s3Service services.S3Storage) *ObjectHandler {
return &ObjectHandler{
s3Service: s3Service,
}
@@ -65,9 +123,7 @@ func (h *ObjectHandler) ListObjects(c fiber.Ctx) error {
// List objects in the bucket
objects, err := h.s3Service.ListObjects(ctx, bucketName, prefix, maxKeys, continuationToken)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeListFailed, "Failed to list objects: "+err.Error()),
)
return apierr.Respond(c, err)
}
return c.JSON(models.SuccessResponse(objects))
@@ -129,14 +185,62 @@ func (h *ObjectHandler) UploadObject(c fiber.Ctx) error {
// Upload to Garage
uploadResult, err := h.s3Service.UploadObject(ctx, bucketName, key, fileHandle, contentType)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeUploadFailed, "Failed to upload object: "+err.Error()),
)
return apierr.Respond(c, err)
}
return c.Status(fiber.StatusCreated).JSON(models.SuccessResponse(uploadResult))
}
// 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 apierr.Respond(c, err)
}
return c.Status(fiber.StatusCreated).JSON(models.SuccessResponse(result))
}
// GetObject retrieves an object from a bucket
//
// @Summary Get object from bucket
@@ -154,9 +258,14 @@ func (h *ObjectHandler) UploadObject(c fiber.Ctx) error {
func (h *ObjectHandler) GetObject(c fiber.Ctx) error {
ctx := c.Context()
// Get bucket name and object key from URL parameters
// Get bucket name from URL parameters
bucketName := c.Params("bucket")
key := c.Params("key")
// Get object key from locals (set by route handler) or from params
key, ok := c.Locals("objectKey").(string)
if !ok || key == "" {
key = c.Params("key")
}
if bucketName == "" || key == "" {
return c.Status(fiber.StatusBadRequest).JSON(
@@ -167,25 +276,31 @@ func (h *ObjectHandler) GetObject(c fiber.Ctx) error {
// Get object from Garage
body, objectInfo, err := h.s3Service.GetObject(ctx, bucketName, key)
if err != nil {
return c.Status(fiber.StatusNotFound).JSON(
models.ErrorResponse(models.ErrCodeObjectNotFound, "Object not found: "+err.Error()),
)
return apierr.Respond(c, err)
}
defer body.Close()
// Set response headers
c.Set("Content-Type", objectInfo.ContentType)
c.Set("Content-Length", string(rune(objectInfo.Size)))
// The uploader controls Content-Type. Rewrite executable MIME types to
// application/octet-stream and always disable sniffing so stored HTML/SVG
// cannot run as XSS in the SPA origin when fetched inline.
c.Set("Content-Type", safeContentType(objectInfo.ContentType))
c.Set("X-Content-Type-Options", "nosniff")
c.Set("Content-Length", strconv.FormatInt(objectInfo.Size, 10))
c.Set("ETag", objectInfo.ETag)
c.Set("Last-Modified", objectInfo.LastModified.Format(time.RFC1123))
// Check if client wants to download or view inline
// The object key is attacker-controlled — build the header via the safe
// RFC 6266 helper to avoid quote/semicolon injection into filename=.
disposition := "inline"
if c.Query("download") == "true" {
c.Set("Content-Disposition", "attachment; filename=\""+key+"\"")
disposition = "attachment"
}
c.Set("Content-Disposition", contentDispositionHeader(disposition, key))
// Stream the object body to the client
return c.SendStream(body)
// Stream the object body to the client without buffering the entire file
return c.SendStreamWriter(func(w *bufio.Writer) {
defer body.Close()
io.Copy(w, body)
})
}
// DeleteObject deletes an object from a bucket
@@ -205,9 +320,14 @@ func (h *ObjectHandler) GetObject(c fiber.Ctx) error {
func (h *ObjectHandler) DeleteObject(c fiber.Ctx) error {
ctx := c.Context()
// Get bucket name and object key from URL parameters
// Get bucket name from URL parameters
bucketName := c.Params("bucket")
key := c.Params("key")
// Get object key from locals (set by route handler) or from params
key, ok := c.Locals("objectKey").(string)
if !ok || key == "" {
key = c.Params("key")
}
if bucketName == "" || key == "" {
return c.Status(fiber.StatusBadRequest).JSON(
@@ -218,9 +338,7 @@ func (h *ObjectHandler) DeleteObject(c fiber.Ctx) error {
// Check if object exists
exists, err := h.s3Service.ObjectExists(ctx, bucketName, key)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to check object existence: "+err.Error()),
)
return apierr.Respond(c, err)
}
if !exists {
@@ -231,9 +349,7 @@ func (h *ObjectHandler) DeleteObject(c fiber.Ctx) error {
// Delete the object
if err := h.s3Service.DeleteObject(ctx, bucketName, key); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeDeleteFailed, "Failed to delete object: "+err.Error()),
)
return apierr.Respond(c, err)
}
// Return success response
@@ -262,9 +378,14 @@ func (h *ObjectHandler) DeleteObject(c fiber.Ctx) error {
func (h *ObjectHandler) GetObjectMetadata(c fiber.Ctx) error {
ctx := c.Context()
// Get bucket name and object key from URL parameters
// Get bucket name from URL parameters
bucketName := c.Params("bucket")
key := c.Params("key")
// Get object key from locals (set by route handler) or from params
key, ok := c.Locals("objectKey").(string)
if !ok || key == "" {
key = c.Params("key")
}
if bucketName == "" || key == "" {
return c.Status(fiber.StatusBadRequest).JSON(
@@ -275,9 +396,7 @@ func (h *ObjectHandler) GetObjectMetadata(c fiber.Ctx) error {
// Get object metadata
metadata, err := h.s3Service.GetObjectMetadata(ctx, bucketName, key)
if err != nil {
return c.Status(fiber.StatusNotFound).JSON(
models.ErrorResponse(models.ErrCodeObjectNotFound, "Object not found: "+err.Error()),
)
return apierr.Respond(c, err)
}
return c.JSON(models.SuccessResponse(metadata))
@@ -301,9 +420,14 @@ func (h *ObjectHandler) GetObjectMetadata(c fiber.Ctx) error {
func (h *ObjectHandler) GetPresignedURL(c fiber.Ctx) error {
ctx := c.Context()
// Get bucket name and object key from URL parameters
// Get bucket name from URL parameters
bucketName := c.Params("bucket")
key := c.Params("key")
// Get object key from locals (set by route handler) or from params
key, ok := c.Locals("objectKey").(string)
if !ok || key == "" {
key = c.Params("key")
}
if bucketName == "" || key == "" {
return c.Status(fiber.StatusBadRequest).JSON(
@@ -330,9 +454,7 @@ func (h *ObjectHandler) GetPresignedURL(c fiber.Ctx) error {
// Check if object exists
exists, err := h.s3Service.ObjectExists(ctx, bucketName, key)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to check object existence: "+err.Error()),
)
return apierr.Respond(c, err)
}
if !exists {
@@ -344,9 +466,7 @@ func (h *ObjectHandler) GetPresignedURL(c fiber.Ctx) error {
// Generate pre-signed URL
url, err := h.s3Service.GetPresignedURL(ctx, bucketName, key, time.Duration(expiresIn)*time.Second)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to generate pre-signed URL: "+err.Error()),
)
return apierr.Respond(c, err)
}
response := models.PresignedURLResponse{
@@ -403,9 +523,7 @@ func (h *ObjectHandler) DeleteMultipleObjects(c fiber.Ctx) error {
// Delete multiple objects
if err := h.s3Service.DeleteMultipleObjects(ctx, bucketName, req.Keys); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeDeleteFailed, "Failed to delete objects: "+err.Error()),
)
return apierr.Respond(c, err)
}
response := models.ObjectDeleteMultipleResponse{
@@ -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)
}
}
})
}
}
+900
View File
@@ -0,0 +1,900 @@
package handlers
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"Noooste/garage-ui/internal/apierr"
"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_ServiceError500(t *testing.T) {
app, s3 := newObjectsTestApp(t)
s3.GetObjectMetadataFn = func(_ context.Context, _, _ string) (*models.ObjectInfo, error) {
return nil, errors.New("boom")
}
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/objects/nope/metadata", nil))
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
// Generic errors map to 500/INTERNAL_ERROR; typed upstream NoSuchKey → 404
// is covered by Task 12 tests.
if resp.StatusCode != http.StatusInternalServerError {
t.Fatalf("status = %d, want 500", resp.StatusCode)
}
}
// --- 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_ServiceErrorReturns500(t *testing.T) {
app, s3 := newObjectsTestApp(t)
s3.GetObjectFn = func(_ context.Context, _, _ string) (io.ReadCloser, *models.ObjectInfo, error) {
return nil, nil, errors.New("boom")
}
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/objects/nope", nil))
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
// Generic errors map to 500/INTERNAL_ERROR; typed upstream NoSuchKey → 404
// is covered by Task 12 tests.
if resp.StatusCode != http.StatusInternalServerError {
t.Fatalf("status = %d, want 500", resp.StatusCode)
}
}
// 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)
}
}
func TestDeleteObject_UpstreamNoSuchKey(t *testing.T) {
app, s3 := newObjectsTestApp(t)
s3.ObjectExistsFn = func(_ context.Context, _, _ string) (bool, error) { return true, nil }
s3.DeleteObjectFn = func(_ context.Context, _, _ string) error {
return &apierr.UpstreamError{
HTTPStatus: 404,
Code: "NoSuchKey",
Message: "The specified key does not exist",
Source: "s3",
}
}
resp, err := app.Test(httptest.NewRequest(http.MethodDelete, "/buckets/b1/objects/foo.txt", nil))
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("status = %d, want 404", resp.StatusCode)
}
var body models.APIResponse
decodeJSON(t, resp.Body, &body)
if body.Error == nil || body.Error.Code != models.ErrCodeObjectNotFound {
t.Fatalf("error = %+v, want code %s", body.Error, models.ErrCodeObjectNotFound)
}
if body.Error.Message != "The specified key does not exist" {
t.Errorf("message = %q", body.Error.Message)
}
}
+43 -19
View File
@@ -3,19 +3,20 @@ package handlers
import (
"time"
"Noooste/garage-ui/internal/apierr"
"Noooste/garage-ui/internal/models"
"Noooste/garage-ui/internal/services"
"github.com/gofiber/fiber/v3"
)
// UserHandler handles user/key management operations using Garage Admin API
// UserHandler handles user and access key HTTP requests.
type UserHandler struct {
adminService *services.GarageAdminService
adminService services.AdminService
}
// NewUserHandler creates a new user handler
func NewUserHandler(adminService *services.GarageAdminService) *UserHandler {
// NewUserHandler creates a new user handler.
func NewUserHandler(adminService services.AdminService) *UserHandler {
return &UserHandler{
adminService: adminService,
}
@@ -35,9 +36,7 @@ func (h *UserHandler) ListUsers(c fiber.Ctx) error {
keys, err := h.adminService.ListKeys(ctx)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to list users: "+err.Error()),
)
return apierr.Respond(c, err)
}
// Convert to UserInfo format
@@ -135,9 +134,7 @@ func (h *UserHandler) CreateUser(c fiber.Ctx) error {
// Create the key
keyInfo, err := h.adminService.CreateKey(ctx, createReq)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to create user: "+err.Error()),
)
return apierr.Respond(c, err)
}
// Convert bucket permissions to frontend format
@@ -188,9 +185,7 @@ func (h *UserHandler) DeleteUser(c fiber.Ctx) error {
// Delete the key
err := h.adminService.DeleteKey(ctx, accessKey)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to delete user: "+err.Error()),
)
return apierr.Respond(c, err)
}
return c.JSON(models.SuccessResponse(map[string]interface{}{
@@ -223,9 +218,7 @@ func (h *UserHandler) GetUser(c fiber.Ctx) error {
// Get key information (without secret key)
keyInfo, err := h.adminService.GetKeyInfo(ctx, accessKey, false)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to get user info: "+err.Error()),
)
return apierr.Respond(c, err)
}
// Convert bucket permissions to frontend format
@@ -251,6 +244,39 @@ func (h *UserHandler) GetUser(c fiber.Ctx) error {
return c.JSON(models.SuccessResponse(userInfo))
}
// GetUserSecretKey retrieves the secret key for a specific user/access key
//
// @Summary Get user secret key
// @Description Retrieves the secret access key for a specific user/access key
// @Tags Users
// @Produce json
// @Param access_key path string true "Access key of the user to retrieve secret for"
// @Success 200 {object} models.APIResponse{data=map[string]string} "Secret key retrieved successfully"
// @Failure 400 {object} models.APIResponse{error=models.APIError} "Access key is required"
// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to get secret key"
// @Router /api/v1/users/{access_key}/secret [get]
func (h *UserHandler) GetUserSecretKey(c fiber.Ctx) error {
ctx := c.Context()
accessKey := c.Params("access_key")
if accessKey == "" {
return c.Status(fiber.StatusBadRequest).JSON(
models.ErrorResponse(models.ErrCodeBadRequest, "Access key is required"),
)
}
// Get key information WITH secret key
keyInfo, err := h.adminService.GetKeyInfo(ctx, accessKey, true)
if err != nil {
return apierr.Respond(c, err)
}
// Return only the secret key
return c.JSON(models.SuccessResponse(map[string]string{
"secretKey": *keyInfo.SecretAccessKey,
}))
}
// UpdateUserPermissions updates user permissions
//
// @Summary Update user permissions
@@ -312,9 +338,7 @@ func (h *UserHandler) UpdateUserPermissions(c fiber.Ctx) error {
// Update the key
keyInfo, err := h.adminService.UpdateKey(ctx, accessKey, updateReq)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to update user: "+err.Error()),
)
return apierr.Respond(c, err)
}
// Convert bucket permissions to frontend format
+396
View File
@@ -0,0 +1,396 @@
package handlers
import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"Noooste/garage-ui/internal/apierr"
"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)
}
}
func TestDeleteUser_UpstreamAccessDenied(t *testing.T) {
app, admin := newUsersTestApp(t)
admin.DeleteKeyFn = func(_ context.Context, _ string) error {
return &apierr.UpstreamError{
HTTPStatus: 403,
Code: "AccessDenied",
Message: "permission denied",
Source: "garage",
}
}
resp, err := app.Test(httptest.NewRequest(http.MethodDelete, "/users/abc", nil))
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("status = %d, want 403", resp.StatusCode)
}
var body models.APIResponse
decodeJSON(t, resp.Body, &body)
if body.Error == nil || body.Error.Code != models.ErrCodeForbidden {
t.Fatalf("error = %+v, want code %s", body.Error, models.ErrCodeForbidden)
}
if body.Error.Message != "permission denied" {
t.Errorf("message = %q", body.Error.Message)
}
}
+64 -104
View File
@@ -4,130 +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 returns a Fiber middleware for authentication
// It handles different auth modes: none, basic, and OIDC
func AuthMiddleware(cfg *config.AuthConfig, authService *auth.AuthService) fiber.Handler {
// AuthMiddleware supports admin and OIDC authentication. On success it
// enriches the per-request logger (stored in c.Context() by the Logging
// middleware) with user_id and auth_method so downstream service log lines
// carry user identity. On failure it emits a warn log with the auth_method
// tried and a reason — never the token value.
func AuthMiddleware(cfg *config.AuthConfig, authService *auth.Service) fiber.Handler {
return func(c fiber.Ctx) error {
// If auth mode is "none", allow all requests
if cfg.Mode == "none" {
// If no auth is enabled, allow all requests.
if !cfg.Admin.Enabled && !cfg.OIDC.Enabled {
return c.Next()
}
// Handle basic authentication
if cfg.Mode == "basic" {
return handleBasicAuth(c, authService)
authHeader := c.Get("Authorization")
// Try admin auth if enabled and header is present.
if cfg.Admin.Enabled && authHeader != "" {
if len(authHeader) > 7 && authHeader[:7] == "Bearer " {
token := authHeader[7:]
userInfo, err := authService.ValidateSessionToken(token)
if err == nil {
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()
}
}
}
// Handle OIDC authentication
if cfg.Mode == "oidc" {
return handleOIDCAuth(c, authService, &cfg.OIDC)
// Try OIDC auth if enabled.
if cfg.OIDC.Enabled {
sessionCookie := c.Cookies(cfg.OIDC.CookieName)
if sessionCookie != "" {
userInfo, err := authService.ValidateSessionToken(sessionCookie)
if err == nil {
c.Locals("userInfo", userInfo)
c.Locals("username", userInfo.Username)
c.Locals("email", userInfo.Email)
enrichRequestLogger(c, userInfo.Username, "oidc")
return c.Next()
}
}
}
// Unknown auth mode - deny access
return c.Status(fiber.StatusUnauthorized).JSON(
models.ErrorResponse(models.ErrCodeUnauthorized, "Invalid authentication mode"),
)
}
}
// 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")
// handleBasicAuth validates basic authentication credentials
func handleBasicAuth(c fiber.Ctx, authService *auth.AuthService) error {
// Get Authorization header
authHeader := c.Get("Authorization")
if authHeader == "" {
c.Set("WWW-Authenticate", `Basic realm="Restricted"`)
return c.Status(fiber.StatusUnauthorized).JSON(
models.ErrorResponse(models.ErrCodeUnauthorized, "Authorization header required"),
)
}
// Parse basic auth credentials
username, password, ok := auth.ParseBasicAuth(authHeader)
if !ok {
c.Set("WWW-Authenticate", `Basic realm="Restricted"`)
return c.Status(fiber.StatusUnauthorized).JSON(
models.ErrorResponse(models.ErrCodeUnauthorized, "Invalid Authorization header format"),
)
}
// Validate credentials
if !authService.ValidateBasicAuth(username, password) {
c.Set("WWW-Authenticate", `Basic realm="Restricted"`)
return c.Status(fiber.StatusUnauthorized).JSON(
models.ErrorResponse(models.ErrCodeUnauthorized, "Invalid credentials"),
)
}
// Store username in context for later use
c.Locals("username", username)
return c.Next()
}
// handleOIDCAuth validates OIDC session/token
func handleOIDCAuth(c fiber.Ctx, authService *auth.AuthService, oidcCfg *config.OIDCConfig) error {
// Get session cookie
sessionCookie := c.Cookies(oidcCfg.CookieName)
if sessionCookie == "" {
return c.Status(fiber.StatusUnauthorized).JSON(
models.ErrorResponse(models.ErrCodeUnauthorized, "Authentication required"),
)
}
// Validate JWT session token
userInfo, err := authService.ValidateSessionToken(sessionCookie)
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(
models.ErrorResponse(models.ErrCodeUnauthorized, "Invalid or expired session"),
)
}
// Store user info in context for handlers to use
c.Locals("userInfo", userInfo)
c.Locals("username", userInfo.Username)
c.Locals("email", userInfo.Email)
return c.Next()
}
func RequireAuth(cfg *config.AuthConfig) fiber.Handler {
return func(c fiber.Ctx) error {
if cfg.Mode == "none" {
return c.Status(fiber.StatusForbidden).JSON(
models.ErrorResponse(models.ErrCodeForbidden, "Authentication is required but not configured"),
)
}
return c.Next()
}
// enrichRequestLogger rebinds the per-request logger in c.Context() with
// user_id and auth_method. Subsequent logpkg.FromCtx(c.Context()) calls
// return the enriched logger.
func enrichRequestLogger(c fiber.Ctx, userID, authMethod string) {
l := logpkg.FromCtx(c.Context()).With().
Str("user_id", userID).
Str("auth_method", authMethod).
Logger()
c.Locals(LoggerLocalsKey, l)
c.SetContext(logpkg.IntoCtx(c.Context(), l))
}
func RequireAdmin(authService *auth.AuthService) fiber.Handler {
return func(c fiber.Ctx) error {
userInfoInterface := c.Locals("userInfo")
if userInfoInterface == nil {
return c.Status(fiber.StatusForbidden).JSON(
models.ErrorResponse(models.ErrCodeForbidden, "Admin access required"),
)
}
userInfo, ok := userInfoInterface.(*auth.UserInfo)
if !ok {
return c.Status(fiber.StatusForbidden).JSON(
models.ErrorResponse(models.ErrCodeForbidden, "Admin access required"),
)
}
// Check if user has admin role
if !authService.IsAdmin(userInfo) {
return c.Status(fiber.StatusForbidden).JSON(
models.ErrorResponse(models.ErrCodeForbidden, "Admin role required"),
)
}
return c.Next()
func authMethodsEnabled(cfg *config.AuthConfig) string {
switch {
case cfg.Admin.Enabled && cfg.OIDC.Enabled:
return "admin+oidc"
case cfg.Admin.Enabled:
return "admin"
case cfg.OIDC.Enabled:
return "oidc"
default:
return "none"
}
}
+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"])
}
}
+15 -6
View File
@@ -1,6 +1,7 @@
package middleware
import (
"strconv"
"strings"
"Noooste/garage-ui/internal/config"
@@ -20,10 +21,14 @@ func CORSMiddleware(cfg *config.CORSConfig) fiber.Handler {
return func(c fiber.Ctx) error {
origin := c.Get("Origin")
// Check if origin is allowed
if origin != "" && isAllowedOrigin(origin, cfg.AllowedOrigins) {
// Check if origin is allowed. When credentials are allowed we refuse
// to treat "*" as a match: reflecting an arbitrary Origin alongside
// Access-Control-Allow-Credentials: true lets any site read responses
// cross-origin with the user's session cookie.
if origin != "" && isAllowedOrigin(origin, cfg.AllowedOrigins, cfg.AllowCredentials) {
// Set CORS headers
c.Set("Access-Control-Allow-Origin", origin)
c.Set("Vary", "Origin")
if cfg.AllowCredentials {
c.Set("Access-Control-Allow-Credentials", "true")
@@ -41,7 +46,7 @@ func CORSMiddleware(cfg *config.CORSConfig) fiber.Handler {
// Set max age for preflight cache
if cfg.MaxAge > 0 {
c.Set("Access-Control-Max-Age", string(rune(cfg.MaxAge)))
c.Set("Access-Control-Max-Age", strconv.Itoa(cfg.MaxAge))
}
}
@@ -54,10 +59,14 @@ func CORSMiddleware(cfg *config.CORSConfig) fiber.Handler {
}
}
// isAllowedOrigin checks if an origin is in the allowed list
func isAllowedOrigin(origin string, allowedOrigins []string) bool {
// isAllowedOrigin checks if an origin is in the allowed list.
// When allowCredentials is true, "*" is NOT honored — exact match is required.
func isAllowedOrigin(origin string, allowedOrigins []string, allowCredentials bool) bool {
for _, allowed := range allowedOrigins {
if allowed == "*" || allowed == origin {
if allowed == origin {
return true
}
if allowed == "*" && !allowCredentials {
return true
}
}
+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)
}
}
@@ -2,10 +2,6 @@ package models
import "time"
// ====================================
// Access Key Models
// ====================================
// GarageKeyInfo represents detailed information about a Garage access key
type GarageKeyInfo struct {
AccessKeyID string `json:"accessKeyId"`
@@ -72,10 +68,6 @@ type ListKeysResponseItem struct {
Expiration *time.Time `json:"expiration,omitempty"`
}
// ====================================
// Bucket Models (Admin API)
// ====================================
// GarageBucketInfo represents detailed information about a bucket from Admin API
type GarageBucketInfo struct {
ID string `json:"id"`
@@ -153,10 +145,6 @@ type BucketLocalAlias struct {
Alias string `json:"alias"`
}
// ====================================
// Bucket Alias Models
// ====================================
// AddBucketAliasRequest represents the request to add a bucket alias
type AddBucketAliasRequest struct {
BucketID string `json:"bucketId"`
@@ -173,10 +161,6 @@ type RemoveBucketAliasRequest struct {
AccessKeyID *string `json:"accessKeyId,omitempty"`
}
// ====================================
// Permission Models
// ====================================
// BucketKeyPermRequest represents a request to change bucket-key permissions
type BucketKeyPermRequest struct {
BucketID string `json:"bucketId"`
@@ -184,10 +168,6 @@ type BucketKeyPermRequest struct {
Permissions BucketKeyPermission `json:"permissions"`
}
// ====================================
// Cluster Models
// ====================================
// ClusterHealth represents the health status of the cluster
type ClusterHealth struct {
Status string `json:"status"`
@@ -211,10 +191,6 @@ type ClusterStatistics struct {
Freeform string `json:"freeform"`
}
// ====================================
// Node Models
// ====================================
// NodeInfo represents information about a cluster node
type NodeInfo struct {
ID string `json:"id"`
+7 -37
View File
@@ -12,50 +12,20 @@ type GrantBucketPermissionRequest struct {
Permissions BucketKeyPermission `json:"permissions" validate:"required"`
}
// DeleteBucketRequest represents a request to delete a bucket
type DeleteBucketRequest struct {
Name string `json:"name" validate:"required"`
}
// ListObjectsRequest represents a request to list objects in a bucket
type ListObjectsRequest struct {
Bucket string `json:"bucket" validate:"required"`
Prefix string `json:"prefix,omitempty"`
MaxKeys int `json:"max_keys,omitempty"`
Marker string `json:"marker,omitempty"`
}
// UploadObjectRequest represents metadata for an object upload
type UploadObjectRequest struct {
Bucket string `json:"bucket" validate:"required"`
Key string `json:"key" validate:"required"`
ContentType string `json:"content_type,omitempty"`
}
// DeleteObjectRequest represents a request to delete an object
type DeleteObjectRequest struct {
Bucket string `json:"bucket" validate:"required"`
Key string `json:"key" validate:"required"`
}
// GetObjectRequest represents a request to get/download an object
type GetObjectRequest struct {
Bucket string `json:"bucket" validate:"required"`
Key string `json:"key" validate:"required"`
}
// CreateUserRequest represents a request to create a new user/key
type CreateUserRequest struct {
Name string `json:"name,omitempty"`
}
// DeleteUserRequest represents a request to delete a user/key
type DeleteUserRequest struct {
AccessKey string `json:"access_key" validate:"required"`
}
// UpdateUserRequest represents a request to update user permissions
type UpdateUserRequest struct {
Status *string `json:"status,omitempty"` // "active" or "inactive"
Expiration *string `json:"expiration,omitempty"` // ISO 8601 date string
}
// UpdateBucketWebsiteRequest represents a request to update bucket website configuration
type UpdateBucketWebsiteRequest struct {
Enabled bool `json:"enabled"`
IndexDocument string `json:"indexDocument,omitempty"`
ErrorDocument string `json:"errorDocument,omitempty"`
}
+17 -19
View File
@@ -40,11 +40,13 @@ type HealthResponse struct {
// BucketInfo represents information about a bucket
type BucketInfo struct {
Name string `json:"name"`
CreationDate time.Time `json:"creation_date"`
ObjectCount *int64 `json:"object_count,omitempty"`
Size *int64 `json:"size,omitempty"`
Region string `json:"region,omitempty"`
Name string `json:"name"`
CreationDate time.Time `json:"creationDate"`
ObjectCount *int64 `json:"objectCount,omitempty"`
Size *int64 `json:"size,omitempty"`
Region string `json:"region,omitempty"`
WebsiteAccess bool `json:"websiteAccess"`
WebsiteConfig *BucketWebsiteConfig `json:"websiteConfig,omitempty"`
}
// BucketListResponse represents a list of buckets
@@ -55,12 +57,13 @@ type BucketListResponse struct {
// ObjectInfo represents information about an object
type ObjectInfo struct {
Key string `json:"key"`
Size int64 `json:"size"`
LastModified time.Time `json:"last_modified"`
ETag string `json:"etag"`
ContentType string `json:"content_type,omitempty"`
StorageClass string `json:"storage_class,omitempty"`
Key string `json:"key"`
Size int64 `json:"size"`
LastModified time.Time `json:"last_modified"`
ETag string `json:"etag"`
ContentType string `json:"content_type,omitempty"`
StorageClass string `json:"storage_class,omitempty"`
Metadata map[string]string `json:"metadata,omitempty"`
}
// ObjectListResponse represents a list of objects in a bucket
@@ -120,7 +123,6 @@ type UserInfo struct {
Name string `json:"name"`
SecretKey *string `json:"secretKey,omitempty"`
CreatedAt *time.Time `json:"createdAt,omitempty"`
LastUsed *time.Time `json:"lastUsed,omitempty"`
Status string `json:"status"` // "active" or "inactive"
BucketPermissions []BucketPermission `json:"permissions"` // Array of bucket permissions
Expiration *time.Time `json:"expiration,omitempty"`
@@ -136,13 +138,6 @@ type BucketPermission struct {
Owner bool `json:"owner"`
}
// Permission represents a permission entry for access control (legacy/deprecated)
type Permission struct {
Resource string `json:"resource"`
Actions []string `json:"actions"`
Effect string `json:"effect"` // "Allow" or "Deny"
}
type PresignedURLResponse struct {
URL string `json:"url"`
ExpiresIn int64 `json:"expires_in"` // in seconds
@@ -201,4 +196,7 @@ const (
ErrCodeUploadFailed = "UPLOAD_FAILED"
ErrCodeDeleteFailed = "DELETE_FAILED"
ErrCodeListFailed = "LIST_FAILED"
ErrCodeBucketNotEmpty = "BUCKET_NOT_EMPTY"
ErrCodeKeyNotFound = "KEY_NOT_FOUND"
ErrCodeInvalidRequest = "INVALID_REQUEST"
)
+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)
}
}
+107 -33
View File
@@ -5,7 +5,8 @@ import (
"Noooste/garage-ui/internal/config"
"Noooste/garage-ui/internal/handlers"
"Noooste/garage-ui/internal/middleware"
"fmt"
"Noooste/garage-ui/pkg/logger"
"net/url"
"os"
"path/filepath"
"strings"
@@ -21,7 +22,7 @@ import (
func SetupRoutes(
app *fiber.App,
cfg *config.Config,
authService *auth.AuthService,
authService *auth.Service,
healthHandler *handlers.HealthHandler,
bucketHandler *handlers.BucketHandler,
objectHandler *handlers.ObjectHandler,
@@ -39,6 +40,12 @@ func SetupRoutes(
// Swagger documentation endpoint (no auth required)
app.Get("/docs/*", swagger.HandlerDefault)
// Create auth handler
authHandler := handlers.NewAuthHandler(cfg, authService)
// Auth configuration endpoint (always accessible, no auth required)
app.Get("/auth/config", authHandler.GetAuthConfig)
// API v1 group
api := app.Group("/api/v1")
@@ -53,6 +60,7 @@ func SetupRoutes(
buckets.Get("/:name", bucketHandler.GetBucketInfo) // Get bucket info
buckets.Delete("/:name", bucketHandler.DeleteBucket) // Delete a bucket
buckets.Post("/:name/permissions", bucketHandler.GrantBucketPermission) // Grant bucket permissions
buckets.Put("/:name/website", bucketHandler.UpdateBucketWebsite) // Update bucket website configuration
}
// Object routes
@@ -62,18 +70,58 @@ func SetupRoutes(
objects.Post("/", objectHandler.UploadObject) // Upload object (multipart)
objects.Post("/upload-multiple", objectHandler.UploadMultipleObjects) // Upload multiple objects
objects.Post("/delete-multiple", objectHandler.DeleteMultipleObjects) // Delete multiple objects
objects.Get("/:key", objectHandler.GetObject) // Download object
objects.Delete("/:key", objectHandler.DeleteObject) // Delete object
objects.Head("/:key", objectHandler.GetObjectMetadata) // Get object metadata
objects.Post("/:key/presign", objectHandler.GetPresignedURL) // Generate pre-signed URL
}
// 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 {
path := decodeObjectKey(c)
switch {
case strings.HasSuffix(path, "/metadata"):
c.Locals("objectKey", strings.TrimSuffix(path, "/metadata"))
return objectHandler.GetObjectMetadata(c)
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)
}
}
objectDeleteHandler := func(c fiber.Ctx) error {
c.Locals("objectKey", decodeObjectKey(c))
return objectHandler.DeleteObject(c)
}
objectHeadHandler := func(c fiber.Ctx) error {
c.Locals("objectKey", decodeObjectKey(c))
return objectHandler.GetObjectMetadata(c)
}
// Register with auth middleware
app.Get("/api/v1/buckets/:bucket/objects/*", middleware.AuthMiddleware(&cfg.Auth, authService), objectWildcardHandler)
app.Delete("/api/v1/buckets/:bucket/objects/*", middleware.AuthMiddleware(&cfg.Auth, authService), objectDeleteHandler)
app.Head("/api/v1/buckets/:bucket/objects/*", middleware.AuthMiddleware(&cfg.Auth, authService), objectHeadHandler)
// User/Key management routes
users := api.Group("/users")
{
users.Get("/", userHandler.ListUsers) // List all users/keys
users.Post("/", userHandler.CreateUser) // Create new user/key
users.Get("/:access_key", userHandler.GetUser) // Get user info
users.Get("/:access_key/secret", userHandler.GetUserSecretKey) // Get user secret key
users.Delete("/:access_key", userHandler.DeleteUser) // Delete user/key
users.Patch("/:access_key", userHandler.UpdateUserPermissions) // Update user permissions
}
@@ -96,12 +144,22 @@ func SetupRoutes(
monitoring.Get("/dashboard", monitoringHandler.GetDashboardMetrics) // Get dashboard metrics
}
// Admin auth login endpoint (only if admin is enabled)
if cfg.Auth.Admin.Enabled {
app.Post("/auth/login", authHandler.LoginAdmin)
}
// Auth "me" endpoint (if any auth is enabled)
if cfg.Auth.Admin.Enabled || cfg.Auth.OIDC.Enabled {
app.Get("/auth/me", middleware.AuthMiddleware(&cfg.Auth, authService), authHandler.GetMe)
}
// OIDC authentication routes (only if OIDC is enabled)
if cfg.Auth.Mode == "oidc" && cfg.Auth.OIDC.Enabled {
authRoutes := app.Group("/auth")
if cfg.Auth.OIDC.Enabled {
oidcRoutes := app.Group("/auth/oidc")
{
// Login endpoint - redirects to OIDC provider
authRoutes.Get("/login", func(c fiber.Ctx) error {
oidcRoutes.Get("/login", func(c fiber.Ctx) error {
state, err := authService.GenerateStateToken()
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
@@ -119,7 +177,7 @@ func SetupRoutes(
})
// Callback endpoint - handles OIDC redirect after login
authRoutes.Get("/callback", func(c fiber.Ctx) error {
oidcRoutes.Get("/callback", func(c fiber.Ctx) error {
// Get and validate state token
state := c.Query("state")
if !authService.ValidateAndConsumeState(state) {
@@ -145,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 {
@@ -161,6 +224,33 @@ func SetupRoutes(
})
}
// Enforce admin role if configured. Roles are often absent from the
// ID token and the userinfo endpoint (Keycloak emits resource_access
// only in the access token by default), so fall back to the access
// token and then the userinfo endpoint before denying access.
if cfg.Auth.OIDC.AdminRole != "" {
if !authService.IsAdmin(userInfo) {
if roles := authService.ExtractRolesFromAccessToken(token.AccessToken); len(roles) > 0 {
userInfo.Roles = roles
}
}
if !authService.IsAdmin(userInfo) {
if ui, err := authService.GetUserInfo(ctx, token); err == nil && len(ui.Roles) > 0 {
userInfo.Roles = ui.Roles
}
}
if !authService.IsAdmin(userInfo) {
logger.Warn().
Str("username", userInfo.Username).
Str("required_role", cfg.Auth.OIDC.AdminRole).
Strs("roles", userInfo.Roles).
Msg("OIDC login denied: user does not have required admin role")
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{
"error": "User does not have the required admin role",
})
}
}
// Generate JWT session token
sessionToken, err := authService.GenerateSessionToken(userInfo)
if err != nil {
@@ -179,14 +269,12 @@ func SetupRoutes(
SameSite: cfg.Auth.OIDC.CookieSameSite,
})
return c.JSON(fiber.Map{
"success": true,
"user": userInfo,
})
// Redirect to frontend with success indicator
return c.Redirect().To("/login?login=success")
})
// Logout endpoint
authRoutes.Post("/logout", func(c fiber.Ctx) error {
oidcRoutes.Post("/logout", func(c fiber.Ctx) error {
// Clear session cookie
c.Cookie(&fiber.Cookie{
Name: cfg.Auth.OIDC.CookieName,
@@ -199,36 +287,22 @@ func SetupRoutes(
"message": "Logged out successfully",
})
})
// User info endpoint
authRoutes.Get("/me", middleware.AuthMiddleware(&cfg.Auth, authService), func(c fiber.Ctx) error {
userInfo := c.Locals("userInfo")
if userInfo == nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
"error": "Not authenticated",
})
}
return c.JSON(fiber.Map{
"success": true,
"user": userInfo,
})
})
}
}
cfg.Server.FrontendPath = "./frontend/dist"
// Check if frontend path exists
if _, err := os.Stat(cfg.Server.FrontendPath); err == nil {
fmt.Println("Serving frontend from:", cfg.Server.FrontendPath)
// SPA fallback - serve index.html for all non-API routes
app.Use(func(c fiber.Ctx) error {
path := c.Path()
if strings.HasPrefix(path, "/api/") ||
strings.HasPrefix(path, "/auth") ||
strings.HasPrefix(path, "/health") ||
strings.HasPrefix(path, "/docs") {
fmt.Println("API or health check route, skipping SPA fallback:", path)
logger.Debug().Str("path", path).Msg("API or health check route, skipping SPA fallback")
return c.Next()
}
+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)
}
@@ -1,13 +1,17 @@
package services
import (
"Noooste/garage-ui/internal/apierr"
"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"
)
@@ -20,9 +24,12 @@ type GarageAdminService struct {
}
// NewGarageAdminService creates a new Garage Admin API service
func NewGarageAdminService(cfg *config.GarageConfig) *GarageAdminService {
func NewGarageAdminService(cfg *config.GarageConfig, logLevel string) *GarageAdminService {
session := azuretls.NewSession()
session.Log()
if logLevel == "debug" {
session.Log()
}
return &GarageAdminService{
baseURL: cfg.AdminEndpoint,
@@ -31,26 +38,40 @@ func NewGarageAdminService(cfg *config.GarageConfig) *GarageAdminService {
}
}
// doRequest performs an HTTP request to the Admin API
// doRequest performs an HTTP request to the Admin API with retry logic for connection refused errors
func (s *GarageAdminService) doRequest(ctx context.Context, method, path string, body interface{}) (*azuretls.Response, error) {
return s.httpClient.Do(&azuretls.Request{
Method: method,
Url: s.baseURL + path,
Body: body,
IgnoreBody: true, // decodeResponse will handle body reading
OrderedHeaders: azuretls.OrderedHeaders{
{"Authorization", fmt.Sprintf("Bearer %s", s.token)},
},
}, ctx)
var resp *azuretls.Response
retryConfig := utils.DefaultRetryConfig()
err := utils.RetryWithBackoff(ctx, retryConfig, func() error {
var reqErr error
resp, reqErr = s.httpClient.Do(&azuretls.Request{
Method: method,
Url: s.baseURL + path,
Body: body,
IgnoreBody: true, // decodeResponse will handle body reading
OrderedHeaders: azuretls.OrderedHeaders{
{"Authorization", fmt.Sprintf("Bearer %s", s.token)},
},
}, ctx)
return reqErr
})
if err != nil {
return nil, err
}
return resp, nil
}
// decodeResponse decodes a JSON response into the target structure
// decodeResponse decodes a JSON response into the target structure. Non-2xx
// responses are converted to *apierr.UpstreamError (Source="garage") so the
// handler layer can map them to the correct API error code and HTTP status.
func decodeResponse(resp *azuretls.Response, target interface{}) error {
defer resp.RawBody.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
bodyBytes, _ := io.ReadAll(resp.RawBody)
return fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(bodyBytes))
if err := apierr.ParseGarage(resp); err != nil {
return err
}
if target != nil {
@@ -58,14 +79,9 @@ func decodeResponse(resp *azuretls.Response, target interface{}) error {
return fmt.Errorf("failed to decode response: %w", err)
}
}
return nil
}
// ====================================
// Access Key Operations
// ====================================
// ListKeys returns all access keys in the cluster
func (s *GarageAdminService) ListKeys(ctx context.Context) ([]models.ListKeysResponseItem, error) {
resp, err := s.doRequest(ctx, http.MethodGet, "/v2/ListKeys", nil)
@@ -75,7 +91,7 @@ func (s *GarageAdminService) ListKeys(ctx context.Context) ([]models.ListKeysRes
var result []models.ListKeysResponseItem
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
return nil, err
}
return result, nil
@@ -90,7 +106,7 @@ func (s *GarageAdminService) CreateKey(ctx context.Context, req models.CreateKey
var result models.GarageKeyInfo
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
return nil, err
}
return &result, nil
@@ -110,7 +126,7 @@ func (s *GarageAdminService) GetKeyInfo(ctx context.Context, keyID string, showS
var result models.GarageKeyInfo
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
return nil, err
}
return &result, nil
@@ -127,7 +143,7 @@ func (s *GarageAdminService) UpdateKey(ctx context.Context, keyID string, req mo
var result models.GarageKeyInfo
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
return nil, err
}
return &result, nil
@@ -143,7 +159,7 @@ func (s *GarageAdminService) DeleteKey(ctx context.Context, keyID string) error
}
if err := decodeResponse(resp, nil); err != nil {
return fmt.Errorf("failed to process response: %w", err)
return err
}
return nil
@@ -158,117 +174,186 @@ func (s *GarageAdminService) ImportKey(ctx context.Context, req models.ImportKey
var result models.GarageKeyInfo
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
return nil, err
}
return &result, nil
}
// ====================================
// Bucket Operations (Admin API)
// ====================================
// ListBuckets returns all buckets in the cluster
// ListBuckets returns all buckets in the cluster.
func (s *GarageAdminService) ListBuckets(ctx context.Context) ([]models.ListBucketsResponseItem, error) {
log := logpkg.FromCtx(ctx).With().
Str("component", "admin").
Str("operation", "list_buckets").
Logger()
log.Debug().Msg("listing buckets")
start := time.Now()
resp, err := s.doRequest(ctx, http.MethodGet, "/v2/ListBuckets", nil)
if err != nil {
log.Error().Err(err).
Float64("duration_ms", msSince(start)).
Str("outcome", "failure").
Msg("garage list_buckets request failed")
return nil, fmt.Errorf("request failed: %w", err)
}
var result []models.ListBucketsResponseItem
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
log.Error().Err(err).
Float64("duration_ms", msSince(start)).
Str("outcome", "failure").
Msg("garage list_buckets decode failed")
return nil, 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 {
return nil, fmt.Errorf("failed to decode response: %w", err)
log.Error().Err(err).Float64("duration_ms", msSince(start)).Str("outcome", "failure").Msg("garage get_bucket_info decode failed")
return nil, 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 {
return nil, fmt.Errorf("failed to decode response: %w", err)
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, 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 {
return nil, fmt.Errorf("failed to decode response: %w", err)
log.Error().Err(err).Float64("duration_ms", msSince(start)).Str("outcome", "failure").Msg("garage create_bucket decode failed")
return nil, 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 {
return nil, fmt.Errorf("failed to decode response: %w", err)
log.Error().Err(err).Float64("duration_ms", msSince(start)).Str("outcome", "failure").Msg("garage update_bucket decode failed")
return nil, 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 {
return fmt.Errorf("failed to process response: %w", err)
log.Error().Err(err).Float64("duration_ms", msSince(start)).Str("outcome", "failure").Msg("garage delete_bucket decode failed")
return err
}
log.Info().Float64("duration_ms", msSince(start)).Str("outcome", "success").Msg("bucket deleted")
return nil
}
// ====================================
// Bucket Alias Operations
// ====================================
// AddBucketAlias adds an alias to a bucket
func (s *GarageAdminService) AddBucketAlias(ctx context.Context, req models.AddBucketAliasRequest) (*models.GarageBucketInfo, error) {
resp, err := s.doRequest(ctx, http.MethodPost, "/v2/AddBucketAlias", req)
@@ -278,7 +363,7 @@ func (s *GarageAdminService) AddBucketAlias(ctx context.Context, req models.AddB
var result models.GarageBucketInfo
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
return nil, err
}
return &result, nil
@@ -293,28 +378,40 @@ func (s *GarageAdminService) RemoveBucketAlias(ctx context.Context, req models.R
var result models.GarageBucketInfo
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
return nil, err
}
return &result, nil
}
// ====================================
// Permission Operations
// ====================================
// AllowBucketKey grants permissions for a key on a bucket
// AllowBucketKey grants permissions for a key on a bucket.
func (s *GarageAdminService) AllowBucketKey(ctx context.Context, req models.BucketKeyPermRequest) (*models.GarageBucketInfo, error) {
log := logpkg.FromCtx(ctx).With().
Str("component", "admin").
Str("operation", "allow_bucket_key").
Str("bucket_id", req.BucketID).
Str("access_key_id", logpkg.RedactKey(req.AccessKeyID)).
Bool("perm_read", req.Permissions.Read).
Bool("perm_write", req.Permissions.Write).
Bool("perm_owner", req.Permissions.Owner).
Logger()
log.Info().Msg("granting bucket key permissions")
start := time.Now()
resp, err := s.doRequest(ctx, http.MethodPost, "/v2/AllowBucketKey", req)
if err != nil {
log.Error().Err(err).Float64("duration_ms", msSince(start)).Str("outcome", "failure").Msg("garage allow_bucket_key request failed")
return nil, fmt.Errorf("request failed: %w", err)
}
var result models.GarageBucketInfo
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
log.Error().Err(err).Float64("duration_ms", msSince(start)).Str("outcome", "failure").Msg("garage allow_bucket_key decode failed")
return nil, err
}
log.Info().Float64("duration_ms", msSince(start)).Str("outcome", "success").Msg("bucket key permissions granted")
return &result, nil
}
@@ -327,16 +424,12 @@ func (s *GarageAdminService) DenyBucketKey(ctx context.Context, req models.Bucke
var result models.GarageBucketInfo
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
return nil, err
}
return &result, nil
}
// ====================================
// Cluster Operations
// ====================================
// GetClusterHealth returns the health status of the cluster
func (s *GarageAdminService) GetClusterHealth(ctx context.Context) (*models.ClusterHealth, error) {
resp, err := s.doRequest(ctx, http.MethodGet, "/v2/GetClusterHealth", nil)
@@ -346,7 +439,7 @@ func (s *GarageAdminService) GetClusterHealth(ctx context.Context) (*models.Clus
var result models.ClusterHealth
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
return nil, err
}
return &result, nil
@@ -361,7 +454,7 @@ func (s *GarageAdminService) GetClusterStatus(ctx context.Context) (*models.Clus
var result models.ClusterStatus
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
return nil, err
}
return &result, nil
@@ -376,16 +469,12 @@ func (s *GarageAdminService) GetClusterStatistics(ctx context.Context) (*models.
var result models.ClusterStatistics
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
return nil, err
}
return &result, nil
}
// ====================================
// Node Operations
// ====================================
// GetNodeInfo returns information about a specific node
func (s *GarageAdminService) GetNodeInfo(ctx context.Context, nodeID string) (*models.MultiNodeResponse, error) {
path := fmt.Sprintf("/v2/GetNodeInfo?node=%s", nodeID)
@@ -397,7 +486,7 @@ func (s *GarageAdminService) GetNodeInfo(ctx context.Context, nodeID string) (*m
var result models.MultiNodeResponse
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
return nil, err
}
return &result, nil
@@ -414,16 +503,12 @@ func (s *GarageAdminService) GetNodeStatistics(ctx context.Context, nodeID strin
var result models.MultiNodeResponse
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
return nil, err
}
return &result, nil
}
// ====================================
// Monitoring Operations
// ====================================
// HealthCheck checks if the Admin API is reachable
func (s *GarageAdminService) HealthCheck(ctx context.Context) error {
resp, err := s.doRequest(ctx, http.MethodGet, "/health", nil)
@@ -432,12 +517,17 @@ func (s *GarageAdminService) HealthCheck(ctx context.Context) error {
}
if err := decodeResponse(resp, nil); err != nil {
return fmt.Errorf("health check returned error: %w", err)
return err
}
return nil
}
// 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)
}
}
+635
View File
@@ -0,0 +1,635 @@
package services
import (
"context"
"encoding/json"
"errors"
"io"
"net"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"Noooste/garage-ui/internal/apierr"
"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")
}
var ue *apierr.UpstreamError
if !errors.As(err, &ue) {
t.Fatalf("expected *apierr.UpstreamError, got %T (%v)", err, err)
}
if ue.HTTPStatus != http.StatusServiceUnavailable {
t.Errorf("HTTPStatus = %d, want %d", ue.HTTPStatus, http.StatusServiceUnavailable)
}
}
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")
}
var ue *apierr.UpstreamError
if !errors.As(err, &ue) {
t.Fatalf("expected *apierr.UpstreamError, got %T (%v)", err, err)
}
if ue.HTTPStatus != http.StatusBadRequest {
t.Errorf("HTTPStatus = %d, want %d", ue.HTTPStatus, http.StatusBadRequest)
}
if !strings.Contains(ue.Message, "bad request") {
t.Errorf("Message = %q, want it to contain the response body", ue.Message)
}
}
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)
}
}
+70
View File
@@ -0,0 +1,70 @@
package services
import (
"context"
"io"
"time"
"Noooste/garage-ui/internal/models"
)
// AdminService is the set of Garage Admin API operations used by HTTP handlers.
// It is implemented by *GarageAdminService in admin.go. Kept narrow so that
// hand-rolled mocks in tests don't need to cover admin methods the handlers
// never call.
type AdminService interface {
// Access keys
ListKeys(ctx context.Context) ([]models.ListKeysResponseItem, error)
CreateKey(ctx context.Context, req models.CreateKeyRequest) (*models.GarageKeyInfo, error)
GetKeyInfo(ctx context.Context, keyID string, showSecret bool) (*models.GarageKeyInfo, error)
UpdateKey(ctx context.Context, keyID string, req models.UpdateKeyRequest) (*models.GarageKeyInfo, error)
DeleteKey(ctx context.Context, keyID string) error
// Buckets
ListBuckets(ctx context.Context) ([]models.ListBucketsResponseItem, error)
GetBucketInfo(ctx context.Context, bucketID string) (*models.GarageBucketInfo, error)
GetBucketInfoByAlias(ctx context.Context, globalAlias string) (*models.GarageBucketInfo, error)
CreateBucket(ctx context.Context, req models.CreateBucketAdminRequest) (*models.GarageBucketInfo, error)
UpdateBucket(ctx context.Context, bucketID string, req models.UpdateBucketRequest) (*models.GarageBucketInfo, error)
DeleteBucket(ctx context.Context, bucketID string) error
AllowBucketKey(ctx context.Context, req models.BucketKeyPermRequest) (*models.GarageBucketInfo, error)
DenyBucketKey(ctx context.Context, req models.BucketKeyPermRequest) (*models.GarageBucketInfo, error)
// Cluster
GetClusterHealth(ctx context.Context) (*models.ClusterHealth, error)
GetClusterStatus(ctx context.Context) (*models.ClusterStatus, error)
GetClusterStatistics(ctx context.Context) (*models.ClusterStatistics, error)
GetNodeInfo(ctx context.Context, nodeID string) (*models.MultiNodeResponse, error)
GetNodeStatistics(ctx context.Context, nodeID string) (*models.MultiNodeResponse, error)
// Monitoring
HealthCheck(ctx context.Context) error
GetMetrics(ctx context.Context) (string, error)
}
// S3Storage is the set of S3 operations used by HTTP handlers. It is
// implemented by *S3Service in s3.go. Methods on *S3Service that are not
// called by handlers (ListBuckets, CreateBucket, DeleteBucket,
// GetBucketStatistics) are intentionally excluded.
type S3Storage interface {
ListObjects(ctx context.Context, bucketName, prefix string, maxKeys int, continuationToken string) (*models.ObjectListResponse, error)
UploadObject(ctx context.Context, bucketName, key string, body io.Reader, contentType string) (*models.ObjectUploadResponse, error)
CreateDirectoryMarker(ctx context.Context, bucketName, key string) (*models.ObjectUploadResponse, error)
GetObject(ctx context.Context, bucketName, key string) (io.ReadCloser, *models.ObjectInfo, error)
ObjectExists(ctx context.Context, bucketName, key string) (bool, error)
DeleteObject(ctx context.Context, bucketName, key string) error
GetObjectMetadata(ctx context.Context, bucketName, key string) (*models.ObjectInfo, error)
GetPresignedURL(ctx context.Context, bucketName, key string, expiresIn time.Duration) (string, error)
DeleteMultipleObjects(ctx context.Context, bucketName string, keys []string) error
UploadMultipleObjects(ctx context.Context, bucketName string, files []struct {
Key string
Body io.Reader
ContentType string
}) []UploadResult
}
// Compile-time guarantees that the concrete services implement the interfaces.
var (
_ AdminService = (*GarageAdminService)(nil)
_ S3Storage = (*S3Service)(nil)
)
@@ -0,0 +1,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)
}
+229 -81
View File
@@ -1,11 +1,15 @@
package services
import (
"bytes"
"context"
"fmt"
"io"
"net/url"
"strings"
"time"
"Noooste/garage-ui/internal/apierr"
"Noooste/garage-ui/internal/config"
"Noooste/garage-ui/internal/models"
"Noooste/garage-ui/pkg/utils"
@@ -24,9 +28,19 @@ type S3Service struct {
// NewS3Service creates a new S3 service instance using MinIO SDK
func NewS3Service(cfg *config.GarageConfig, adminService *GarageAdminService) *S3Service {
// Create MinIO client for Garage
// trim http or https from endpoint
if strings.HasPrefix(cfg.Endpoint, "http://") {
cfg.Endpoint = strings.TrimPrefix(cfg.Endpoint, "http://")
}
if strings.HasPrefix(cfg.Endpoint, "https://") {
cfg.Endpoint = strings.TrimPrefix(cfg.Endpoint, "https://")
cfg.UseSSL = true
}
client, err := minio.New(cfg.Endpoint, &minio.Options{
//Creds: credentials.NewStaticV4(cfg.AccessKey, cfg.SecretKey, ""),
Secure: cfg.UseSSL,
Region: cfg.Region,
})
if err != nil {
panic(fmt.Errorf("failed to create MinIO client: %w", err))
@@ -39,8 +53,6 @@ func NewS3Service(cfg *config.GarageConfig, adminService *GarageAdminService) *S
}
}
// getBucketCredentials retrieves credentials for a specific bucket
// It checks the cache first, then queries the Garage Admin API
func (s *S3Service) getBucketCredentials(ctx context.Context, bucketName string) (*credentials.Credentials, error) {
cacheKey := fmt.Sprintf("key:%s", bucketName)
cacheData := utils.GlobalCache.Get(cacheKey)
@@ -99,6 +111,7 @@ func (s *S3Service) getMinioClient(ctx context.Context, bucketName string) (*min
client, err := minio.New(s.config.Endpoint, &minio.Options{
Creds: creds,
Secure: s.config.UseSSL,
Region: s.config.Region,
})
if err != nil {
return nil, fmt.Errorf("failed to create MinIO client for bucket %s: %w", bucketName, err)
@@ -109,10 +122,17 @@ func (s *S3Service) getMinioClient(ctx context.Context, bucketName string) (*min
// ListBuckets retrieves all buckets from Garage
func (s *S3Service) ListBuckets(ctx context.Context) (*models.BucketListResponse, error) {
// Call MinIO ListBuckets API
bucketInfos, err := s.client.ListBuckets(ctx)
var bucketInfos []minio.BucketInfo
// Call MinIO ListBuckets API with retry logic
retryConfig := utils.DefaultRetryConfig()
err := utils.RetryWithBackoff(ctx, retryConfig, func() error {
var listErr error
bucketInfos, listErr = s.client.ListBuckets(ctx)
return listErr
})
if err != nil {
return nil, fmt.Errorf("failed to list buckets: %w", err)
return nil, apierr.FromMinio(err)
}
// Convert MinIO buckets to our model
@@ -137,12 +157,15 @@ func (s *S3Service) CreateBucket(ctx context.Context, bucketName string) error {
return fmt.Errorf("failed to get MinIO client for bucket %s: %w", bucketName, err)
}
// Call MinIO MakeBucket API
err = client.MakeBucket(ctx, bucketName, minio.MakeBucketOptions{
Region: s.config.Region,
// Call MinIO MakeBucket API with retry logic
retryConfig := utils.DefaultRetryConfig()
err = utils.RetryWithBackoff(ctx, retryConfig, func() error {
return client.MakeBucket(ctx, bucketName, minio.MakeBucketOptions{
Region: s.config.Region,
})
})
if err != nil {
return fmt.Errorf("failed to create bucket %s: %w", bucketName, err)
return apierr.FromMinio(err)
}
return nil
@@ -155,10 +178,13 @@ func (s *S3Service) DeleteBucket(ctx context.Context, bucketName string) error {
return fmt.Errorf("failed to get MinIO client for bucket %s: %w", bucketName, err)
}
// Call MinIO RemoveBucket API
err = client.RemoveBucket(ctx, bucketName)
// Call MinIO RemoveBucket API with retry logic
retryConfig := utils.DefaultRetryConfig()
err = utils.RetryWithBackoff(ctx, retryConfig, func() error {
return client.RemoveBucket(ctx, bucketName)
})
if err != nil {
return fmt.Errorf("failed to delete bucket %s: %w", bucketName, err)
return apierr.FromMinio(err)
}
return nil
@@ -177,64 +203,102 @@ func (s *S3Service) ListObjects(ctx context.Context, bucketName, prefix string,
maxKeys = 1000
}
// Use ListObjectsV2 for proper pagination support
opts := minio.ListObjectsOptions{
Prefix: prefix,
Recursive: false,
MaxKeys: maxKeys,
StartAfter: continuationToken,
UseV1: false,
// Create Core client for low-level API access
core := &minio.Core{Client: client}
// Use Core.ListObjectsV2 for proper pagination with continuation tokens
result, err := core.ListObjectsV2(
bucketName,
prefix, // objectPrefix
"", // startAfter (empty when using continuationToken)
continuationToken, // continuationToken (proper S3 token)
"/", // delimiter (for folder listing)
maxKeys, // maxkeys
)
if err != nil {
return nil, apierr.FromMinio(err)
}
objects := make([]models.ObjectInfo, 0)
prefixesMap := make(map[string]bool)
var lastKey string
isTruncated := false
itemCount := 0
// List objects using the channel-based API
for object := range client.ListObjects(ctx, bucketName, opts) {
if object.Err != nil {
return nil, fmt.Errorf("failed to list objects in bucket %s: %w", bucketName, object.Err)
}
// Check if this is a prefix (directory)
if len(object.Key) > 0 && object.Key[len(object.Key)-1:] == "/" && object.Size == 0 {
prefixesMap[object.Key] = true
// 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)
}
// Track the last key for pagination
lastKey = object.Key
// Process objects from result.Contents
// Note: ListObjectsV2 doesn't return ContentType, so we need to fetch it separately
objects := make([]models.ObjectInfo, len(contents))
// Add to objects list
objects = append(objects, models.ObjectInfo{
Key: object.Key,
Size: object.Size,
LastModified: object.LastModified,
ETag: object.ETag,
ContentType: object.ContentType,
StorageClass: object.StorageClass,
})
// Use goroutines to fetch ContentType concurrently for better performance
type statResult struct {
index int
contentType string
err error
}
itemCount++
if itemCount >= maxKeys {
isTruncated = true
break
statChan := make(chan statResult, len(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{})
if err != nil {
// If StatObject fails, we still include the object but without ContentType
statChan <- statResult{index: idx, contentType: "", err: err}
return
}
statChan <- statResult{index: idx, contentType: stat.ContentType, err: nil}
}(i, obj.Key)
// Initialize the object with basic info from ListObjectsV2
objects[i] = models.ObjectInfo{
Key: obj.Key,
Size: obj.Size,
LastModified: obj.LastModified,
ETag: obj.ETag,
StorageClass: obj.StorageClass,
}
}
// Convert prefixes map to slice
prefixList := make([]string, 0, len(prefixesMap))
for p := range prefixesMap {
prefixList = append(prefixList, p)
// Collect results from goroutines
for range contents {
res := <-statChan
if res.err == nil {
objects[res.index].ContentType = res.contentType
}
// If there was an error, ContentType remains empty, which is acceptable
}
close(statChan)
// Prepare next continuation token
var nextToken string
if isTruncated && lastKey != "" {
nextToken = lastKey
// Process folders from 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{
@@ -242,8 +306,8 @@ func (s *S3Service) ListObjects(ctx context.Context, bucketName, prefix string,
Objects: objects,
Prefixes: prefixList,
Count: len(objects),
IsTruncated: isTruncated,
NextContinuationToken: nextToken,
IsTruncated: result.IsTruncated,
NextContinuationToken: result.NextContinuationToken,
}, nil
}
@@ -260,10 +324,17 @@ func (s *S3Service) UploadObject(ctx context.Context, bucketName, key string, bo
ContentType: contentType,
}
// Call MinIO PutObject API
info, err := client.PutObject(ctx, bucketName, key, body, -1, opts)
var info minio.UploadInfo
// Call MinIO PutObject API with retry logic
retryConfig := utils.DefaultRetryConfig()
err = utils.RetryWithBackoff(ctx, retryConfig, func() error {
var uploadErr error
info, uploadErr = client.PutObject(ctx, bucketName, key, body, -1, opts)
return uploadErr
})
if err != nil {
return nil, fmt.Errorf("failed to upload object %s to bucket %s: %w", key, bucketName, err)
return nil, apierr.FromMinio(err)
}
return &models.ObjectUploadResponse{
@@ -275,19 +346,65 @@ 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, apierr.FromMinio(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) {
// Call MinIO GetObject API
object, err := s.client.GetObject(ctx, bucketName, key, minio.GetObjectOptions{})
// Get bucket-specific MinIO client
client, err := s.getMinioClient(ctx, bucketName)
if err != nil {
return nil, nil, fmt.Errorf("failed to get object %s from bucket %s: %w", key, bucketName, err)
return nil, nil, fmt.Errorf("failed to get MinIO client for bucket %s: %w", bucketName, err)
}
var object *minio.Object
// Call MinIO GetObject API with retry logic
retryConfig := utils.DefaultRetryConfig()
err = utils.RetryWithBackoff(ctx, retryConfig, func() error {
var getErr error
object, getErr = client.GetObject(ctx, bucketName, key, minio.GetObjectOptions{})
return getErr
})
if err != nil {
return nil, nil, apierr.FromMinio(err)
}
// Get object info
stat, err := object.Stat()
if err != nil {
object.Close()
return nil, nil, fmt.Errorf("failed to get object info for %s in bucket %s: %w", key, bucketName, err)
return nil, nil, apierr.FromMinio(err)
}
// Create object info
@@ -304,10 +421,19 @@ func (s *S3Service) GetObject(ctx context.Context, bucketName, key string) (io.R
// DeleteObject deletes an object from a bucket
func (s *S3Service) DeleteObject(ctx context.Context, bucketName, key string) error {
// Call MinIO RemoveObject API
err := s.client.RemoveObject(ctx, bucketName, key, minio.RemoveObjectOptions{})
// Get bucket-specific MinIO client
client, err := s.getMinioClient(ctx, bucketName)
if err != nil {
return fmt.Errorf("failed to delete object %s from bucket %s: %w", key, bucketName, err)
return fmt.Errorf("failed to get MinIO client for bucket %s: %w", bucketName, err)
}
// Call MinIO RemoveObject API with retry logic
retryConfig := utils.DefaultRetryConfig()
err = utils.RetryWithBackoff(ctx, retryConfig, func() error {
return client.RemoveObject(ctx, bucketName, key, minio.RemoveObjectOptions{})
})
if err != nil {
return apierr.FromMinio(err)
}
return nil
@@ -321,14 +447,22 @@ func (s *S3Service) ObjectExists(ctx context.Context, bucketName, key string) (b
return false, fmt.Errorf("failed to get MinIO client for bucket %s: %w", bucketName, err)
}
_, err = client.StatObject(ctx, bucketName, key, minio.StatObjectOptions{})
var statErr error
// Call MinIO StatObject API with retry logic
retryConfig := utils.DefaultRetryConfig()
err = utils.RetryWithBackoff(ctx, retryConfig, func() error {
_, statErr = client.StatObject(ctx, bucketName, key, minio.StatObjectOptions{})
return statErr
})
if err != nil {
// Check if error is "object not found"
errResponse := minio.ToErrorResponse(err)
if errResponse.Code == "NoSuchKey" {
return false, nil
}
return false, fmt.Errorf("failed to check if object exists: %w", err)
return false, apierr.FromMinio(err)
}
return true, nil
}
@@ -341,9 +475,17 @@ func (s *S3Service) GetObjectMetadata(ctx context.Context, bucketName, key strin
return nil, fmt.Errorf("failed to get MinIO client for bucket %s: %w", bucketName, err)
}
stat, err := client.StatObject(ctx, bucketName, key, minio.StatObjectOptions{})
var stat minio.ObjectInfo
// Call MinIO StatObject API with retry logic
retryConfig := utils.DefaultRetryConfig()
err = utils.RetryWithBackoff(ctx, retryConfig, func() error {
var statErr error
stat, statErr = client.StatObject(ctx, bucketName, key, minio.StatObjectOptions{})
return statErr
})
if err != nil {
return nil, fmt.Errorf("failed to get metadata for object %s in bucket %s: %w", key, bucketName, err)
return nil, apierr.FromMinio(err)
}
return &models.ObjectInfo{
@@ -352,6 +494,8 @@ func (s *S3Service) GetObjectMetadata(ctx context.Context, bucketName, key strin
LastModified: stat.LastModified,
ETag: stat.ETag,
ContentType: stat.ContentType,
StorageClass: stat.StorageClass,
Metadata: stat.UserMetadata,
}, nil
}
@@ -386,7 +530,7 @@ func (s *S3Service) DeleteMultipleObjects(ctx context.Context, bucketName string
// Check for errors
for err := range errorCh {
if err.Err != nil {
return fmt.Errorf("failed to delete object %s from bucket %s: %w", err.ObjectName, bucketName, err.Err)
return apierr.FromMinio(err.Err)
}
}
@@ -402,8 +546,15 @@ func (s *S3Service) GetPresignedURL(ctx context.Context, bucketName, key string,
return "", fmt.Errorf("failed to get MinIO client for bucket %s: %w", bucketName, err)
}
// Generate presigned GET URL
presignedURL, err := client.PresignedGetObject(ctx, bucketName, key, expiresIn, nil)
var presignedURL *url.URL
// Generate presigned GET URL with retry logic
retryConfig := utils.DefaultRetryConfig()
err = utils.RetryWithBackoff(ctx, retryConfig, func() error {
var presignErr error
presignedURL, presignErr = client.PresignedGetObject(ctx, bucketName, key, expiresIn, nil)
return presignErr
})
if err != nil {
return "", fmt.Errorf("failed to generate presigned URL for %s/%s: %w", bucketName, key, err)
}
@@ -421,9 +572,6 @@ type UploadResult struct {
ContentType string
}
// UploadMultipleObjects uploads multiple objects to a bucket
// It handles uploads in batches to respect any S3/Garage limits
// Returns results for each file, including both successes and failures
func (s *S3Service) UploadMultipleObjects(ctx context.Context, bucketName string, files []struct {
Key string
Body io.Reader
+579
View File
@@ -0,0 +1,579 @@
package services
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"Noooste/garage-ui/internal/apierr"
"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")
}
var ue *apierr.UpstreamError
if !errors.As(err, &ue) {
t.Errorf("expected *apierr.UpstreamError, got %T: %v", err, 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")
}
var ue *apierr.UpstreamError
if !errors.As(err, &ue) {
t.Errorf("expected *apierr.UpstreamError, got %T: %v", err, 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")
}
var ue *apierr.UpstreamError
if !errors.As(err, &ue) {
t.Errorf("expected *apierr.UpstreamError, got %T: %v", err, 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")
}
var ue *apierr.UpstreamError
if !errors.As(err, &ue) {
t.Errorf("expected *apierr.UpstreamError, got %T: %v", err, 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")
}
var ue *apierr.UpstreamError
if !errors.As(err, &ue) {
t.Errorf("expected *apierr.UpstreamError, got %T: %v", err, 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")
}
var ue *apierr.UpstreamError
if !errors.As(err, &ue) {
t.Errorf("expected *apierr.UpstreamError, got %T: %v", err, 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")
}
var ue *apierr.UpstreamError
if !errors.As(err, &ue) {
t.Errorf("expected *apierr.UpstreamError, got %T: %v", err, 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")
}
var ue *apierr.UpstreamError
if !errors.As(err, &ue) {
t.Errorf("expected *apierr.UpstreamError, got %T: %v", err, 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)
}
}
+109 -41
View File
@@ -3,20 +3,24 @@ package main
import (
"flag"
"fmt"
"log"
"os"
"os/signal"
"runtime"
"runtime/debug"
"syscall"
"time"
"Noooste/garage-ui/internal/auth"
"Noooste/garage-ui/internal/config"
"Noooste/garage-ui/internal/handlers"
appmw "Noooste/garage-ui/internal/middleware"
"Noooste/garage-ui/internal/routes"
"Noooste/garage-ui/internal/services"
"Noooste/garage-ui/pkg/logger"
"github.com/gofiber/fiber/v3"
"github.com/gofiber/fiber/v3/middleware/logger"
"github.com/gofiber/fiber/v3/middleware/recover"
"github.com/rs/zerolog/log"
)
// @title Garage UI API
@@ -25,9 +29,6 @@ import (
// @description This API provides endpoints for managing buckets, objects, users, and cluster operations.
// @termsOfService http://swagger.io/terms/
// @contact.name API Support
// @contact.email support@garage-ui.io
// @license.name MIT
// @license.url https://opensource.org/licenses/MIT
@@ -58,33 +59,56 @@ import (
// @name Authorization
// @description Type "Bearer" followed by a space and JWT token.
const version = "0.1.0"
var version = "dev"
func main() {
// Parse command-line flags
configPath := flag.String("config", "config.yaml", "Path to configuration file")
flag.Parse()
// Load configuration
log.Printf("Loading configuration from: %s", *configPath)
// Load configuration first (before initializing logger)
cfg, err := config.Load(*configPath)
if err != nil {
log.Fatalf("Failed to load configuration: %v", err)
// If config fails to load, use default logger to report the error
logger.Get().Fatal().Err(err).Str("config_path", *configPath).Msg("Failed to load configuration")
}
log.Printf("Starting Garage UI Backend v%s in %s mode", version, cfg.Server.Environment)
// Initialize logger with configuration from config file
logger.Init(logger.Config{
Level: cfg.Logging.Level,
Format: cfg.Logging.Format,
})
// Now log with the properly configured logger
logger.Info().
Str("config_path", *configPath).
Str("version", version).
Str("go_version", runtime.Version()).
Str("environment", cfg.Server.Environment).
Msg("Starting Garage UI Backend")
// Initialize services
log.Println("Initializing Garage Admin service...")
adminService := services.NewGarageAdminService(&cfg.Garage)
logger.Info().Msg("Initializing Garage Admin service")
adminService := services.NewGarageAdminService(&cfg.Garage, cfg.Logging.Level)
log.Println("Initializing S3 service...")
logger.Info().Msg("Initializing S3 service")
s3Service := services.NewS3Service(&cfg.Garage, adminService)
log.Printf("Initializing authentication service (mode: %s)...", cfg.Auth.Mode)
authService, err := auth.NewAuthService(&cfg.Auth)
// Determine enabled auth methods for logging
authMethods := []string{}
if cfg.Auth.Admin.Enabled {
authMethods = append(authMethods, "admin")
}
if cfg.Auth.OIDC.Enabled {
authMethods = append(authMethods, "oidc")
}
if len(authMethods) == 0 {
authMethods = append(authMethods, "none")
}
logger.Info().Strs("enabled_methods", authMethods).Msg("Initializing authentication service")
authService, err := auth.NewAuthService(&cfg.Auth, &cfg.Server)
if err != nil {
log.Fatalf("Failed to initialize auth service: %v", err)
logger.Fatal().Err(err).Msg("Failed to initialize auth service")
}
// Initialize handlers
@@ -95,22 +119,59 @@ func main() {
clusterHandler := handlers.NewClusterHandler(adminService)
monitoringHandler := handlers.NewMonitoringHandler(adminService, s3Service)
// Set default values for buffer sizes if not configured
maxBodySize := cfg.Server.MaxBodySize
if maxBodySize == 0 {
maxBodySize = 300 * 1024 * 1024 // 300MB default
}
maxHeaderSize := cfg.Server.MaxHeaderSize
if maxHeaderSize == 0 {
maxHeaderSize = 1 * 1024 * 1024 // 1MB default
}
readBufferSize := cfg.Server.ReadBufferSize
if readBufferSize == 0 {
readBufferSize = 4096 // 4KB default
}
writeBufferSize := cfg.Server.WriteBufferSize
if writeBufferSize == 0 {
writeBufferSize = 4096 // 4KB default
}
logger.Info().
Int64("max_body_bytes", maxBodySize).
Float64("max_body_mb", float64(maxBodySize)/(1024*1024)).
Int("max_header_bytes", maxHeaderSize).
Float64("max_header_kb", float64(maxHeaderSize)/1024).
Msg("Server request limits configured")
// Create Fiber app with configuration
app := fiber.New(fiber.Config{
AppName: "Garage UI Backend v" + version,
//DisableStartupMessage: false,
//EnablePrintRoutes: cfg.IsDevelopment(),
ErrorHandler: customErrorHandler,
AppName: "Garage UI Backend | Version: " + version,
BodyLimit: int(maxBodySize),
ReadBufferSize: readBufferSize,
WriteBufferSize: writeBufferSize,
ErrorHandler: customErrorHandler,
})
// Apply global middleware
app.Use(recover.New()) // Panic recovery
app.Use(logger.New(logger.Config{
Format: "[${time}] ${status} - ${method} ${path} (${latency})\n",
// Apply global middleware (order matters):
// 1. recover — must be outermost so panics become 500s.
// 2. RequestID — mints/reads X-Request-ID before any logger needs it.
// 3. Logging — builds per-request zerolog logger + emits access log.
// Auth middleware is installed per-route inside routes.SetupRoutes.
app.Use(recover.New(recover.Config{
EnableStackTrace: true,
StackTraceHandler: func(c fiber.Ctx, e interface{}) {
logger.FromCtx(c.Context()).Error().
Interface("panic", e).
Bytes("stack", debug.Stack()).
Msg("panic_recovered")
},
}))
app.Use(appmw.RequestID())
app.Use(appmw.Logging(log.Logger))
// Setup routes
log.Println("Setting up routes...")
logger.Info().Msg("Setting up routes")
routes.SetupRoutes(
app,
cfg,
@@ -126,42 +187,49 @@ func main() {
// Start server in a goroutine
go func() {
addr := cfg.GetAddress()
log.Printf("Server listening on %s", addr)
log.Printf("Health check available at: http://%s/health", addr)
log.Printf("API documentation: http://%s/api/v1/", addr)
logger.Info().
Str("address", addr).
Str("health_endpoint", fmt.Sprintf("http://%s/health", addr)).
Str("api_docs", fmt.Sprintf("http://%s/api/v1/", addr)).
Msg("Server starting")
if err := app.Listen(addr); err != nil {
log.Fatalf("Failed to start server: %v", err)
logger.Fatal().Err(err).Msg("Failed to start server")
}
}()
// Wait for interrupt signal to gracefully shutdown the server
// Wait for interrupt signal to gracefully shutdown the server.
quit := make(chan os.Signal, 1)
signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
<-quit
sig := <-quit
log.Println("Shutting down server...")
logger.Info().Str("signal", sig.String()).Msg("Shutting down server")
shutdownStart := time.Now()
if err := app.Shutdown(); err != nil {
log.Fatalf("Server shutdown failed: %v", err)
logger.Fatal().Err(err).Msg("Server shutdown failed")
}
log.Println("Server stopped gracefully")
logger.Info().
Dur("shutdown_duration", time.Since(shutdownStart)).
Msg("Server stopped gracefully")
}
// customErrorHandler handles errors globally
// customErrorHandler handles errors globally. It uses the per-request logger
// from c.Context() so request_id / user_id attach automatically, and it
// demotes expected 4xx responses to warn (5xx stays at error).
func customErrorHandler(c fiber.Ctx, err error) error {
// Default to 500 Internal Server Error
code := fiber.StatusInternalServerError
// Check if it's a Fiber error
if e, ok := err.(*fiber.Error); ok {
code = e.Code
}
// Log the error
log.Printf("Error: %v", err)
l := logger.FromCtx(c.Context())
evt := l.Error()
if code >= 400 && code < 500 {
evt = l.Warn()
}
evt.Err(err).Int("status_code", code).Msg("request_error")
// Return JSON error response
return c.Status(code).JSON(fiber.Map{
"success": false,
"error": fiber.Map{
+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")
}
+5 -6
View File
@@ -21,17 +21,16 @@ var (
// Config holds logger configuration
type Config struct {
Level string // debug, info, warn, error
Pretty bool // Enable console pretty printing
TimeFormat string // Time format (default: time.RFC3339)
Level string // debug, info, warn, error
Format string // json, text
}
// Init initializes the global logger with the given configuration
func Init(cfg Config) {
var output io.Writer = os.Stdout
// Set up pretty console output if enabled
if cfg.Pretty {
// Set up console output for text format
if cfg.Format == "text" {
output = zerolog.ConsoleWriter{
Out: os.Stdout,
TimeFormat: time.RFC3339,
@@ -70,7 +69,7 @@ func Get() *Logger {
// Initialize with defaults if not initialized
Init(Config{
Level: "info",
Pretty: true,
Format: "text",
})
}
return globalLogger
+261
View File
@@ -0,0 +1,261 @@
package logger
import (
"bufio"
"encoding/json"
"io"
"os"
"strings"
"sync"
"testing"
)
// serializeLoggerTests guards the global mutations (os.Stdout, globalLogger,
// zerolog global). These tests cannot run in parallel with each other.
var serializeLoggerTests sync.Mutex
// captureStdout swaps os.Stdout for a pipe, calls fn, restores stdout, and
// returns everything written during fn.
func captureStdout(t *testing.T, fn func()) string {
t.Helper()
r, w, err := os.Pipe()
if err != nil {
t.Fatalf("os.Pipe: %v", err)
}
old := os.Stdout
os.Stdout = w
t.Cleanup(func() { os.Stdout = old })
// Run fn and close writer so the reader unblocks.
doneWrite := make(chan struct{})
go func() {
fn()
_ = w.Close()
close(doneWrite)
}()
var buf strings.Builder
scanner := bufio.NewScanner(r)
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
for scanner.Scan() {
buf.WriteString(scanner.Text())
buf.WriteByte('\n')
}
// Drain any residual (shouldn't happen after Close, but safe):
_, _ = io.Copy(io.Discard, r)
<-doneWrite
return buf.String()
}
func TestInit_JSONFormatProducesParseableOutput(t *testing.T) {
serializeLoggerTests.Lock()
defer serializeLoggerTests.Unlock()
out := captureStdout(t, func() {
Init(Config{Level: "info", Format: "json"})
Info().Str("user", "alice").Msg("hello")
})
// Find the first non-empty line; parse as JSON.
var line string
for l := range strings.SplitSeq(out, "\n") {
if strings.TrimSpace(l) != "" {
line = l
break
}
}
if line == "" {
t.Fatalf("no log output captured; stdout = %q", out)
}
var parsed map[string]any
if err := json.Unmarshal([]byte(line), &parsed); err != nil {
t.Fatalf("log line is not valid JSON: %v\nline: %s", err, line)
}
// Field assertions — zerolog uses "message" for the msg and "level" for level.
if got, _ := parsed["message"].(string); got != "hello" {
t.Errorf("message = %v, want hello", parsed["message"])
}
if got, _ := parsed["user"].(string); got != "alice" {
t.Errorf("user field = %v, want alice", parsed["user"])
}
if got, _ := parsed["level"].(string); got != "info" {
t.Errorf("level = %v, want info", parsed["level"])
}
if _, ok := parsed["time"]; !ok {
t.Errorf("expected time field; got keys %v", keysOf(parsed))
}
if _, ok := parsed["caller"]; !ok {
t.Errorf("expected caller field; got keys %v", keysOf(parsed))
}
}
func TestInit_LevelFilterDropsBelowThreshold(t *testing.T) {
serializeLoggerTests.Lock()
defer serializeLoggerTests.Unlock()
out := captureStdout(t, func() {
Init(Config{Level: "warn", Format: "json"})
Debug().Msg("debug-dropped")
Info().Msg("info-dropped")
Warn().Msg("warn-kept")
Error().Msg("error-kept")
})
if strings.Contains(out, "debug-dropped") {
t.Errorf("debug event leaked through warn filter: %s", out)
}
if strings.Contains(out, "info-dropped") {
t.Errorf("info event leaked through warn filter: %s", out)
}
if !strings.Contains(out, "warn-kept") {
t.Errorf("warn event missing: %s", out)
}
if !strings.Contains(out, "error-kept") {
t.Errorf("error event missing: %s", out)
}
}
func TestInit_UnknownLevelDefaultsToInfo(t *testing.T) {
serializeLoggerTests.Lock()
defer serializeLoggerTests.Unlock()
out := captureStdout(t, func() {
Init(Config{Level: "gibberish", Format: "json"})
Debug().Msg("debug-should-be-dropped")
Info().Msg("info-should-appear")
})
if strings.Contains(out, "debug-should-be-dropped") {
t.Errorf("debug leaked at default info level: %s", out)
}
if !strings.Contains(out, "info-should-appear") {
t.Errorf("info missing at default info level: %s", out)
}
}
func TestInit_TextFormatDoesNotCrashAndIsNotJSON(t *testing.T) {
serializeLoggerTests.Lock()
defer serializeLoggerTests.Unlock()
out := captureStdout(t, func() {
Init(Config{Level: "info", Format: "text"})
Info().Str("k", "v").Msg("plain")
})
if !strings.Contains(out, "plain") {
t.Errorf("text output missing message: %s", out)
}
// Console writer output is ANSI-colored key=value form, not JSON.
var parsed map[string]any
if json.Unmarshal([]byte(strings.Split(out, "\n")[0]), &parsed) == nil {
t.Errorf("text format unexpectedly parsed as JSON: %s", out)
}
}
func TestGet_AutoInitializesWhenUnused(t *testing.T) {
serializeLoggerTests.Lock()
defer serializeLoggerTests.Unlock()
// Forcibly clear the global so Get() hits the lazy-init branch.
globalLogger = nil
l := Get()
if l == nil {
t.Fatal("Get() returned nil; lazy init did not run")
}
if globalLogger == nil {
t.Fatal("globalLogger still nil after Get()")
}
}
func TestWithComponent_AddsComponentField(t *testing.T) {
serializeLoggerTests.Lock()
defer serializeLoggerTests.Unlock()
out := captureStdout(t, func() {
Init(Config{Level: "info", Format: "json"})
comp := WithComponent("buckets")
comp.Info().Msg("tagged")
})
line := firstNonEmptyLine(out)
var parsed map[string]any
if err := json.Unmarshal([]byte(line), &parsed); err != nil {
t.Fatalf("not JSON: %v — %s", err, line)
}
if got, _ := parsed["component"].(string); got != "buckets" {
t.Errorf("component = %v, want buckets", parsed["component"])
}
}
func TestLogger_WithContext_AddsFields(t *testing.T) {
serializeLoggerTests.Lock()
defer serializeLoggerTests.Unlock()
out := captureStdout(t, func() {
Init(Config{Level: "info", Format: "json"})
l := Get().WithContext(map[string]any{
"request_id": "req-42",
"attempt": 2,
})
l.Info().Msg("ctx")
})
line := firstNonEmptyLine(out)
var parsed map[string]any
if err := json.Unmarshal([]byte(line), &parsed); err != nil {
t.Fatalf("not JSON: %v — %s", err, line)
}
if parsed["request_id"] != "req-42" {
t.Errorf("request_id = %v", parsed["request_id"])
}
// JSON numbers decode to float64.
if got, _ := parsed["attempt"].(float64); got != 2 {
t.Errorf("attempt = %v, want 2", parsed["attempt"])
}
}
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 {
for l := range strings.SplitSeq(s, "\n") {
if strings.TrimSpace(l) != "" {
return l
}
}
return ""
}
func keysOf(m map[string]any) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}
+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)
}
}
}
-1
View File
@@ -91,5 +91,4 @@ func (c *Cache) cleanupExpired() {
}
}
// Global cache instance
var GlobalCache = NewCache()
+129
View File
@@ -0,0 +1,129 @@
package utils
import (
"fmt"
"sync"
"testing"
"time"
)
func TestCache_GetMissReturnsNil(t *testing.T) {
c := NewCache()
if v := c.Get("nope"); v != nil {
t.Errorf("expected nil for missing key, got %v", v)
}
}
func TestCache_SetThenGetReturnsValue(t *testing.T) {
c := NewCache()
c.Set("k", "v", time.Minute)
got := c.Get("k")
if got != "v" {
t.Errorf("Get(k) = %v, want v", got)
}
}
func TestCache_SetWithDifferentTypes(t *testing.T) {
c := NewCache()
c.Set("str", "hello", time.Minute)
c.Set("int", 42, time.Minute)
c.Set("slice", []int{1, 2, 3}, time.Minute)
if got := c.Get("str"); got != "hello" {
t.Errorf("str: got %v", got)
}
if got := c.Get("int"); got != 42 {
t.Errorf("int: got %v", got)
}
if got, ok := c.Get("slice").([]int); !ok || len(got) != 3 {
t.Errorf("slice: got %v", c.Get("slice"))
}
}
func TestCache_GetExpiredReturnsNil(t *testing.T) {
c := NewCache()
c.Set("k", "v", 10*time.Millisecond)
time.Sleep(25 * time.Millisecond)
if got := c.Get("k"); got != nil {
t.Errorf("expected nil after TTL, got %v", got)
}
}
func TestCache_DeleteRemovesItem(t *testing.T) {
c := NewCache()
c.Set("k", "v", time.Minute)
c.Delete("k")
if got := c.Get("k"); got != nil {
t.Errorf("expected nil after Delete, got %v", got)
}
}
func TestCache_DeleteMissingKeyIsNoOp(t *testing.T) {
c := NewCache()
// Should not panic or error.
c.Delete("never-set")
}
func TestCache_ClearRemovesAllItems(t *testing.T) {
c := NewCache()
c.Set("a", 1, time.Minute)
c.Set("b", 2, time.Minute)
c.Set("c", 3, time.Minute)
c.Clear()
if c.Get("a") != nil || c.Get("b") != nil || c.Get("c") != nil {
t.Errorf("expected all items cleared")
}
}
func TestCache_SetOverwrites(t *testing.T) {
c := NewCache()
c.Set("k", "v1", time.Minute)
c.Set("k", "v2", time.Minute)
if got := c.Get("k"); got != "v2" {
t.Errorf("expected v2 after overwrite, got %v", got)
}
}
// TestCache_ConcurrentAccess exercises the RWMutex under load. Run with
// `go test -race` to catch data races. Uses bounded concurrency so the test
// stays deterministic.
func TestCache_ConcurrentAccess(t *testing.T) {
c := NewCache()
const goroutines = 50
const opsPerGoroutine = 100
var wg sync.WaitGroup
wg.Add(goroutines)
for g := range goroutines {
go func(id int) {
defer wg.Done()
for i := range opsPerGoroutine {
key := fmt.Sprintf("k%d", (id+i)%10)
c.Set(key, i, time.Minute)
_ = c.Get(key)
if i%10 == 0 {
c.Delete(key)
}
}
}(g)
}
wg.Wait()
// If we got here without a panic and `-race` is clean, the RWMutex is
// protecting the map correctly.
}
// TestGlobalCache_IsUsable is a smoke test for the package-level var.
// It doesn't Clear() afterwards because the global is shared state that
// other packages may depend on at test time.
func TestGlobalCache_IsUsable(t *testing.T) {
key := "stage2-smoke-key"
GlobalCache.Set(key, "x", time.Minute)
t.Cleanup(func() { GlobalCache.Delete(key) })
if got := GlobalCache.Get(key); got != "x" {
t.Errorf("GlobalCache.Get = %v, want x", got)
}
}
+114
View File
@@ -0,0 +1,114 @@
package utils
import (
"context"
"errors"
"fmt"
"net"
"strings"
"syscall"
"time"
)
// RetryConfig configures retry behavior
type RetryConfig struct {
MaxRetries int
InitialBackoff time.Duration
MaxBackoff time.Duration
BackoffFactor float64
}
// DefaultRetryConfig returns default retry configuration
func DefaultRetryConfig() RetryConfig {
return RetryConfig{
MaxRetries: 3,
InitialBackoff: 100 * time.Millisecond,
MaxBackoff: 5 * time.Second,
BackoffFactor: 2.0,
}
}
// IsConnectionRefused checks if the error is a connection refused error
func IsConnectionRefused(err error) bool {
if err == nil {
return false
}
// Check for connection refused error
var opErr *net.OpError
if errors.As(err, &opErr) {
// Check if it's a connection refused error
if opErr.Op == "dial" || opErr.Op == "read" {
var syscallErr *syscall.Errno
if errors.As(opErr.Err, &syscallErr) {
return *syscallErr == syscall.ECONNREFUSED
}
}
}
// Also check for "connection refused" in error message as fallback
if errors.Is(err, syscall.ECONNREFUSED) {
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
}
// RetryWithBackoff executes a function with exponential backoff on connection refused errors
func RetryWithBackoff(ctx context.Context, config RetryConfig, fn func() error) error {
var lastErr error
for attempt := 0; attempt <= config.MaxRetries; attempt++ {
// Execute the function
err := fn()
if err == nil {
return nil
}
lastErr = err
// If it's not a connection refused error, don't retry
if !IsConnectionRefused(err) {
return err
}
// If this was the last attempt, return the error
if attempt == config.MaxRetries {
return fmt.Errorf("max retries (%d) exceeded: %w", config.MaxRetries, err)
}
// Calculate backoff duration with exponential increase
backoff := time.Duration(float64(config.InitialBackoff) * pow(config.BackoffFactor, float64(attempt)))
if backoff > config.MaxBackoff {
backoff = config.MaxBackoff
}
// Wait with context cancellation support
select {
case <-ctx.Done():
return fmt.Errorf("context cancelled during retry: %w", ctx.Err())
case <-time.After(backoff):
// Continue to next attempt
}
}
return lastErr
}
// pow is a simple integer exponentiation helper
func pow(base float64, exp float64) float64 {
result := 1.0
for i := 0; i < int(exp); i++ {
result *= base
}
return result
}
+284
View File
@@ -0,0 +1,284 @@
package utils
import (
"context"
"errors"
"fmt"
"net"
"strings"
"syscall"
"testing"
"time"
)
func TestIsConnectionRefused(t *testing.T) {
tests := []struct {
name string
err error
want bool
}{
{
name: "nil error returns false",
err: nil,
want: false,
},
{
name: "unrelated error returns false",
err: errors.New("something else went wrong"),
want: false,
},
{
name: "bare ECONNREFUSED returns true (fallback errors.Is branch)",
err: syscall.ECONNREFUSED,
want: true,
},
{
name: "wrapped ECONNREFUSED returns true (fallback errors.Is branch)",
err: fmt.Errorf("context: %w", syscall.ECONNREFUSED),
want: true,
},
{
name: "OpError dial+ECONNREFUSED returns true (primary branch)",
err: &net.OpError{
Op: "dial",
Net: "tcp",
Err: syscall.ECONNREFUSED,
},
want: true,
},
{
name: "OpError read+ECONNREFUSED returns true (primary branch)",
err: &net.OpError{
Op: "read",
Net: "tcp",
Err: syscall.ECONNREFUSED,
},
want: true,
},
{
name: "OpError dial+ETIMEDOUT returns false (primary branch, wrong errno)",
err: &net.OpError{
Op: "dial",
Net: "tcp",
Err: syscall.ETIMEDOUT,
},
want: false,
},
{
name: "OpError dial+plain error falls through to errors.Is and returns false (inner As miss)",
err: &net.OpError{
Op: "dial",
Net: "tcp",
Err: errors.New("not a syscall errno"),
},
want: false,
},
{
name: "OpError write+ECONNREFUSED returns true via fallback errors.Is",
err: &net.OpError{
Op: "write",
Net: "tcp",
Err: syscall.ECONNREFUSED,
},
want: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := IsConnectionRefused(tc.err); got != tc.want {
t.Errorf("IsConnectionRefused(%v) = %v, want %v", tc.err, got, tc.want)
}
})
}
}
// fastRetryConfig keeps test runtime in the low-millisecond range.
func fastRetryConfig() RetryConfig {
return RetryConfig{
MaxRetries: 3,
InitialBackoff: 1 * time.Millisecond,
MaxBackoff: 5 * time.Millisecond,
BackoffFactor: 2.0,
}
}
func TestRetryWithBackoff_SuccessOnFirstAttempt(t *testing.T) {
calls := 0
err := RetryWithBackoff(context.Background(), fastRetryConfig(), func() error {
calls++
return nil
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if calls != 1 {
t.Errorf("want 1 call, got %d", calls)
}
}
func TestRetryWithBackoff_NonRetryableErrorReturnedImmediately(t *testing.T) {
sentinel := errors.New("boom")
calls := 0
err := RetryWithBackoff(context.Background(), fastRetryConfig(), func() error {
calls++
return sentinel
})
if !errors.Is(err, sentinel) {
t.Errorf("want wrapped sentinel, got %v", err)
}
if calls != 1 {
t.Errorf("want 1 call (no retry on non-conn-refused), got %d", calls)
}
}
func TestRetryWithBackoff_SuccessAfterTransientRefusals(t *testing.T) {
cfg := fastRetryConfig()
cfg.MaxRetries = 5 // allow up to 6 attempts
calls := 0
err := RetryWithBackoff(context.Background(), cfg, func() error {
calls++
if calls < 3 {
return syscall.ECONNREFUSED
}
return nil
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if calls != 3 {
t.Errorf("want 3 calls (2 failures + 1 success), got %d", calls)
}
}
func TestRetryWithBackoff_MaxRetriesExceededReturnsWrappedError(t *testing.T) {
cfg := fastRetryConfig()
cfg.MaxRetries = 2 // 3 total attempts (attempt 0, 1, 2)
calls := 0
err := RetryWithBackoff(context.Background(), cfg, func() error {
calls++
return syscall.ECONNREFUSED
})
if err == nil {
t.Fatal("expected error after exhausting retries, got nil")
}
if !errors.Is(err, syscall.ECONNREFUSED) {
t.Errorf("expected wrapped ECONNREFUSED, got %v", err)
}
// The loop runs attempt = 0..MaxRetries inclusive.
if calls != cfg.MaxRetries+1 {
t.Errorf("want %d calls, got %d", cfg.MaxRetries+1, calls)
}
// Error message includes the retry count for operator diagnostics.
if !containsAll(err.Error(), "max retries", "2") {
t.Errorf("error message missing retry count: %q", err.Error())
}
}
func TestRetryWithBackoff_ZeroMaxRetriesReturnsImmediately(t *testing.T) {
cfg := RetryConfig{
MaxRetries: 0,
InitialBackoff: 1 * time.Second, // large on purpose; must not sleep
MaxBackoff: 5 * time.Second,
BackoffFactor: 2.0,
}
calls := 0
start := time.Now()
err := RetryWithBackoff(context.Background(), cfg, func() error {
calls++
return syscall.ECONNREFUSED
})
elapsed := time.Since(start)
if err == nil {
t.Fatal("expected error, got nil")
}
if !errors.Is(err, syscall.ECONNREFUSED) {
t.Errorf("expected wrapped ECONNREFUSED, got %v", err)
}
if calls != 1 {
t.Errorf("want 1 call (no retry budget), got %d", calls)
}
// The only sleep would be after the attempt, but attempt == MaxRetries is
// short-circuited before the sleep select. So total runtime must be well
// under InitialBackoff.
if elapsed >= 500*time.Millisecond {
t.Errorf("no-retry path should not have slept; elapsed %v", elapsed)
}
}
func TestRetryWithBackoff_ContextCancelledDuringBackoff(t *testing.T) {
// Use a slow backoff so cancellation is guaranteed to land during the sleep.
cfg := RetryConfig{
MaxRetries: 5,
InitialBackoff: 50 * time.Millisecond,
MaxBackoff: 1 * time.Second,
BackoffFactor: 2.0,
}
ctx, cancel := context.WithCancel(context.Background())
// Cancel shortly after the first failed attempt starts its backoff.
go func() {
time.Sleep(10 * time.Millisecond)
cancel()
}()
calls := 0
err := RetryWithBackoff(ctx, cfg, func() error {
calls++
return syscall.ECONNREFUSED
})
if err == nil {
t.Fatal("expected error from cancelled context, got nil")
}
if !errors.Is(err, context.Canceled) {
t.Errorf("expected wrapped context.Canceled, got %v", err)
}
if calls < 1 {
t.Errorf("expected at least 1 call before cancellation, got %d", calls)
}
}
func TestRetryWithBackoff_WaitsBetweenAttempts(t *testing.T) {
// Lower-bound timing check — with InitialBackoff=20ms and BackoffFactor=2,
// three failed attempts sleep ~20ms + ~40ms = ~60ms before giving up.
// Assert >= 50ms to absorb scheduler jitter.
cfg := RetryConfig{
MaxRetries: 2,
InitialBackoff: 20 * time.Millisecond,
MaxBackoff: 100 * time.Millisecond,
BackoffFactor: 2.0,
}
start := time.Now()
_ = RetryWithBackoff(context.Background(), cfg, func() error {
return syscall.ECONNREFUSED
})
elapsed := time.Since(start)
if elapsed < 50*time.Millisecond {
t.Errorf("expected at least ~60ms of backoff delay, got %v", elapsed)
}
}
// containsAll reports whether s contains every substring in subs.
func containsAll(s string, subs ...string) bool {
for _, sub := range subs {
if !strings.Contains(s, sub) {
return false
}
}
return true
}
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,40 @@
---
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:
image: garage-ui-smoke-backend:ci
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/"
+28 -13
View File
@@ -5,29 +5,46 @@ server:
host: "0.0.0.0"
port: 8080
environment: "development" # development, production
domain: "localhost" # Domain name for the application
protocol: "http" # Protocol for internal communication (http/https)
root_url: "http://localhost:8080" # Full external URL for OAuth2 redirects (adjust for production)
# Request size limits (in bytes)
max_body_size: 314572800 # 300MB - Maximum request body size (increase for large file uploads)
max_header_size: 1048576 # 1MB - Maximum request header size
read_buffer_size: 4096 # 4KB - Read buffer size
write_buffer_size: 4096 # 4KB - Write buffer size
# Garage S3 Configuration
garage:
endpoint: "http://localhost:3900" # Garage S3 API endpoint
region: "eu-west-1" # S3 region (can be any value for Garage)
region: "eu-west-1" # S3 region (ensure it matches Garage S3 configuration)
# Garage Admin API configuration
admin_endpoint: "http://localhost:3903" # Garage Admin API endpoint
admin_token: "changeme" # Admin API bearer token
# Authentication Configuration
# You can enable one or both authentication methods
auth:
# Auth mode: "none", "basic", or "oidc"
mode: "none"
# JWT Configuration
# Ed25519 private key in PEM format for JWT token signing
# If not specified, a new key will be generated on each startup (tokens won't persist across restarts)
# Generate with: openssl genpkey -algorithm ED25519 -out jwt-key.pem
# The key is a 64-byte Ed25519 private key
jwt_private_key: "" # Leave empty to auto-generate, or provide PEM-encoded Ed25519 private key
# Basic Authentication (only used when mode = "basic")
basic:
# Admin Authentication (username/password)
admin:
enabled: false # Set to true to enable admin login
username: "admin"
password: "changeme"
# OIDC Configuration (only used when mode = "oidc")
# OIDC Configuration
# NOTE: When OIDC is enabled, server.root_url is required for OAuth2 redirects
# The redirect URL will be automatically constructed as: {root_url}/auth/oidc/callback
oidc:
enabled: true
enabled: false # Set to true to enable OIDC login
provider_name: "Keycloak"
client_id: "garage-ui"
client_secret: "your-client-secret"
@@ -40,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
@@ -86,7 +100,8 @@ cors:
allow_credentials: false
max_age: 3600
# Logging
# Logging Configuration
# The application uses zerolog for structured logging
logging:
level: "info" # debug, info, warn, error
format: "json" # json, text
level: "info" # Options: debug, info, warn, error
format: "text" or "json"
+9 -5
View File
@@ -1,7 +1,7 @@
---
services:
garage:
image: dxflrs/garage:v2.0.0
image: dxflrs/garage:v2.1.0
container_name: garage
volumes:
- ./garage.toml:/etc/garage.toml
@@ -26,7 +26,7 @@ services:
- garage
environment:
# Garage S3 Configuration
GARAGE_UI_GARAGE_ENDPOINT: "http://garage:3900"
GARAGE_UI_GARAGE_ENDPOINT: "garage:3900"
GARAGE_UI_GARAGE_ADMIN_ENDPOINT: "http://garage:3903"
# Server Configuration
@@ -34,6 +34,10 @@ services:
GARAGE_UI_SERVER_PORT: "8080"
GARAGE_UI_SERVER_ENVIRONMENT: "production"
# Logging
GARAGE_UI_LOGGING_LEVEL: "info"
GARAGE_UI_LOGGING_FORMAT: "json"
# JWT Configuration
# IMPORTANT: Replace this with your own Ed25519 private key in production!
# Generate with: openssl genpkey -algorithm ED25519
GARAGE_UI_AUTH_JWT_PRIVATE_KEY: |
-----BEGIN PRIVATE KEY-----
MC4CAQAwBQYDK2VwBCIEIH0ZHqIV7MEyVsxNYc5TA/a0qaBxgq3ntlFS3w1F03MS
-----END PRIVATE KEY-----
-1
View File
@@ -143,7 +143,6 @@ list: async (): Promise<Bucket[]> => {
- Activate/deactivate keys
- Copy access key IDs
- Permission configuration
- Bucket policies (coming soon)
## Theme Customization
+569 -739
View File
File diff suppressed because it is too large Load Diff
+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.
-42
View File
@@ -1,42 +0,0 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}
+55 -4
View File
@@ -1,29 +1,80 @@
import {BrowserRouter, Route, Routes} from 'react-router-dom';
import { useEffect } from 'react';
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 {Toaster} from 'sonner';
import {queryClient} from '@/lib/query-client';
import {useAuthStore} from '@/store/auth-store';
import {ProtectedRoute} from '@/components/auth/ProtectedRoute';
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() {
const { initialize, isLoading } = useAuthStore();
useEffect(() => {
initialize();
}, [initialize]);
if (isLoading) {
return <LoadingSpinner />;
}
return (
<QueryClientProvider client={queryClient}>
<ThemeProvider defaultTheme="system" storageKey="Noooste/garage-ui-theme">
<BrowserRouter>
<Routes>
<Route path="/" element={<Layout />}>
<Route path="/login" element={<Login />} />
<Route
path="/"
element={
<ProtectedRoute>
<Layout />
</ProtectedRoute>
}
>
<Route index element={<Dashboard />} />
<Route path="buckets" element={<Buckets />} />
<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>
@@ -0,0 +1,114 @@
import { useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useAuthStore } from '@/store/auth-store';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { LogIn } from 'lucide-react';
import type { AuthConfig } from '@/types/auth';
interface BasicLoginFormProps {
showOIDC?: boolean;
config?: AuthConfig | null;
}
export function BasicLoginForm({ showOIDC = false, config }: BasicLoginFormProps) {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const { loginAdmin, loginOIDC } = useAuthStore();
const returnUrl = searchParams.get('returnUrl') || '/';
const providerName = config?.oidc?.provider || 'OIDC Provider';
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
try {
await loginAdmin(username, password);
// Navigate to return URL on success
navigate(decodeURIComponent(returnUrl));
} catch (error) {
// Error is already handled by the store and toast
console.error('Login failed:', error);
} finally {
setIsLoading(false);
}
};
return (
<Card className="w-full">
<CardHeader className="space-y-1">
<div className="flex items-center justify-center mb-4">
<img
src="/garage.png"
alt="Garage Logo"
className="h-16 w-16 object-contain"
/>
</div>
<CardTitle className="text-2xl text-center">
Welcome to Garage UI
</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<label htmlFor="username" className="text-sm font-medium">Username</label>
<Input
id="username"
type="text"
placeholder="Enter your username"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
disabled={isLoading}
autoComplete="username"
/>
</div>
<div className="space-y-2">
<label htmlFor="password" className="text-sm font-medium">Password</label>
<Input
id="password"
type="password"
placeholder="Enter your password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
disabled={isLoading}
autoComplete="current-password"
/>
</div>
<Button
type="submit"
className="w-full"
disabled={isLoading || !username || !password}
>
{isLoading ? 'Signing in...' : 'Sign in'}
</Button>
</form>
{showOIDC && (
<div className="mt-4">
<div className="relative mb-4">
<div className="relative flex justify-center text-xs">
<span className="bg-card px-2 text-muted-foreground">or</span>
</div>
</div>
<Button
type="button"
variant="secondary"
className="w-full"
onClick={loginOIDC}
>
<LogIn className="mr-2 h-4 w-4" />
Sign in with {providerName}
</Button>
</div>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,12 @@
import { Loader2 } from 'lucide-react';
export function LoadingSpinner() {
return (
<div className="flex h-screen w-full items-center justify-center">
<div className="flex flex-col items-center gap-4">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<p className="text-sm text-muted-foreground">Loading...</p>
</div>
</div>
);
}
@@ -0,0 +1,43 @@
import { useAuthStore } from '@/store/auth-store';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { LogIn } from 'lucide-react';
export function OIDCLoginView() {
const { config, loginOIDC } = useAuthStore();
const providerName = config?.oidc.provider || 'OIDC Provider';
return (
<div className="flex min-h-screen items-center justify-center bg-background p-4">
<Card className="w-full max-w-md">
<CardHeader className="space-y-1">
<div className="flex items-center justify-center mb-4">
<img
src="/garage.png"
alt="Garage Logo"
className="h-16 w-16 object-contain"
/>
</div>
<CardTitle className="text-2xl text-center">Sign in to Garage UI</CardTitle>
<CardDescription className="text-center">
Authenticate using your {providerName} account
</CardDescription>
</CardHeader>
<CardContent>
<Button
onClick={loginOIDC}
className="w-full"
size="lg"
>
<LogIn className="mr-2 h-5 w-5" />
Continue with {providerName}
</Button>
<p className="mt-4 text-center text-xs text-muted-foreground">
You will be redirected to {providerName} to complete the sign-in process
</p>
</CardContent>
</Card>
</div>
);
}
@@ -0,0 +1,29 @@
import { Navigate, useLocation } from 'react-router-dom';
import { useAuthStore } from '@/store/auth-store';
import { LoadingSpinner } from './LoadingSpinner';
interface ProtectedRouteProps {
children: React.ReactNode;
}
export function ProtectedRoute({ children }: ProtectedRouteProps) {
const { isAuthenticated, isLoading, config } = useAuthStore();
const location = useLocation();
if (isLoading) {
return <LoadingSpinner />;
}
// If no auth is enabled, always allow access
if (config && !config.admin.enabled && !config.oidc.enabled) {
return <>{children}</>;
}
// If not authenticated, redirect to login with return URL
if (!isAuthenticated) {
const returnUrl = encodeURIComponent(location.pathname + location.search);
return <Navigate to={`/login?returnUrl=${returnUrl}`} replace />;
}
return <>{children}</>;
}
@@ -9,8 +9,9 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { FolderIcon, Loader2, MoreVertical, Plus, Search, Settings, Trash2 } from 'lucide-react';
import { formatBytes, formatDate } from '@/lib/utils';
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';
interface BucketListViewProps {
@@ -20,8 +21,8 @@ interface BucketListViewProps {
onSearchChange: (query: string) => void;
onViewBucket: (bucketName: string) => void;
onOpenSettings: (bucket: Bucket) => void;
onCreateBucket: () => void;
onDeleteBucket: (bucket: Bucket) => void;
onWebsiteSettings: (bucket: Bucket) => void;
}
export function BucketListView({
@@ -31,8 +32,8 @@ export function BucketListView({
onSearchChange,
onViewBucket,
onOpenSettings,
onCreateBucket,
onDeleteBucket,
onWebsiteSettings,
}: BucketListViewProps) {
const filteredBuckets = buckets.filter((bucket) =>
bucket.name.toLowerCase().includes(searchQuery.toLowerCase())
@@ -41,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 */}
@@ -93,9 +88,17 @@ export function BucketListView({
className="cursor-pointer hover:bg-muted/50"
onClick={() => onViewBucket(bucket.name)}
>
<TableCell className="font-medium truncate max-w-[200px]">{bucket.name}</TableCell>
<TableCell className="font-medium max-w-[200px]">
<span className="truncate">{bucket.name}</span>
{bucket.websiteAccess && (
<Badge variant="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>
@@ -122,6 +125,13 @@ export function BucketListView({
<Settings className="h-4 w-4" />
Settings
</DropdownMenuItem>
<DropdownMenuItem onClick={(e) => {
e.stopPropagation();
onWebsiteSettings(bucket);
}}>
<Globe className="h-4 w-4" />
Website Settings
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive"

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