Compare commits

...

121 Commits

Author SHA1 Message Date
garage-ui-release-bot[bot] b80cfef844 chore: release main (#69)
* chore: release main

* chore: sync Chart.yaml appVersion to v0.8.0

---------

Co-authored-by: garage-ui-release-bot[bot] <285030457+garage-ui-release-bot[bot]@users.noreply.github.com>
Co-authored-by: garage-ui-release-bot[bot] <garage-ui-release-bot[bot]@users.noreply.github.com>
2026-05-31 11:57:40 +02:00
Noste 186af18d54 feat(docs): add documentation generation command to Makefile
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2026-05-31 11:54:43 +02:00
dependabot[bot] 4b3a562acb chore(deps): bump axios from 1.15.2 to 1.16.0 in /frontend (#67)
Bumps [axios](https://github.com/axios/axios) from 1.15.2 to 1.16.0.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.15.2...v1.16.0)

---
updated-dependencies:
- dependency-name: axios
  dependency-version: 1.16.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-31 11:39:32 +02:00
Alistair Young 5427758eaa feat(backend,helm)!: bind to IPv6 wildcard by default for dual-stack support
* fix: Enable Garage UI to work on IPv6-based clusters.

* fix: building on nonstandard-uid machines.

* fix: Enable Garage UI to work on IPv6-based clusters.
2026-05-31 11:39:04 +02:00
garage-ui-release-bot[bot] c30400cf84 chore: release main (#62)
* chore: release main

* chore: sync Chart.yaml appVersion to v0.7.0

* Bump version to 0.5.1 in Chart.yaml

* chore: bump version

---------

Co-authored-by: garage-ui-release-bot[bot] <285030457+garage-ui-release-bot[bot]@users.noreply.github.com>
Co-authored-by: garage-ui-release-bot[bot] <garage-ui-release-bot[bot]@users.noreply.github.com>
Co-authored-by: Noste <83548733+Noooste@users.noreply.github.com>
2026-05-25 18:43:44 +02:00
Noste d05b9ce324 docs: update README to include loading sensitive values from files with _FILE suffix
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2026-05-23 15:43:26 +02:00
Noste 1f16edd39c feat(backend,frontend): enable quotas support in bucket settings (#64) 2026-05-23 15:29:04 +02:00
Noste 36ec8e800e feat(backend): Support _FILE suffix on sensitive env variables (#63)
* feat(env): add _FILE suffix support for env variables

* fix(frontend): dynamic refresh on key creation

* chore(deps): update packages

* fix(test): coverage
2026-05-23 14:55:52 +02:00
Noste 1b645b0c2c fix(auth): remove auto-enable token auth logic
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2026-05-23 12:46:38 +02:00
garage-ui-release-bot[bot] dc9ea5716a chore: release main (#57)
* chore: release main

* chore: sync Chart.yaml appVersion to v0.6.2

---------

Co-authored-by: garage-ui-release-bot[bot] <285030457+garage-ui-release-bot[bot]@users.noreply.github.com>
Co-authored-by: garage-ui-release-bot[bot] <garage-ui-release-bot[bot]@users.noreply.github.com>
2026-05-16 10:27:30 +02:00
Noste 699f11afaa feat(helm): add support for extra Kubernetes manifests in values.yaml
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2026-05-16 10:26:04 +02:00
Noste 657d919331 chore: update .gitignore
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2026-05-16 09:30:02 +02:00
Noste 01c4c16778 ci: add workflow_dispatch trigger to build.yml
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2026-05-16 09:21:57 +02:00
garage-ui-release-bot[bot] 3521e63073 chore: release main (#55)
Co-authored-by: garage-ui-release-bot[bot] <285030457+garage-ui-release-bot[bot]@users.noreply.github.com>
Co-authored-by: Noste <83548733+Noooste@users.noreply.github.com>
2026-05-15 23:29:54 +02:00
Noste 71d3c8446a ci: sync Chart.yaml appVersion via post-release-please step
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2026-05-15 23:28:28 +02:00
Noste 709b9f2ad3 fix(helm): update appVersion format and improve image tag handling
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2026-05-15 23:11:51 +02:00
github-actions[bot] fd49c4e4c9 chore: release main (#54)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-05-15 18:56:43 +02:00
Noste ff46ff6232 fix: Helm image tag (#53)
* fix(helm): update image tag logic to handle version prefixes correctly

Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>

* fix(ci): exclude helm/garage-ui from changelog generation

Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>

---------

Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2026-05-15 18:52:49 +02:00
github-actions[bot] 66abbc64b3 chore: release main (#52)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-05-15 18:37:40 +02:00
Noste cb3839e618 fix(ci): remove CHANGELOG.md seeds so release-please owns them (#51)
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2026-05-15 18:34:00 +02:00
Noste f04c38168d fix(ci): correct appVersion tracking and remove changelog seeds (#49)
* feat(ci): add automated release workflow and changelog management

Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>

* fix(ci): correct appVersion tracking and remove changelog seeds

---------

Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2026-05-15 18:29:07 +02:00
Noste c45846535c ci: add automated release workflow and changelog management (#47)
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2026-05-15 18:14:15 +02:00
Noste c8cb3c4923 feat: enhance bucket credential retrieval to support read/write operations and improve caching logic (#46)
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2026-05-15 15:37:07 +02:00
madestreel c8337de3a8 feat: add extraEnvs to helm chart to allow config override (#45)
Co-authored-by: Maxime de Streel <mst@escaux.com>
2026-05-15 15:36:44 +02:00
dependabot[bot] 5cc4b02114 chore(deps-dev): bump postcss from 8.5.6 to 8.5.12 in /frontend (#32)
Bumps [postcss](https://github.com/postcss/postcss) from 8.5.6 to 8.5.12.
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss/compare/8.5.6...8.5.12)

---
updated-dependencies:
- dependency-name: postcss
  dependency-version: 8.5.12
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-14 09:20:12 +02:00
dependabot[bot] 741232c797 chore(deps): bump axios from 1.15.0 to 1.15.2 in /frontend (#34)
Bumps [axios](https://github.com/axios/axios) from 1.15.0 to 1.15.2.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.15.0...v1.15.2)

---
updated-dependencies:
- dependency-name: axios
  dependency-version: 1.15.2
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-14 09:19:54 +02:00
Noste a9337ff59b chore: update version and appVersion in Chart.yaml and README
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2026-05-14 09:17:26 +02:00
Noste ff3977aeb5 feat: enhance admin role checks to support multiple roles configuration (#43)
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2026-05-12 23:31:11 +02:00
Noste 5388f0da8f feat: add support for TLS InsecureSkipVerify in OIDC configuration (#42)
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2026-05-12 17:57:07 +02:00
Noooste 42055930ed docs: add garage setup guide and update config example filename
Signed-off-by: Noooste <83548733+Noooste@users.noreply.github.com>
2026-04-24 12:09:04 +02:00
Noste f1eeca60bf feat: add garage.toml support (#30) 2026-04-24 11:27:43 +02:00
Noooste a5f064761b docs: update README to enhance clarity and modernize presentation
Signed-off-by: Noooste <83548733+Noooste@users.noreply.github.com>
2026-04-24 11:10:13 +02:00
Noooste b90e4f71be docs: update README to enhance clarity and modernize presentation
Signed-off-by: Noooste <83548733+Noooste@users.noreply.github.com>
2026-04-24 11:08:55 +02:00
Noste 21f10a0274 Update README with completed Garage v1 support 2026-04-24 09:55:18 +02:00
Noste 390ccd7893 Support garage v1 (#31)
* feat: support garage v1
2026-04-24 09:53:56 +02:00
Noste b8b4d039ff Merge pull request #29 from Noooste/alert-autofix-1
Potential fix for code scanning alert no. 1: Workflow does not contain permissions
2026-04-20 14:00:55 +02:00
Noste 1e6ab4f3cc Merge pull request #28 from Noooste/alert-autofix-2
Potential fix for code scanning alert no. 2: Workflow does not contain permissions
2026-04-20 14:00:45 +02:00
Noste 9feaaf7c04 Potential fix for code scanning alert no. 1: Workflow does not contain permissions
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-04-20 13:58:18 +02:00
Noste a6ecf2af05 Potential fix for code scanning alert no. 2: Workflow does not contain permissions
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-04-20 13:58:08 +02:00
Noste ee6de30b40 Merge pull request #27 from Noooste/dependabot/npm_and_yarn/frontend/follow-redirects-1.16.0
chore(deps): bump follow-redirects from 1.15.11 to 1.16.0 in /frontend
2026-04-20 12:25:13 +02:00
dependabot[bot] c165d04c77 chore(deps): bump follow-redirects from 1.15.11 to 1.16.0 in /frontend
Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.15.11 to 1.16.0.
- [Release notes](https://github.com/follow-redirects/follow-redirects/releases)
- [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.15.11...v1.16.0)

---
updated-dependencies:
- dependency-name: follow-redirects
  dependency-version: 1.16.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-20 10:24:53 +00:00
Noste e3a74c4791 Merge pull request #26 from Noooste/dependabot/npm_and_yarn/frontend/multi-05c8b36e59
chore(deps): bump react-router and react-router-dom in /frontend
2026-04-20 12:24:49 +02:00
Noste 93d7b3aeb2 Merge pull request #25 from Noooste/dependabot/npm_and_yarn/frontend/vite-7.3.2
chore(deps-dev): bump vite from 7.3.0 to 7.3.2 in /frontend
2026-04-20 12:24:40 +02:00
Noste c7d7e8c215 Merge pull request #24 from Noooste/dependabot/npm_and_yarn/frontend/flatted-3.4.2
chore(deps-dev): bump flatted from 3.3.3 to 3.4.2 in /frontend
2026-04-20 12:24:28 +02:00
Noste 4c92203023 Merge pull request #23 from Noooste/dependabot/npm_and_yarn/frontend/multi-770cfcd984
chore(deps): bump minimatch in /frontend
2026-04-20 12:24:19 +02:00
Noste 6c3c13a321 Merge pull request #22 from Noooste/dependabot/npm_and_yarn/frontend/rollup-4.60.2
chore(deps): bump rollup from 4.54.0 to 4.60.2 in /frontend
2026-04-20 12:24:10 +02:00
Noste a293e0e007 Merge pull request #21 from Noooste/dependabot/npm_and_yarn/frontend/axios-1.15.0
chore(deps): bump axios from 1.13.2 to 1.15.0 in /frontend
2026-04-20 12:24:00 +02:00
Noste 153f95bf6b Merge pull request #20 from Noooste/dependabot/npm_and_yarn/frontend/picomatch-4.0.4
chore(deps): bump picomatch from 4.0.3 to 4.0.4 in /frontend
2026-04-20 12:23:49 +02:00
dependabot[bot] 614bf5b04a chore(deps): bump react-router and react-router-dom in /frontend
Bumps [react-router](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router) to 7.14.1 and updates ancestor dependency [react-router-dom](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-dom). These dependencies need to be updated together.


Updates `react-router` from 7.11.0 to 7.14.1
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/react-router@7.14.1/packages/react-router)

Updates `react-router-dom` from 7.11.0 to 7.14.1
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router-dom/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/react-router-dom@7.14.1/packages/react-router-dom)

---
updated-dependencies:
- dependency-name: react-router
  dependency-version: 7.14.1
  dependency-type: indirect
- dependency-name: react-router-dom
  dependency-version: 7.14.1
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-20 10:23:19 +00:00
dependabot[bot] a74d00d81f chore(deps-dev): bump vite from 7.3.0 to 7.3.2 in /frontend
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 7.3.0 to 7.3.2.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v7.3.2/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v7.3.2/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 7.3.2
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-20 10:23:19 +00:00
dependabot[bot] 13bbdac837 chore(deps-dev): bump flatted from 3.3.3 to 3.4.2 in /frontend
Bumps [flatted](https://github.com/WebReflection/flatted) from 3.3.3 to 3.4.2.
- [Commits](https://github.com/WebReflection/flatted/compare/v3.3.3...v3.4.2)

---
updated-dependencies:
- dependency-name: flatted
  dependency-version: 3.4.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-20 10:23:13 +00:00
dependabot[bot] 12b06a4565 chore(deps): bump minimatch in /frontend
Bumps  and [minimatch](https://github.com/isaacs/minimatch). These dependencies needed to be updated together.

Updates `minimatch` from 3.1.2 to 3.1.5
- [Changelog](https://github.com/isaacs/minimatch/blob/main/changelog.md)
- [Commits](https://github.com/isaacs/minimatch/compare/v3.1.2...v3.1.5)

Updates `minimatch` from 9.0.5 to 9.0.9
- [Changelog](https://github.com/isaacs/minimatch/blob/main/changelog.md)
- [Commits](https://github.com/isaacs/minimatch/compare/v3.1.2...v3.1.5)

---
updated-dependencies:
- dependency-name: minimatch
  dependency-version: 3.1.5
  dependency-type: indirect
- dependency-name: minimatch
  dependency-version: 9.0.9
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-20 10:23:09 +00:00
dependabot[bot] 093b3ebdcc chore(deps): bump rollup from 4.54.0 to 4.60.2 in /frontend
Bumps [rollup](https://github.com/rollup/rollup) from 4.54.0 to 4.60.2.
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.54.0...v4.60.2)

---
updated-dependencies:
- dependency-name: rollup
  dependency-version: 4.60.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-20 10:23:09 +00:00
dependabot[bot] 4ebe8ec56f chore(deps): bump axios from 1.13.2 to 1.15.0 in /frontend
Bumps [axios](https://github.com/axios/axios) from 1.13.2 to 1.15.0.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.13.2...v1.15.0)

---
updated-dependencies:
- dependency-name: axios
  dependency-version: 1.15.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-20 10:23:08 +00:00
dependabot[bot] 123f8d14f5 chore(deps): bump picomatch from 4.0.3 to 4.0.4 in /frontend
Bumps [picomatch](https://github.com/micromatch/picomatch) from 4.0.3 to 4.0.4.
- [Release notes](https://github.com/micromatch/picomatch/releases)
- [Changelog](https://github.com/micromatch/picomatch/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/picomatch/compare/4.0.3...4.0.4)

---
updated-dependencies:
- dependency-name: picomatch
  dependency-version: 4.0.4
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-20 10:23:02 +00:00
Noste aa3e8ac33b Create dependabot.yml for version updates
Add initial configuration for Dependabot updates.
2026-04-20 12:22:31 +02:00
Noooste c9ed8fed83 fix: update bucket image 2026-04-20 09:59:25 +02:00
Noooste 16e416420a fix: update bucket image 2026-04-20 00:25:51 +02:00
Noooste a7e79e5343 fix: update dashboard images 2026-04-20 00:24:02 +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
167 changed files with 18549 additions and 3067 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 248 KiB

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 364 KiB

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 289 KiB

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 344 KiB

After

Width:  |  Height:  |  Size: 117 KiB

+11
View File
@@ -0,0 +1,11 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
version: 2
updates:
- package-ecosystem: "" # See documentation for possible values
directory: "/" # Location of package manifests
schedule:
interval: "weekly"
+5 -2
View File
@@ -1,9 +1,11 @@
name: Docker Build and Push
name: build
on:
push:
tags:
- 'v*'
- 'v[0-9]+.[0-9]+.[0-9]+'
- 'v[0-9]+.[0-9]+.[0-9]+-*'
workflow_dispatch:
jobs:
build:
@@ -51,6 +53,7 @@ jobs:
context: .
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }}
build-args: VERSION=${{ steps.meta.outputs.version }}
cache-from: type=gha,scope=build-${{ matrix.platform }}
cache-to: type=gha,mode=max,scope=build-${{ matrix.platform }}
outputs: type=image,name=${{ secrets.DOCKER_REGISTRY }},push-by-digest=true,name-canonical=true,push=true
@@ -1,17 +1,16 @@
name: Release Charts
name: chart-release
on:
push:
branches:
- main
paths:
- 'helm/garage-ui/**'
- '!helm/garage-ui/README.md'
tags:
- 'garage-ui-chart-v*'
permissions:
contents: write
pages: write
jobs:
release:
permissions:
contents: write
runs-on: ubuntu-latest
steps:
- name: Checkout Code
@@ -33,5 +32,6 @@ jobs:
uses: helm/chart-releaser-action@v1.7.0
with:
charts_dir: helm
skip_existing: true
env:
CR_TOKEN: "${{ secrets.HELM_RELEASE_TOKEN }}"
CR_TOKEN: ${{ secrets.HELM_RELEASE_TOKEN }}
+38
View File
@@ -0,0 +1,38 @@
name: pr-title
on:
pull_request:
types: [opened, edited, synchronize, reopened]
permissions:
pull-requests: read
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: amannn/action-semantic-pull-request@v5
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
types: |
feat
fix
docs
chore
refactor
test
ci
build
perf
scopes: |
backend
frontend
helm
ci
deps
requireScope: false
subjectPattern: ^[A-Za-z].+[^.]$
subjectPatternError: |
PR title subject must start with a letter and not end with a period.
Example: "feat(helm): add support for extraEnvs"
+111
View File
@@ -0,0 +1,111 @@
name: release-please
on:
push:
branches:
- main
permissions:
contents: write
pull-requests: write
jobs:
release-please:
runs-on: ubuntu-latest
steps:
- uses: actions/create-github-app-token@v2
id: app-token
with:
app-id: ${{ secrets.RELEASE_PLEASE_APP_ID }}
private-key: ${{ secrets.RELEASE_PLEASE_APP_KEY }}
- id: rp
uses: googleapis/release-please-action@v4
with:
config-file: release-please-config.json
manifest-file: .release-please-manifest.json
token: ${{ steps.app-token.outputs.token }}
- name: Sync Chart.yaml appVersion on release PR
if: ${{ steps.rp.outputs.prs_created == 'true' }}
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
PRS: ${{ steps.rp.outputs.prs }}
REPO: ${{ github.repository }}
run: |
set -euo pipefail
echo "$PRS" | jq -c '.[]' | while read -r pr; do
branch=$(echo "$pr" | jq -r '.headBranchName')
workdir=$(mktemp -d)
git clone --branch "$branch" --depth 1 \
"https://x-access-token:${GH_TOKEN}@github.com/${REPO}.git" "$workdir"
cd "$workdir"
git config user.email "garage-ui-release-bot[bot]@users.noreply.github.com"
git config user.name "garage-ui-release-bot[bot]"
version=$(jq -r '."."' .release-please-manifest.json)
target="v${version}"
sed -i -E "s|^appVersion:.*|appVersion: ${target}|" helm/garage-ui/Chart.yaml
if ! git diff --quiet helm/garage-ui/Chart.yaml; then
git add helm/garage-ui/Chart.yaml
git commit -m "chore: sync Chart.yaml appVersion to ${target}"
git push origin "$branch"
fi
cd -
rm -rf "$workdir"
done
- name: Cut chart patch when backend released without chart
if: ${{ steps.rp.outputs.releases_created == 'true' }}
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
REPO: ${{ github.repository }}
BACKEND_RELEASED: ${{ steps.rp.outputs.release_created }}
CHART_RELEASED: ${{ steps.rp.outputs['helm/garage-ui--release_created'] }}
BACKEND_TAG: ${{ steps.rp.outputs.tag_name }}
BACKEND_VERSION: ${{ steps.rp.outputs.version }}
run: |
set -euo pipefail
if [ "$BACKEND_RELEASED" != "true" ] || [ "$CHART_RELEASED" = "true" ]; then
echo "Backend not released, or chart already released this cycle. Nothing to do."
exit 0
fi
workdir=$(mktemp -d)
git clone \
"https://x-access-token:${GH_TOKEN}@github.com/${REPO}.git" "$workdir"
cd "$workdir"
git config user.email "garage-ui-release-bot[bot]@users.noreply.github.com"
git config user.name "garage-ui-release-bot[bot]"
chart_old=$(jq -r '."helm/garage-ui"' .release-please-manifest.json)
chart_new=$(echo "$chart_old" | awk -F. '{printf "%d.%d.%d", $1, $2, $3+1}')
chart_tag="garage-ui-chart-v${chart_new}"
jq --arg v "$chart_new" '."helm/garage-ui" = $v' \
.release-please-manifest.json > .release-please-manifest.json.tmp
mv .release-please-manifest.json.tmp .release-please-manifest.json
sed -i -E "s|^version:.*|version: ${chart_new}|" helm/garage-ui/Chart.yaml
sed -i -E "s|^appVersion:.*|appVersion: ${BACKEND_TAG}|" helm/garage-ui/Chart.yaml
today=$(date -u +%Y-%m-%d)
compare_url="https://github.com/${REPO}/compare/garage-ui-chart-v${chart_old}...${chart_tag}"
entry=$(printf '## [%s](%s) (%s)\n\n\n### Miscellaneous Chores\n\n* sync chart appVersion to %s\n' \
"$chart_new" "$compare_url" "$today" "$BACKEND_TAG")
awk -v entry="$entry" 'NR==1 && /^# Changelog/ { print; print ""; print entry; next } { print }' \
helm/garage-ui/CHANGELOG.md > helm/garage-ui/CHANGELOG.md.tmp
mv helm/garage-ui/CHANGELOG.md.tmp helm/garage-ui/CHANGELOG.md
git add .release-please-manifest.json helm/garage-ui/Chart.yaml helm/garage-ui/CHANGELOG.md
git commit -m "chore: bump chart to ${chart_new} for backend ${BACKEND_TAG}"
git push origin main
git tag "$chart_tag"
git push origin "$chart_tag"
gh release create "$chart_tag" \
--repo "$REPO" \
--title "$chart_tag" \
--notes "Chart patch synced to backend ${BACKEND_TAG}. No chart template changes."
+86
View File
@@ -0,0 +1,86 @@
name: backend-tests
on:
pull_request:
paths:
- 'backend/**'
- 'Dockerfile'
- 'Makefile'
- 'scripts/coverage-gate.sh'
- '.github/workflows/test.yml'
push:
branches: [main]
permissions:
contents: read
jobs:
unit:
name: Unit tests + coverage gate
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.25'
cache: true
cache-dependency-path: backend/go.sum
- name: Generate swagger docs
run: |
go install github.com/swaggo/swag/cmd/swag@latest
cd backend && swag init
- name: Run unit tests with race detector and coverage
run: |
cd backend
go test -race -count=1 -coverprofile=../coverage.out -coverpkg=./... ./...
- name: Enforce coverage gate
run: bash scripts/coverage-gate.sh coverage.out
- name: Upload coverage artifact
if: always()
uses: actions/upload-artifact@v4
with:
name: coverage
path: coverage.out
- name: Upload coverage reports to Codecov
uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
slug: Noooste/garage-ui
files: ./coverage.out
fail_ci_if_error: false
smoke:
name: Smoke test (docker compose)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.25'
cache: true
cache-dependency-path: backend/go.sum
- name: Run smoke test
run: make test-smoke
- name: Dump compose logs on failure
if: failure()
run: |
docker compose -p garage-ui-smoke \
-f backend/tests/smoke/docker-compose.test.yml logs --no-color || true
- name: Tear down compose
if: always()
run: |
docker compose -p garage-ui-smoke \
-f backend/tests/smoke/docker-compose.test.yml down -v || true
+8 -2
View File
@@ -52,12 +52,18 @@ dist-ssr
*.sw?
.env*
!config.yaml.example
!config.example.yaml
docker-compose.*.yml
!backend/tests/smoke/docker-compose.test.yml
data/
meta/
garage.toml
!backend/tests/smoke/garage.toml
backend/docs/
config.yaml
config.yaml
docs/**/*.md
!docs/garage-setup.md
**/worktrees
+4
View File
@@ -0,0 +1,4 @@
{
".": "0.8.0",
"helm/garage-ui": "0.6.0"
}
+54
View File
@@ -0,0 +1,54 @@
# Changelog
## [0.8.0](https://github.com/Noooste/garage-ui/compare/v0.7.0...v0.8.0) (2026-05-31)
### ⚠ BREAKING CHANGES
* **backend,helm:** bind to IPv6 wildcard by default for dual-stack support
### Features
* **backend,helm:** bind to IPv6 wildcard by default for dual-stack support ([5427758](https://github.com/Noooste/garage-ui/commit/5427758eaadc4fa1327402b958b7e7e1f43aecdd))
* **docs:** add documentation generation command to Makefile ([186af18](https://github.com/Noooste/garage-ui/commit/186af18d54f739bd9b467cbf3ab9ccc2e92ddf62))
## [0.7.0](https://github.com/Noooste/garage-ui/compare/v0.6.2...v0.7.0) (2026-05-23)
### Features
* **backend,frontend:** enable quotas support in bucket settings ([#64](https://github.com/Noooste/garage-ui/issues/64)) ([1f16edd](https://github.com/Noooste/garage-ui/commit/1f16edd39cfa2f3a51cb576871642c9d23545781))
* **backend:** Support _FILE suffix on sensitive env variables ([#63](https://github.com/Noooste/garage-ui/issues/63)) ([36ec8e8](https://github.com/Noooste/garage-ui/commit/36ec8e800ea0ff5306e4d0f9f4aea4f72a5a109b))
### Bug Fixes
* **auth:** remove auto-enable token auth logic ([1b645b0](https://github.com/Noooste/garage-ui/commit/1b645b0c2c6e05dea98a7fc5d7595ca015913770))
## [0.6.2](https://github.com/Noooste/garage-ui/compare/v0.6.1...v0.6.2) (2026-05-15)
### Bug Fixes
* **helm:** update appVersion format and improve image tag handling ([709b9f2](https://github.com/Noooste/garage-ui/commit/709b9f2ad33fb3852bc23be1d647bb1d9388169b))
## [0.6.1](https://github.com/Noooste/garage-ui/compare/v0.6.0...v0.6.1) (2026-05-15)
### Bug Fixes
* Helm image tag ([#53](https://github.com/Noooste/garage-ui/issues/53)) ([ff46ff6](https://github.com/Noooste/garage-ui/commit/ff46ff623299461abade27478128f7e5ce409557))
## [0.6.0](https://github.com/Noooste/garage-ui/compare/v0.5.0...v0.6.0) (2026-05-15)
### Features
* add extraEnvs to helm chart to allow config override ([#45](https://github.com/Noooste/garage-ui/issues/45)) ([c8337de](https://github.com/Noooste/garage-ui/commit/c8337de3a885fc86a08a9be329a60c0846e52d62))
* enhance bucket credential retrieval to support read/write operations and improve caching logic ([#46](https://github.com/Noooste/garage-ui/issues/46)) ([c8cb3c4](https://github.com/Noooste/garage-ui/commit/c8cb3c49239bcf21b0abacba86622d55a202c4bd))
### Bug Fixes
* **ci:** correct appVersion tracking and remove changelog seeds ([#49](https://github.com/Noooste/garage-ui/issues/49)) ([f04c381](https://github.com/Noooste/garage-ui/commit/f04c38168d026c9746c5fb9f8182cb9fadc06f53))
* **ci:** remove CHANGELOG.md seeds so release-please owns them ([#51](https://github.com/Noooste/garage-ui/issues/51)) ([cb3839e](https://github.com/Noooste/garage-ui/commit/cb3839e6189ec1d2a6609ff500ab7a79d0f5cbd9))
+58
View File
@@ -130,3 +130,61 @@ Enhancement suggestions are tracked as [GitHub issues](https://github.com/Nooost
- **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.
## Commit Messages
This repo uses [Conventional Commits](https://www.conventionalcommits.org/) on
PR titles to drive automated releases and changelogs via
[release-please](https://github.com/googleapis/release-please).
PRs are squash-merged, so **only the PR title needs to follow the format**.
Branch commits can be anything.
### Format
```
<type>(<optional-scope>): <subject>
```
### Allowed types
| Type | Use when… | Triggers release? |
|------------|------------------------------------------|-------------------|
| `feat` | adding new user-facing functionality | minor bump |
| `fix` | fixing a bug | patch bump |
| `feat!` | breaking change (pre-1.0: still minor) | minor bump |
| `perf` | performance improvement | patch bump |
| `docs` | documentation only | no |
| `refactor` | code change that's not feat/fix | no |
| `chore` | tooling, deps, build | no |
| `test` | adding/fixing tests | no |
| `ci` | CI workflow changes | no |
| `build` | build system changes | no |
Pre-1.0 SemVer: while the project is `<1.0`, breaking changes (`feat!`) bump
the **minor** version, not the major. Once the project declares `1.0.0`,
`feat!` will bump major as per standard SemVer.
### Allowed scopes
- `backend` — Go API server
- `frontend` — React app
- `helm` — Helm chart
- `ci` — CI workflows
- `deps` — dependency updates
Scope is optional. Use it when the change is clearly scoped to one component
(it routes the version bump to that component only).
### Examples
```
feat(backend): add bucket quota enforcement
fix(frontend): correct theme toggle in Safari
feat(helm): support extraEnvs in deployment template
chore(deps): bump axios from 1.15.0 to 1.15.2
docs: clarify OIDC setup in README
```
The PR title is automatically validated by the `pr-title` GitHub Action.
+8 -7
View File
@@ -1,4 +1,4 @@
FROM --platform=$BUILDPLATFORM node:25-alpine3.22 AS frontend-builder
FROM --platform=$BUILDPLATFORM node:25-alpine3.23 AS frontend-builder
WORKDIR /app/frontend
@@ -11,7 +11,7 @@ COPY frontend/ .
RUN npm run build
FROM --platform=$BUILDPLATFORM golang:1.25.4-alpine3.22 AS backend-builder
FROM --platform=$BUILDPLATFORM golang:1.26.0-alpine3.23 AS backend-builder
ARG TARGETOS
ARG TARGETARCH
@@ -29,14 +29,16 @@ RUN --mount=type=cache,target=/go/pkg/mod \
COPY backend .
ARG VERSION=dev
RUN --mount=type=cache,target=/root/.cache/go-build \
swag init
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -a -installsuffix cgo -o garage-ui .
CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -a -installsuffix cgo -ldflags "-X main.version=${VERSION}" -o garage-ui .
FROM alpine:3.22
FROM alpine:3.23.3
WORKDIR /app
@@ -46,14 +48,13 @@ RUN addgroup -g 1000 garageui && \
adduser -D -u 1000 -G garageui garageui
COPY --from=backend-builder --chown=garageui:garageui /app/garage-ui .
COPY --from=frontend-builder /app/frontend/dist ./frontend/dist
COPY --from=frontend-builder --chown=garageui:garageui /app/frontend/dist ./frontend/dist
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"]
+28 -3
View File
@@ -1,9 +1,9 @@
.PHONY: help build up down restart logs clean ps health dev-build dev-up dev-down dev-restart dev-logs prod-up prod-down prod-restart prod-logs push stop
# Variables
DOCKER_COMPOSE = docker-compose
DOCKER_COMPOSE_DEV = docker-compose -f docker-compose.dev.yml
DOCKER_COMPOSE_PROD = docker-compose -f docker-compose.yml
DOCKER_COMPOSE = docker compose
DOCKER_COMPOSE_DEV = docker compose -f docker-compose.dev.yml
DOCKER_COMPOSE_PROD = docker compose -f docker-compose.yml
IMAGE_NAME = noooste/garage-ui
IMAGE_TAG = latest
@@ -135,3 +135,28 @@ install:
update: prod-pull prod-restart
.DEFAULT_GOAL := help
.PHONY: test test-race test-cover test-smoke
## test: Run backend unit tests
test:
cd backend && go test ./...
## test-race: Run backend unit tests with the race detector
test-race:
cd backend && go test -race ./...
## test-cover: Run backend unit tests with coverage and enforce the coverage gate
test-cover:
cd backend && go test -coverprofile=../coverage.out -coverpkg=./... ./...
bash scripts/coverage-gate.sh coverage.out
## test-smoke: Run the docker compose smoke test (requires Docker + compose v2)
test-smoke:
cd backend && go test -tags=smoke -timeout 10m ./tests/smoke/...
docs:
@echo "Generating documentation..."
@mkdir -p docs
@echo "Documentation generated in the 'docs' directory."
swag init -g backend/cmd/garage-ui/main.go -o docs --parseDependency --parseInternal
+180 -88
View File
@@ -1,13 +1,15 @@
# Garage UI
<p align="center">
<a href="https://github.com/Noooste/garage-ui/actions/workflows/build.yml"><img src="https://github.com/Noooste/garage-ui/actions/workflows/build.yml/badge.svg" alt="Docker Build" /></a>
<a href="https://github.com/Noooste/garage-ui/actions/workflows/chart-release.yml"><img src="https://github.com/Noooste/garage-ui/actions/workflows/chart-release.yml/badge.svg" alt="Helm Chart" /></a>
<a href="https://codecov.io/gh/Noooste/garage-ui"><img src="https://codecov.io/gh/Noooste/garage-ui/branch/main/graph/badge.svg" alt="Coverage" /></a>
<a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/License-MIT-yellow.svg" alt="License: MIT" /></a>
<a href="https://go.dev/"><img src="https://img.shields.io/badge/Go-1.25%2B-00ADD8?logo=go" alt="Go Version" /></a>
<a href="https://artifacthub.io/packages/search?repo=garage-ui"><img src="https://img.shields.io/endpoint?url=https://artifacthub.io/badge/repository/garage-ui" alt="Artifact Hub" /></a>
</p>
A web interface for managing [Garage](https://garagehq.deuxfleurs.fr/) object storage clusters.
# Garage UI - Web Dashboard for Garage S3 Storage
[![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)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Go Version](https://img.shields.io/badge/Go-1.25%2B-00ADD8?logo=go)](https://go.dev/)
[![Node Version](https://img.shields.io/badge/Node-25%2B-339933?logo=node.js)](https://nodejs.org/)
[![Artifact Hub](https://img.shields.io/endpoint?url=https://artifacthub.io/badge/repository/garage-ui)](https://artifacthub.io/packages/search?repo=garage-ui)
A modern web interface to manage <a href="https://garagehq.deuxfleurs.fr/">Garage</a> object storage clusters. Browse buckets, manage access keys, monitor your cluster, all from your browser.
---
@@ -24,97 +26,37 @@ A web interface for managing [Garage](https://garagehq.deuxfleurs.fr/) object st
## Features
- Bucket and object management
- User access control
- Cluster monitoring
- Multiple authentication options (none/basic/OIDC)
- Drag-and-drop file uploads
- **Bucket management** - create, configure, and browse buckets with drag-and-drop file uploads
- **Access key management** - create keys, assign per-bucket permissions
- **Cluster overview** - monitor node status, layout configuration, and storage usage
- **Flexible authentication** - no auth, basic credentials, or OIDC (Keycloak, Authentik, etc.)
- **Easy deployment** - single Docker image or Helm chart, configure with one YAML file
## Quick Start
### Prerequisites
- Docker & Docker Compose
- Garage S3 cluster (v2.1.0+) or use the included setup
- A running Garage cluster (v2.1.0+) - [setup guide](docs/garage-setup.md) if you need one
### 1. Clone & Setup
### 1. Clone & Configure
```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
cp config.example.yaml config.yaml
```
Edit `config.yaml` with your Garage endpoints and admin token (from `garage.toml`).
### 4. Start UI
### 2. Start
```bash
docker-compose up -d garage-ui
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
@@ -135,22 +77,108 @@ helm install garage-ui garage-ui/garage-ui \
--set garage.adminToken=your-token
```
## Development
Access at http://localhost:8080
### Quick Start with garage.toml
If you already have a running Garage instance, you can point Garage UI directly at your `garage.toml` -- no `config.yaml` needed:
Backend (Go 1.25+):
```bash
cd backend
go run main.go --config ../config.yaml
./garage-ui --garage-toml /etc/garage.toml
```
Frontend (Node.js 25+):
Garage UI reads the S3 endpoint, admin endpoint, admin token, and S3 region straight from the TOML file. When no authentication method is explicitly configured, **token auth auto-enables**: the login page asks for the Garage admin token, giving you a login wall with zero extra config.
**Bind address handling:** Wildcard addresses like `0.0.0.0` or `[::]` are converted to `127.0.0.1` so the UI can reach Garage on localhost. Inside containers this won't work -- override the endpoint explicitly with environment variables or a config file.
**Docker:**
```bash
cd frontend
npm install
npm run dev
docker run -d -p 8080:8080 \
-v /etc/garage.toml:/etc/garage.toml:ro \
-e GARAGE_UI_GARAGE_TOML=/etc/garage.toml \
-e GARAGE_UI_GARAGE_ENDPOINT=http://garage:3900 \
-e GARAGE_UI_GARAGE_ADMIN_ENDPOINT=http://garage:3903 \
noooste/garage-ui:latest
```
API docs: http://localhost:8080/api/v1/
The endpoint overrides are needed because the container cannot reach `127.0.0.1` on the host.
**Combining flags:** Use `--garage-toml` for Garage connection values and `--config` for everything else (auth, CORS, logging, etc.):
```bash
./garage-ui --garage-toml /etc/garage.toml --config config.yaml
```
**Precedence order** (highest wins): built-in defaults < `garage.toml` < `config.yaml` < environment variables.
## 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"
```
Server bind host is configured by `server.host` (default: `::`). IPv6 literals like `::` and `::1` are supported.
```yaml
server:
host: "::" # IPv6 wildcard (dual-stack-preferred)
port: 8080
```
If your environment needs explicit IPv4-only binding, set `server.host: "0.0.0.0"`.
See [config.example.yaml](config.example.yaml) for all options including authentication, CORS, and logging.
### 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
```
#### Loading sensitive values from files (`_FILE` suffix)
For Docker/Kubernetes secret integration, sensitive env vars can be read from files instead of plain values. Set `{VAR}_FILE=/path/to/file` and garage-ui reads the file's contents (trailing CR/LF trimmed) as the value. If both `{VAR}` and `{VAR}_FILE` are set, `_FILE` wins and a warning is logged. A missing or unreadable file causes startup to fail.
Supported vars:
- `GARAGE_UI_GARAGE_ADMIN_TOKEN_FILE`
- `GARAGE_UI_AUTH_ADMIN_USERNAME_FILE`
- `GARAGE_UI_AUTH_ADMIN_PASSWORD_FILE`
- `GARAGE_UI_AUTH_JWT_PRIVATE_KEY_FILE`
- `GARAGE_UI_AUTH_OIDC_CLIENT_ID_FILE`
- `GARAGE_UI_AUTH_OIDC_CLIENT_SECRET_FILE`
Example with Docker Compose secrets:
```yaml
services:
garage-ui:
image: noooste/garage-ui:latest
environment:
GARAGE_UI_AUTH_ADMIN_PASSWORD_FILE: /run/secrets/admin_password
secrets:
- admin_password
secrets:
admin_password:
file: ./admin_password.txt
```
This matches the convention used by the official Postgres and MySQL Docker images. Helm users do not need this — the chart already injects secrets via `existingSecret` references.
## Garage Configuration
@@ -172,6 +200,23 @@ api_bind_addr = "[::]:3900" # Default: 127.0.0.1:3900
For complete Garage configuration, see the [official documentation](https://garagehq.deuxfleurs.fr/documentation/reference-manual/configuration/).
## 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/
## Troubleshooting
**Connection failed:**
@@ -183,8 +228,55 @@ curl http://localhost:3903/status -H "Authorization: Bearer your-token"
```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 x buckets)
- [ ] Key rotation helper
- [ ] Copy-ready snippets per key (aws-cli, rclone, restic, s3cmd, mc, Terraform)
**Cluster**
- [X] Support Garage v1 to latest
- [ ] 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)
@@ -193,4 +285,4 @@ MIT - see [LICENSE](LICENSE)
- [Issues](https://github.com/Noooste/garage-ui/issues)
- [Contributing](CONTRIBUTING.md)
- [Garage Docs](https://garagehq.deuxfleurs.fr/documentation/)
- [Garage Docs](https://garagehq.deuxfleurs.fr/documentation/)
+44 -46
View File
@@ -3,63 +3,62 @@ 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.3.0
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/google/uuid v1.6.0
github.com/minio/minio-go/v7 v7.1.0
github.com/pelletier/go-toml/v2 v2.3.1
github.com/rs/zerolog v1.35.1
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/cespare/xxhash/v2 v2.3.0 // 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/fsnotify/fsnotify v1.9.0 // indirect
github.com/fatih/color v1.19.0 // indirect
github.com/fsnotify/fsnotify v1.10.1 // 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/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.6 // 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/klauspost/compress v1.18.6 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/klauspost/crc32 v1.3.0 // 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.22 // 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/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/spf13/afero v1.15.0 // indirect
@@ -67,18 +66,17 @@ require (
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.71.0 // indirect
github.com/zeebo/xxh3 v1.1.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
gopkg.in/yaml.v3 v3.0.1 // indirect
golang.org/x/crypto v0.52.0 // indirect
golang.org/x/mod v0.36.0 // indirect
golang.org/x/net v0.55.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.45.0 // indirect
golang.org/x/text v0.37.0 // indirect
golang.org/x/tools v0.45.0 // indirect
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
)
+138 -136
View File
@@ -1,168 +1,163 @@
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.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/utls v1.3.20 h1:QzBNGGJ184bNMLodOzvM9YWc4vZ36QodIjqFQOHoZ88=
github.com/Noooste/utls v1.3.20/go.mod h1:XEy+VEbTxmH6krfSG5YT7wDbjHTEi2zUXTG33R0PAAg=
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.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/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-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
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.18.0 h1:V9orjXynvu5wiC9SemFTWnG4F45v403aIcjWo0d41+A=
github.com/coreos/go-oidc/v3 v3.18.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
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=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
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/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
github.com/fxamacker/cbor/v2 v2.9.1 h1:2rWm8B193Ll4VdjsJY28jxs70IdDsHRWgQYAI80+rMQ=
github.com/fxamacker/cbor/v2 v2.9.1/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
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=
github.com/gaukas/godicttls v0.0.4/go.mod h1:l6EenT4TLWgTdwslVb4sEMOCf7Bv0JAK67deKr9/NCI=
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.23.1 h1:1HBACs7XIwR2RcmItfdSFlALhGbe6S92p0ry4d1GWg4=
github.com/go-openapi/jsonpointer v0.23.1/go.mod h1:iWRmZTrGn7XwYhtPt/fvdSFj1OfNBngqRT2UG3BxSqY=
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.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/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/jsonutils/fixtures_test v0.26.0 h1:apqeINu/ICHouqiRZbyFvuDge5jCmmLTqGQ9V95EaOM=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.0/go.mod h1:AyM6QT8uz5IdKxk5akv0y6u4QvcL9GWERt0Jx/F/R8Y=
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-openapi/testify/enable/yaml/v2 v2.4.2 h1:5zRca5jw7lzVREKCZVNBpysDNBjj74rBh0N2BGQbSR0=
github.com/go-openapi/testify/enable/yaml/v2 v2.4.2/go.mod h1:XVevPw5hUXuV+5AkI1u1PeAm27EQVrhXTTCPAF85LmE=
github.com/go-openapi/testify/v2 v2.4.2 h1:tiByHpvE9uHrrKjOszax7ZvKB7QOgizBWGBLuq0ePx4=
github.com/go-openapi/testify/v2 v2.4.2/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw=
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/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/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
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/fiber/v3 v3.3.0 h1:QBd3sYCqdy6Qs5gJYzSw4I4SbqL204jPqpdub/ueiw8=
github.com/gofiber/fiber/v3 v3.3.0/go.mod h1:YH7/TAoRaU4kF8slDCtQuFJ1NzC+3MtxUI4KfvQtaIA=
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.3 h1:qJyfS/t7s7Z4+/zlU1i1pafYNP2+xLupVPgkW8ce1uI=
github.com/gofiber/utils/v2 v2.0.3/go.mod h1:GGERKU3Vhj5z6hS8YKvxL99A54DjOvTFZ0cjZnG4Lj4=
github.com/gofiber/utils/v2 v2.0.6 h1:7fXYy7nSsyqbH0GQUMtK4Kwjy4J7R5742VM7JsZxzOs=
github.com/gofiber/utils/v2 v2.0.6/go.mod h1:p7mAHAk3+oUK10ZX2xTw9fZQixb4hCg8SKd4IH2xroU=
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=
github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo=
github.com/google/pprof v0.0.0-20250607225305-033d6d78b36a h1://KbezygeMJZCSHH+HgUZiTeSoiuFspbMg1ge+eFj18=
github.com/google/pprof v0.0.0-20250607225305-033d6d78b36a/go.mod h1:5hDyRhoBCxViHszMt12TnOpEI4VVi+U8Gm9iphldiMA=
github.com/google/pprof v0.0.0-20251213031049-b05bdaca462f h1:HU1RgM6NALf/KW9HEY6zry3ADbDKcmpQ+hJedoNGQYQ=
github.com/google/pprof v0.0.0-20251213031049-b05bdaca462f/go.mod h1:67FPmZWbr+KDT/VlpWtw6sO9XSjpJmLuHpoLmWiTGgY=
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/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
github.com/klauspost/compress v1.18.6/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.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/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=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
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/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
github.com/mattn/go-isatty v0.0.22/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/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/minio/minio-go/v7 v7.0.100 h1:ShkWi8Tyj9RtU57OQB2HIXKz4bFgtVib0bbT1sbtLI8=
github.com/minio/minio-go/v7 v7.0.100/go.mod h1:EtGNKtlX20iL2yaYnxEigaIvj0G0GwSDnifnG8ClIdw=
github.com/minio/minio-go/v7 v7.1.0 h1:QEt5IStDpxgGjEdtOgpiZ5QhmSl3ax7qy61vi2SwHO8=
github.com/minio/minio-go/v7 v7.1.0/go.mod h1:Dm7WS1AgLmBa0NcQD6SeJnJf+K/EUW3GR7Ks6olB3OA=
github.com/onsi/ginkgo/v2 v2.27.3 h1:ICsZJ8JoYafeXFFlFAG75a7CxMsJHwgKwtO+82SE9L8=
github.com/onsi/ginkgo/v2 v2.27.3/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
github.com/onsi/gomega v1.38.3 h1:eTX+W6dobAYfFeGC2PV6RwXRu/MyT+cQguijutvkpSM=
github.com/onsi/gomega v1.38.3/go.mod h1:ZCU1pkQcXDO5Sl9/VVEGlDyp+zm0m1cmeG5TOzLgdh4=
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/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc=
github.com/pelletier/go-toml/v2 v2.3.1/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=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
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.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
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/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI=
github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw=
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/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 +174,68 @@ 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.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.70.0 h1:LAhMGcWk13QZWm85+eg8ZBNbrq5mnkWFGbHMUJHIdXA=
github.com/valyala/fasthttp v1.70.0/go.mod h1:oDZEHHkJ/Buyklg6uURmYs19442zFSnCIfX3j1FY3pE=
github.com/valyala/fasthttp v1.71.0 h1:tepR7H+Guh9VUqxxcPggYi8R3lGUu2Rsdh+z7/FCY3k=
github.com/valyala/fasthttp v1.71.0/go.mod h1:z1sDUvOShhXq/C9mwH/fSm1Vb71tUJwmQdgkBrBNwnA=
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=
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
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.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
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.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
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.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
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.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.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.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.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
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.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
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/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=
+90 -53
View File
@@ -1,10 +1,14 @@
package auth
import (
"Noooste/garage-ui/pkg/logger"
"context"
"crypto/subtle"
"crypto/tls"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"strings"
"Noooste/garage-ui/internal/config"
@@ -13,13 +17,14 @@ import (
"golang.org/x/oauth2"
)
// AuthService handles authentication operations
type AuthService struct {
// Service handles authentication operations
type Service struct {
authConfig *config.AuthConfig
serverConfig *config.ServerConfig
oidcProvider *oidc.Provider
oidcVerifier *oidc.IDTokenVerifier
oauth2Config *oauth2.Config
oidcClient *http.Client
jwtService *JWTService
}
@@ -32,13 +37,13 @@ type UserInfo struct {
}
// NewAuthService creates a new authentication service
func NewAuthService(authCfg *config.AuthConfig, serverCfg *config.ServerConfig) (*AuthService, error) {
func NewAuthService(authCfg *config.AuthConfig, serverCfg *config.ServerConfig) (*Service, error) {
jwtService, err := NewJWTServiceWithKey(authCfg.JWTPrivKey)
if err != nil {
return nil, fmt.Errorf("failed to initialize JWT service: %w", err)
}
service := &AuthService{
service := &Service{
authConfig: authCfg,
serverConfig: serverCfg,
jwtService: jwtService,
@@ -55,8 +60,16 @@ func NewAuthService(authCfg *config.AuthConfig, serverCfg *config.ServerConfig)
}
// initOIDC initializes the OIDC provider and configuration
func (a *AuthService) initOIDC() error {
ctx := context.Background()
func (a *Service) initOIDC() error {
if a.authConfig.OIDC.TLSSkipVerify {
a.oidcClient = &http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
},
}
}
ctx := a.oidcContext(context.Background())
// Create OIDC provider
provider, err := oidc.NewProvider(ctx, a.authConfig.OIDC.IssuerURL)
@@ -90,8 +103,15 @@ func (a *AuthService) initOIDC() error {
return nil
}
func (a *Service) oidcContext(ctx context.Context) context.Context {
if a.oidcClient == nil {
return ctx
}
return oidc.ClientContext(ctx, a.oidcClient)
}
// ValidateBasicAuth validates basic authentication credentials
func (a *AuthService) ValidateBasicAuth(username, password string) bool {
func (a *Service) ValidateBasicAuth(username, password string) bool {
// Use constant-time comparison to prevent timing attacks
usernameMatch := subtle.ConstantTimeCompare(
[]byte(username),
@@ -106,37 +126,8 @@ func (a *AuthService) ValidateBasicAuth(username, password string) bool {
return usernameMatch && passwordMatch
}
// ParseBasicAuth parses the Authorization header for basic auth
func ParseBasicAuth(authHeader string) (username, password string, ok bool) {
if authHeader == "" {
return "", "", false
}
// Check if it's a Basic auth header
const prefix = "Basic "
if !strings.HasPrefix(authHeader, prefix) {
return "", "", false
}
// Decode base64 credentials
encoded := authHeader[len(prefix):]
decoded, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
return "", "", false
}
// Split username:password
credentials := string(decoded)
parts := strings.SplitN(credentials, ":", 2)
if len(parts) != 2 {
return "", "", false
}
return parts[0], parts[1], true
}
// GetAuthorizationURL returns the OIDC authorization URL for login
func (a *AuthService) GetAuthorizationURL(state string) (string, error) {
func (a *Service) GetAuthorizationURL(state string) (string, error) {
if a.oauth2Config == nil {
return "", fmt.Errorf("OIDC not initialized")
}
@@ -145,12 +136,12 @@ 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")
}
token, err := a.oauth2Config.Exchange(ctx, code)
token, err := a.oauth2Config.Exchange(a.oidcContext(ctx), code)
if err != nil {
return nil, fmt.Errorf("failed to exchange code: %w", err)
}
@@ -159,13 +150,13 @@ 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")
}
// Verify the ID token
idToken, err := a.oidcVerifier.Verify(ctx, rawIDToken)
idToken, err := a.oidcVerifier.Verify(a.oidcContext(ctx), rawIDToken)
if err != nil {
return nil, fmt.Errorf("failed to verify ID token: %w", err)
}
@@ -192,11 +183,13 @@ func (a *AuthService) VerifyIDToken(ctx context.Context, rawIDToken string) (*Us
}
// GetUserInfo retrieves user information from the OIDC provider
func (a *AuthService) GetUserInfo(ctx context.Context, token *oauth2.Token) (*UserInfo, error) {
func (a *Service) GetUserInfo(ctx context.Context, token *oauth2.Token) (*UserInfo, error) {
if a.oidcProvider == nil {
return nil, fmt.Errorf("OIDC not initialized")
}
ctx = a.oidcContext(ctx)
// Create OAuth2 token source
tokenSource := a.oauth2Config.TokenSource(ctx, token)
@@ -212,6 +205,8 @@ func (a *AuthService) GetUserInfo(ctx context.Context, token *oauth2.Token) (*Us
return nil, fmt.Errorf("failed to parse user info claims: %w", err)
}
logger.Debug().Interface("claims", claims).Msg("Extracted user info claims")
// Build user info
userInfo := &UserInfo{
Username: extractClaim(claims, a.authConfig.OIDC.UsernameAttribute),
@@ -227,15 +222,48 @@ func (a *AuthService) GetUserInfo(ctx context.Context, token *oauth2.Token) (*Us
return userInfo, nil
}
// IsAdmin checks if the user has admin role
func (a *AuthService) IsAdmin(userInfo *UserInfo) bool {
if a.authConfig.OIDC.AdminRole == "" {
// 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 any of the configured admin roles.
func (a *Service) IsAdmin(userInfo *UserInfo) bool {
adminRoles := a.authConfig.OIDC.EffectiveAdminRoles()
if len(adminRoles) == 0 {
return false
}
for _, role := range userInfo.Roles {
if role == a.authConfig.OIDC.AdminRole {
return true
for _, adminRole := range adminRoles {
if role == adminRole {
return true
}
}
}
@@ -316,22 +344,31 @@ func extractStringArray(value interface{}) []string {
}
// GenerateStateToken generates a secure CSRF state token
func (a *AuthService) GenerateStateToken() (string, error) {
func (a *Service) GenerateStateToken() (string, error) {
return a.jwtService.GenerateStateToken()
}
// ValidateAndConsumeState validates and consumes a CSRF state token
func (a *AuthService) ValidateAndConsumeState(token string) bool {
func (a *Service) ValidateAndConsumeState(token string) bool {
return a.jwtService.ValidateAndConsumeState(token)
}
// GenerateSessionToken generates a JWT session token for the user
func (a *AuthService) GenerateSessionToken(userInfo *UserInfo) (string, error) {
return a.jwtService.GenerateToken(userInfo, a.authConfig.OIDC.SessionMaxAge)
// GenerateSessionToken generates a JWT session token for the user.
//
// SessionMaxAge lives under the OIDC config block, but the JWT is shared with
// admin-only deployments that never set it. Treat a non-positive value as
// "not configured" and fall back to 24h so the token isn't issued already
// expired (which would make every subsequent request fail with 401).
func (a *Service) GenerateSessionToken(userInfo *UserInfo) (string, error) {
maxAge := a.authConfig.OIDC.SessionMaxAge
if maxAge <= 0 {
maxAge = 86400
}
return a.jwtService.GenerateToken(userInfo, maxAge)
}
// ValidateSessionToken validates a JWT session token and returns user info
func (a *AuthService) ValidateSessionToken(tokenString string) (*UserInfo, error) {
func (a *Service) ValidateSessionToken(tokenString string) (*UserInfo, error) {
claims, err := a.jwtService.ValidateToken(tokenString)
if err != nil {
return nil, err
+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")
}
}
+648
View File
@@ -0,0 +1,648 @@
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)
}
}
// newTLSDiscoveryServer is the same as newDiscoveryServer but serves the OIDC
// discovery document over HTTPS using httptest's self-signed certificate. The
// cert is not signed by any system-trusted CA, so any HTTP client without
// InsecureSkipVerify (or the cert pinned) will fail to connect.
func newTLSDiscoveryServer(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.NewTLSServer(mux)
t.Cleanup(srv.Close)
return srv
}
func TestNewAuthService_OIDCEnabled_SelfSignedIssuer_FailsWithoutTLSSkipVerify(t *testing.T) {
disco := newTLSDiscoveryServer(t)
authCfg := &config.AuthConfig{
OIDC: config.OIDCConfig{
Enabled: true,
ClientID: "test-client",
IssuerURL: disco.URL,
Scopes: []string{"openid"},
TLSSkipVerify: false,
},
}
srvCfg := &config.ServerConfig{RootURL: "https://garage-ui.example"}
_, err := NewAuthService(authCfg, srvCfg)
if err == nil {
t.Fatal("expected TLS verification error from self-signed issuer, got nil")
}
if !strings.Contains(err.Error(), "failed to initialize OIDC") {
t.Errorf("expected wrapping error, got %v", err)
}
}
func TestNewAuthService_OIDCEnabled_SelfSignedIssuer_SucceedsWithTLSSkipVerify(t *testing.T) {
disco := newTLSDiscoveryServer(t)
authCfg := &config.AuthConfig{
OIDC: config.OIDCConfig{
Enabled: true,
ClientID: "test-client",
IssuerURL: disco.URL,
Scopes: []string{"openid"},
TLSSkipVerify: true,
},
}
srvCfg := &config.ServerConfig{RootURL: "https://garage-ui.example"}
svc, err := NewAuthService(authCfg, srvCfg)
if err != nil {
t.Fatalf("NewAuthService with tls_skip_verify=true should succeed: %v", err)
}
if svc.oidcProvider == nil {
t.Fatal("oidcProvider not initialized")
}
if svc.oidcClient == nil {
t.Fatal("oidcClient should be set when tls_skip_verify=true")
}
}
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
adminRoles []string
userRoles []string
want bool
}{
{"empty admin role config returns false", "", nil, []string{"admin"}, false},
{"user has admin role", "admin", nil, []string{"viewer", "admin"}, true},
{"user lacks admin role", "admin", nil, []string{"viewer"}, false},
{"user has no roles", "admin", nil, nil, false},
{"role match is exact (case-sensitive)", "admin", nil, []string{"Admin"}, false},
// admin_roles list
{"user matches first entry in admin_roles", "", []string{"group1", "group2"}, []string{"group1"}, true},
{"user matches second entry in admin_roles", "", []string{"group1", "group2"}, []string{"group2"}, true},
{"user matches none of admin_roles", "", []string{"group1", "group2"}, []string{"group3"}, false},
{"empty admin_roles list returns false", "", []string{}, []string{"group1"}, false},
// admin_role + admin_roles merge
{"matches single admin_role when admin_roles set too", "admin", []string{"ops"}, []string{"admin"}, true},
{"matches admin_roles entry when admin_role set too", "admin", []string{"ops"}, []string{"ops"}, true},
{"matches neither admin_role nor admin_roles", "admin", []string{"ops"}, []string{"viewer"}, false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
svc := &Service{
authConfig: &config.AuthConfig{
OIDC: config.OIDCConfig{AdminRole: tc.adminRole, AdminRoles: tc.adminRoles},
},
}
if got := svc.IsAdmin(&UserInfo{Roles: tc.userRoles}); got != tc.want {
t.Errorf("IsAdmin = %v, want %v", got, tc.want)
}
})
}
}
+469
View File
@@ -0,0 +1,469 @@
package auth
import (
"crypto/ed25519"
"crypto/rand"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"errors"
"strings"
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
)
// generatePKCS8PEM produces a PEM-encoded PKCS#8 Ed25519 private key.
// This is the format `openssl genpkey -algorithm ED25519` emits and the
// format the production code documents in jwt_private_key.
func generatePKCS8PEM(t *testing.T) (string, ed25519.PrivateKey) {
t.Helper()
_, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("ed25519.GenerateKey: %v", err)
}
der, err := x509.MarshalPKCS8PrivateKey(priv)
if err != nil {
t.Fatalf("MarshalPKCS8PrivateKey: %v", err)
}
pemBytes := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der})
return string(pemBytes), priv
}
// generateRawPEM wraps a raw 64-byte Ed25519 key in a PEM block. The
// production code accepts this as a fallback when PKCS#8 parsing fails.
func generateRawPEM(t *testing.T) (string, ed25519.PrivateKey) {
t.Helper()
_, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("ed25519.GenerateKey: %v", err)
}
pemBytes := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: priv})
return string(pemBytes), priv
}
func TestParseEd25519PrivateKeyFromPEM_PKCS8(t *testing.T) {
pemStr, want := generatePKCS8PEM(t)
got, err := parseEd25519PrivateKeyFromPEM(pemStr)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !got.Equal(want) {
t.Errorf("parsed key does not equal generated key")
}
}
func TestParseEd25519PrivateKeyFromPEM_RawBytes(t *testing.T) {
pemStr, want := generateRawPEM(t)
got, err := parseEd25519PrivateKeyFromPEM(pemStr)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !got.Equal(want) {
t.Errorf("parsed raw key does not equal generated key")
}
}
func TestParseEd25519PrivateKeyFromPEM_NotPEM(t *testing.T) {
_, err := parseEd25519PrivateKeyFromPEM("this is not a pem block")
if err == nil {
t.Fatal("expected error for non-PEM input, got nil")
}
if !strings.Contains(err.Error(), "decode PEM block") {
t.Errorf("unexpected error: %v", err)
}
}
func TestParseEd25519PrivateKeyFromPEM_PKCS8WrongKeyType(t *testing.T) {
// Generate a non-Ed25519 PKCS#8 key (RSA would require crypto/rsa; instead
// we craft a PKCS#8 wrapping for an ECDSA key via x509). The simplest
// portable way is to use a known-bad DER blob: a PKCS#8 wrapping of an
// ed25519 PUBLIC key, which ParsePKCS8PrivateKey will reject as not a
// private key. To keep the test deterministic and dependency-free, we
// instead build a PEM of length-mismatched bytes that's neither PKCS#8
// nor 64 raw bytes.
pemBytes := pem.EncodeToMemory(&pem.Block{
Type: "PRIVATE KEY",
Bytes: []byte("definitely not a valid pkcs8 or raw ed25519 key"),
})
_, err := parseEd25519PrivateKeyFromPEM(string(pemBytes))
if err == nil {
t.Fatal("expected error for invalid key bytes, got nil")
}
if !strings.Contains(err.Error(), "invalid Ed25519 private key format") {
t.Errorf("unexpected error: %v", err)
}
}
func TestNewJWTService_AutoGeneratesKeyPair(t *testing.T) {
svc, err := NewJWTService()
if err != nil {
t.Fatalf("NewJWTService: %v", err)
}
if svc.privateKey == nil {
t.Error("privateKey is nil after auto-generate")
}
if svc.publicKey == nil {
t.Error("publicKey is nil after auto-generate")
}
if len(svc.privateKey) != ed25519.PrivateKeySize {
t.Errorf("privateKey size = %d, want %d", len(svc.privateKey), ed25519.PrivateKeySize)
}
if len(svc.publicKey) != ed25519.PublicKeySize {
t.Errorf("publicKey size = %d, want %d", len(svc.publicKey), ed25519.PublicKeySize)
}
if svc.stateStore == nil || svc.stateStore.states == nil {
t.Error("stateStore not initialized")
}
}
func TestNewJWTServiceWithKey_EmptyStringAutoGenerates(t *testing.T) {
svc, err := NewJWTServiceWithKey("")
if err != nil {
t.Fatalf("NewJWTServiceWithKey(\"\"): %v", err)
}
if svc.privateKey == nil || svc.publicKey == nil {
t.Error("expected auto-generated keys for empty PEM input")
}
}
func TestNewJWTServiceWithKey_PKCS8(t *testing.T) {
pemStr, want := generatePKCS8PEM(t)
svc, err := NewJWTServiceWithKey(pemStr)
if err != nil {
t.Fatalf("NewJWTServiceWithKey: %v", err)
}
if !svc.privateKey.Equal(want) {
t.Error("loaded privateKey does not match input")
}
// Public key must match the public part of the loaded private key.
wantPub := want.Public().(ed25519.PublicKey)
if !svc.publicKey.Equal(wantPub) {
t.Error("derived publicKey does not match")
}
}
func TestNewJWTServiceWithKey_BadPEMReturnsWrappedError(t *testing.T) {
_, err := NewJWTServiceWithKey("garbage")
if err == nil {
t.Fatal("expected error for bad PEM, got nil")
}
if !strings.Contains(err.Error(), "failed to parse Ed25519 private key") {
t.Errorf("expected wrapping error, got %v", err)
}
}
func newTestUserInfo() *UserInfo {
return &UserInfo{
Username: "alice",
Email: "alice@example.com",
Name: "Alice Example",
Roles: []string{"admin", "viewer"},
}
}
func TestGenerateAndValidateToken_RoundTrip(t *testing.T) {
svc, err := NewJWTService()
if err != nil {
t.Fatalf("NewJWTService: %v", err)
}
user := newTestUserInfo()
tok, err := svc.GenerateToken(user, 60)
if err != nil {
t.Fatalf("GenerateToken: %v", err)
}
if tok == "" {
t.Fatal("GenerateToken returned empty string")
}
claims, err := svc.ValidateToken(tok)
if err != nil {
t.Fatalf("ValidateToken: %v", err)
}
if claims.Username != user.Username {
t.Errorf("Username = %q, want %q", claims.Username, user.Username)
}
if claims.Email != user.Email {
t.Errorf("Email = %q, want %q", claims.Email, user.Email)
}
if claims.Name != user.Name {
t.Errorf("Name = %q, want %q", claims.Name, user.Name)
}
if len(claims.Roles) != 2 || claims.Roles[0] != "admin" || claims.Roles[1] != "viewer" {
t.Errorf("Roles = %v, want [admin viewer]", claims.Roles)
}
// ExpiresAt should be ~60s in the future.
if claims.ExpiresAt == nil {
t.Fatal("ExpiresAt nil")
}
if d := time.Until(claims.ExpiresAt.Time); d <= 0 || d > 61*time.Second {
t.Errorf("ExpiresAt delta = %v, want (0,61s]", d)
}
}
func TestValidateToken_Expired(t *testing.T) {
svc, err := NewJWTService()
if err != nil {
t.Fatalf("NewJWTService: %v", err)
}
// sessionMaxAge = -1s → token is born expired.
tok, err := svc.GenerateToken(newTestUserInfo(), -1)
if err != nil {
t.Fatalf("GenerateToken: %v", err)
}
_, err = svc.ValidateToken(tok)
if err == nil {
t.Fatal("expected expired-token error, got nil")
}
if !strings.Contains(err.Error(), "failed to parse token") {
t.Errorf("unexpected error: %v", err)
}
// jwt/v5 surfaces ErrTokenExpired wrapped in the parse error.
if !errors.Is(err, jwt.ErrTokenExpired) {
t.Errorf("expected wrapped jwt.ErrTokenExpired, got %v", err)
}
}
func TestValidateToken_SignedByDifferentKey(t *testing.T) {
signer, err := NewJWTService()
if err != nil {
t.Fatalf("signer: %v", err)
}
verifier, err := NewJWTService()
if err != nil {
t.Fatalf("verifier: %v", err)
}
tok, err := signer.GenerateToken(newTestUserInfo(), 60)
if err != nil {
t.Fatalf("GenerateToken: %v", err)
}
if _, err := verifier.ValidateToken(tok); err == nil {
t.Fatal("expected signature-mismatch error, got nil")
}
}
func TestValidateToken_Malformed(t *testing.T) {
svc, err := NewJWTService()
if err != nil {
t.Fatalf("NewJWTService: %v", err)
}
cases := []string{
"",
"not.a.jwt",
"only-one-segment",
"two.segments",
"aaaa.bbbb.cccc", // valid shape, invalid base64/JSON
}
for _, c := range cases {
t.Run(c, func(t *testing.T) {
if _, err := svc.ValidateToken(c); err == nil {
t.Errorf("expected error for %q, got nil", c)
}
})
}
}
func TestValidateToken_WrongSigningMethod(t *testing.T) {
svc, err := NewJWTService()
if err != nil {
t.Fatalf("NewJWTService: %v", err)
}
// Forge an HS256 token with the same claim shape; ValidateToken's
// keyfunc must reject the alg before signature verification.
claims := SessionClaims{
Username: "mallory",
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
},
}
tok := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
signed, err := tok.SignedString([]byte("a-shared-secret"))
if err != nil {
t.Fatalf("sign HS256: %v", err)
}
_, err = svc.ValidateToken(signed)
if err == nil {
t.Fatal("expected error for non-EdDSA token, got nil")
}
if !strings.Contains(err.Error(), "unexpected signing method") {
t.Errorf("expected signing-method error, got %v", err)
}
}
func TestGenerateToken_NilPrivateKeyReturnsError(t *testing.T) {
// Construct a service with a nil key directly. This guards the explicit
// nil-check at the top of GenerateToken.
svc := &JWTService{}
_, err := svc.GenerateToken(newTestUserInfo(), 60)
if err == nil {
t.Fatal("expected error for nil private key, got nil")
}
if !strings.Contains(err.Error(), "private key not initialized") {
t.Errorf("unexpected error: %v", err)
}
}
func TestValidateToken_NilPublicKeyReturnsError(t *testing.T) {
svc := &JWTService{}
_, err := svc.ValidateToken("anything")
if err == nil {
t.Fatal("expected error for nil public key, got nil")
}
if !strings.Contains(err.Error(), "public key not initialized") {
t.Errorf("unexpected error: %v", err)
}
}
func TestGenerateStateToken_ProducesUniqueValues(t *testing.T) {
svc, err := NewJWTService()
if err != nil {
t.Fatalf("NewJWTService: %v", err)
}
a, err := svc.GenerateStateToken()
if err != nil {
t.Fatalf("GenerateStateToken: %v", err)
}
b, err := svc.GenerateStateToken()
if err != nil {
t.Fatalf("GenerateStateToken: %v", err)
}
if a == "" || b == "" {
t.Fatal("state token is empty")
}
if a == b {
t.Errorf("state tokens collided: %q", a)
}
}
func TestValidateAndConsumeState_HappyPath(t *testing.T) {
svc, err := NewJWTService()
if err != nil {
t.Fatalf("NewJWTService: %v", err)
}
tok, err := svc.GenerateStateToken()
if err != nil {
t.Fatalf("GenerateStateToken: %v", err)
}
if !svc.ValidateAndConsumeState(tok) {
t.Error("first consume should succeed")
}
}
func TestValidateAndConsumeState_IsSingleUse(t *testing.T) {
svc, err := NewJWTService()
if err != nil {
t.Fatalf("NewJWTService: %v", err)
}
tok, err := svc.GenerateStateToken()
if err != nil {
t.Fatalf("GenerateStateToken: %v", err)
}
_ = svc.ValidateAndConsumeState(tok)
if svc.ValidateAndConsumeState(tok) {
t.Error("second consume should fail")
}
}
func TestValidateAndConsumeState_UnknownTokenRejected(t *testing.T) {
svc, err := NewJWTService()
if err != nil {
t.Fatalf("NewJWTService: %v", err)
}
if svc.ValidateAndConsumeState("never-issued") {
t.Error("unknown token must not validate")
}
}
func TestValidateAndConsumeState_ExpiredTokenRejected(t *testing.T) {
svc, err := NewJWTService()
if err != nil {
t.Fatalf("NewJWTService: %v", err)
}
// Inject an expired entry directly to avoid a real 10-minute wait.
svc.stateStore.states["expired"] = StateData{
Created: time.Now().Add(-20 * time.Minute),
ExpiresAt: time.Now().Add(-10 * time.Minute),
}
if svc.ValidateAndConsumeState("expired") {
t.Error("expired token must not validate")
}
// And it should be deleted as a side effect of the rejection.
if _, exists := svc.stateStore.states["expired"]; exists {
t.Error("expired token should be removed from the store")
}
}
func TestGetPublicKeyPEM_ParsesBackToOriginalKey(t *testing.T) {
svc, err := NewJWTService()
if err != nil {
t.Fatalf("NewJWTService: %v", err)
}
pemStr, err := svc.GetPublicKeyPEM()
if err != nil {
t.Fatalf("GetPublicKeyPEM: %v", err)
}
block, _ := pem.Decode([]byte(pemStr))
if block == nil {
t.Fatalf("returned PEM did not decode: %q", pemStr)
}
if block.Type != "PUBLIC KEY" {
t.Errorf("PEM type = %q, want PUBLIC KEY", block.Type)
}
// The implementation writes the raw 32-byte public key as the block body.
if len(block.Bytes) != ed25519.PublicKeySize {
t.Errorf("body length = %d, want %d", len(block.Bytes), ed25519.PublicKeySize)
}
if !ed25519.PublicKey(block.Bytes).Equal(svc.publicKey) {
t.Error("decoded public key does not match service key")
}
}
func TestGetPublicKeyBase64_RoundTripsToOriginalKey(t *testing.T) {
svc, err := NewJWTService()
if err != nil {
t.Fatalf("NewJWTService: %v", err)
}
b64, err := svc.GetPublicKeyBase64()
if err != nil {
t.Fatalf("GetPublicKeyBase64: %v", err)
}
if b64 == "" {
t.Fatal("empty base64 output")
}
// base64.RawURLEncoding (no padding) is what the production code uses.
// Decode and compare.
// Use the std encoding through helper to keep the import list small.
got, err := decodeRawURL(b64)
if err != nil {
t.Fatalf("base64 decode: %v", err)
}
if !ed25519.PublicKey(got).Equal(svc.publicKey) {
t.Error("base64-decoded key does not match service key")
}
}
func TestGetPublicKeyPEM_NilKeyReturnsError(t *testing.T) {
svc := &JWTService{}
if _, err := svc.GetPublicKeyPEM(); err == nil {
t.Error("expected error for nil public key")
}
}
func TestGetPublicKeyBase64_NilKeyReturnsError(t *testing.T) {
svc := &JWTService{}
if _, err := svc.GetPublicKeyBase64(); err == nil {
t.Error("expected error for nil public key")
}
}
// decodeRawURL is a tiny shim around encoding/base64's RawURLEncoding decoder
// so the test body stays focused on assertions, not encoding plumbing.
func decodeRawURL(s string) ([]byte, error) {
return base64RawURLDecode(s)
}
func base64RawURLDecode(s string) ([]byte, error) {
return base64.RawURLEncoding.DecodeString(s)
}
+29
View File
@@ -0,0 +1,29 @@
package auth
import (
"crypto/subtle"
"testing"
)
func TestValidateAdminToken(t *testing.T) {
tests := []struct {
name string
configured string
provided string
want bool
}{
{"correct token", "my-secret-token", "my-secret-token", true},
{"wrong token", "my-secret-token", "wrong-token", false},
{"empty provided", "my-secret-token", "", false},
{"empty configured", "", "any-token", false},
{"both empty", "", "", true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := subtle.ConstantTimeCompare([]byte(tc.configured), []byte(tc.provided)) == 1
if got != tc.want {
t.Errorf("ValidateAdminToken(%q, %q) = %v, want %v", tc.configured, tc.provided, got, tc.want)
}
})
}
}
+142 -8
View File
@@ -2,10 +2,14 @@ package config
import (
"fmt"
"net"
"os"
"strconv"
"strings"
"github.com/spf13/viper"
"Noooste/garage-ui/pkg/logger"
)
// Config represents the application configuration
@@ -46,6 +50,7 @@ type GarageConfig struct {
type AuthConfig struct {
Admin AdminAuthConfig `mapstructure:"admin"`
OIDC OIDCConfig `mapstructure:"oidc"`
Token TokenAuthConfig `mapstructure:"token"`
JWTPrivKey string `mapstructure:"jwt_private_key"` // Ed25519 private key in PEM format for JWT signing (64 bytes)
}
@@ -56,6 +61,12 @@ type AdminAuthConfig struct {
Password string `mapstructure:"password"`
}
// TokenAuthConfig contains admin token authentication settings.
// When enabled, users can log in using the Garage admin token.
type TokenAuthConfig struct {
Enabled bool `mapstructure:"enabled"`
}
// OIDCConfig contains OIDC authentication settings
type OIDCConfig struct {
Enabled bool `mapstructure:"enabled"`
@@ -64,9 +75,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"`
@@ -74,6 +82,7 @@ type OIDCConfig struct {
NameAttribute string `mapstructure:"name_attribute"`
RoleAttributePath string `mapstructure:"role_attribute_path"`
AdminRole string `mapstructure:"admin_role"`
AdminRoles []string `mapstructure:"admin_roles"`
TLSSkipVerify bool `mapstructure:"tls_skip_verify"`
SessionMaxAge int `mapstructure:"session_max_age"`
CookieName string `mapstructure:"cookie_name"`
@@ -82,6 +91,29 @@ type OIDCConfig struct {
CookieSameSite string `mapstructure:"cookie_same_site"`
}
// EffectiveAdminRoles returns the deduplicated list of admin roles drawn from
// both admin_role (legacy single-value) and admin_roles (list). A user is
// considered an admin if any of their roles matches any entry in this list.
func (o OIDCConfig) EffectiveAdminRoles() []string {
seen := make(map[string]struct{}, len(o.AdminRoles)+1)
var roles []string
add := func(r string) {
if r == "" {
return
}
if _, ok := seen[r]; ok {
return
}
seen[r] = struct{}{}
roles = append(roles, r)
}
add(o.AdminRole)
for _, r := range o.AdminRoles {
add(r)
}
return roles
}
// CORSConfig contains CORS settings for frontend communication
type CORSConfig struct {
Enabled bool `mapstructure:"enabled"`
@@ -98,8 +130,28 @@ type LoggingConfig struct {
Format string `mapstructure:"format"`
}
// LoadOption configures optional behaviour of Load.
type LoadOption func(*loadOptions)
type loadOptions struct {
garageTomlPath string
}
// WithGarageToml tells Load to parse a garage.toml file and use its values as
// lowest-priority defaults (below YAML, below env vars).
func WithGarageToml(path string) LoadOption {
return func(o *loadOptions) {
o.garageTomlPath = path
}
}
// Load reads the configuration from the specified file
func Load(configPath string) (*Config, error) {
func Load(configPath string, opts ...LoadOption) (*Config, error) {
var lo loadOptions
for _, fn := range opts {
fn(&lo)
}
// Set default config file name if not specified
if configPath == "" {
configPath = "config.yaml"
@@ -109,6 +161,28 @@ func Load(configPath string) (*Config, error) {
viper.SetConfigFile(configPath)
viper.SetConfigType("yaml")
// Built-in defaults (lowest priority)
viper.SetDefault("server.host", "::")
viper.SetDefault("server.port", 8080)
viper.SetDefault("server.environment", "production")
viper.SetDefault("garage.force_path_style", true)
viper.SetDefault("logging.level", "info")
viper.SetDefault("logging.format", "text")
// If garage.toml path is provided, parse it and set values as viper
// defaults. Defaults sit below config-file and env-var values in viper's
// priority order, so YAML and env vars will still win.
if lo.garageTomlPath != "" {
tomlResult, err := ParseGarageToml(lo.garageTomlPath)
if err != nil {
return nil, fmt.Errorf("error parsing garage.toml: %w", err)
}
viper.SetDefault("garage.endpoint", tomlResult.Endpoint)
viper.SetDefault("garage.admin_endpoint", tomlResult.AdminEndpoint)
viper.SetDefault("garage.admin_token", tomlResult.AdminToken)
viper.SetDefault("garage.region", tomlResult.Region)
}
// Allow environment variables to override config values
// Environment variables take precedence over config file
viper.AutomaticEnv()
@@ -118,6 +192,13 @@ func Load(configPath string) (*Config, error) {
// Env vars override config file values
bindEnvVars()
// Resolve `_FILE`-suffixed env vars for sensitive values (e.g.
// {ENV}_FILE=/run/secrets/foo). Must run after bindEnvVars so the
// warning about both forms being set fires correctly.
if err := applyFileBackedEnvVars(); err != nil {
return nil, fmt.Errorf("error resolving _FILE env vars: %w", err)
}
// Read the config file (optional - will use defaults and env vars if not found)
if _, err := os.Stat(configPath); err == nil {
if err := viper.ReadInConfig(); err != nil {
@@ -168,6 +249,9 @@ func bindEnvVars() {
viper.BindEnv("auth.admin.password", "GARAGE_UI_AUTH_ADMIN_PASSWORD")
viper.BindEnv("auth.jwt_private_key", "GARAGE_UI_AUTH_JWT_PRIVATE_KEY")
// Token auth config
viper.BindEnv("auth.token.enabled", "GARAGE_UI_AUTH_TOKEN_ENABLED")
// OIDC config
viper.BindEnv("auth.oidc.enabled", "GARAGE_UI_AUTH_OIDC_ENABLED")
viper.BindEnv("auth.oidc.provider_name", "GARAGE_UI_AUTH_OIDC_PROVIDER_NAME")
@@ -175,9 +259,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")
@@ -185,6 +266,7 @@ func bindEnvVars() {
viper.BindEnv("auth.oidc.name_attribute", "GARAGE_UI_AUTH_OIDC_NAME_ATTRIBUTE")
viper.BindEnv("auth.oidc.role_attribute_path", "GARAGE_UI_AUTH_OIDC_ROLE_ATTRIBUTE_PATH")
viper.BindEnv("auth.oidc.admin_role", "GARAGE_UI_AUTH_OIDC_ADMIN_ROLE")
viper.BindEnv("auth.oidc.admin_roles", "GARAGE_UI_AUTH_OIDC_ADMIN_ROLES")
viper.BindEnv("auth.oidc.tls_skip_verify", "GARAGE_UI_AUTH_OIDC_TLS_SKIP_VERIFY")
viper.BindEnv("auth.oidc.session_max_age", "GARAGE_UI_AUTH_OIDC_SESSION_MAX_AGE")
viper.BindEnv("auth.oidc.cookie_name", "GARAGE_UI_AUTH_OIDC_COOKIE_NAME")
@@ -205,6 +287,50 @@ func bindEnvVars() {
viper.BindEnv("logging.format", "GARAGE_UI_LOGGING_FORMAT")
}
// fileBackedEnvVars maps env var names to viper config keys for variables that
// support the `_FILE` suffix convention. Operators may set `{ENV}_FILE` to a
// file path; the file's contents (with trailing whitespace trimmed) become the
// effective value. This pattern is used by Docker Official Images (postgres,
// mysql) to inject secrets via mounted files instead of plain env vars,
// avoiding exposure through `docker inspect`, process listings, or crash logs.
//
// Scope is intentionally limited to values that an operator would reasonably
// store in a Kubernetes Secret or Docker secret. Non-sensitive config (host,
// port, endpoints, etc.) is excluded.
var fileBackedEnvVars = map[string]string{
"GARAGE_UI_GARAGE_ADMIN_TOKEN": "garage.admin_token",
"GARAGE_UI_AUTH_ADMIN_USERNAME": "auth.admin.username",
"GARAGE_UI_AUTH_ADMIN_PASSWORD": "auth.admin.password",
"GARAGE_UI_AUTH_JWT_PRIVATE_KEY": "auth.jwt_private_key",
"GARAGE_UI_AUTH_OIDC_CLIENT_ID": "auth.oidc.client_id",
"GARAGE_UI_AUTH_OIDC_CLIENT_SECRET": "auth.oidc.client_secret",
}
// applyFileBackedEnvVars resolves `_FILE`-suffixed env vars listed in
// fileBackedEnvVars. For each entry where `{ENV}_FILE` is set, the file is
// read and its contents (trimmed of trailing CR/LF) become the value via
// viper.Set, which is the highest-priority source — so a `_FILE` value wins
// over both `{ENV}` and YAML. A missing or unreadable file is a hard error.
func applyFileBackedEnvVars() error {
for envVar, configKey := range fileBackedEnvVars {
path := os.Getenv(envVar + "_FILE")
if path == "" {
continue
}
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("reading %s_FILE (%s): %w", envVar, path, err)
}
if os.Getenv(envVar) != "" {
logger.Warn().
Str("env", envVar).
Msg("both VAR and VAR_FILE are set; VAR_FILE takes precedence")
}
viper.Set(configKey, strings.TrimRight(string(data), "\r\n"))
}
return nil
}
// Validate checks if the configuration is valid
func (c *Config) Validate() error {
// Validate server config
@@ -244,6 +370,14 @@ func (c *Config) Validate() error {
if len(c.Auth.OIDC.Scopes) == 0 {
return fmt.Errorf("oidc scopes are required when oidc is enabled")
}
// Every authenticated route on this service grants full admin
// access — there is no separate authorization layer. Empty
// admin role configuration would therefore promote every user
// in the IdP realm to cluster admin. Require operators to opt
// in explicitly via admin_role or admin_roles.
if len(c.Auth.OIDC.EffectiveAdminRoles()) == 0 {
return fmt.Errorf("oidc admin_role or admin_roles is required when oidc is enabled: leaving them empty would grant cluster-admin access to any authenticated IdP user")
}
}
return nil
@@ -251,7 +385,7 @@ func (c *Config) Validate() error {
// GetAddress returns the full server address (host:port)
func (c *Config) GetAddress() string {
return fmt.Sprintf("%s:%d", c.Server.Host, c.Server.Port)
return net.JoinHostPort(c.Server.Host, strconv.Itoa(c.Server.Port))
}
// IsDevelopment returns true if running in development mode
+661
View File
@@ -0,0 +1,661 @@
package config
import (
"os"
"path/filepath"
"reflect"
"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.Server.Host != "::" {
t.Errorf("Server.Host = %q, want :: (default)", cfg.Server.Host)
}
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 or admin_roles is required",
},
{
name: "oidc enabled with admin_roles only is valid",
mutate: func(c *Config) {
applyValidOIDC(c)
c.Auth.OIDC.AdminRole = ""
c.Auth.OIDC.AdminRoles = []string{"group1", "group2"}
},
wantErrContains: "",
},
{
name: "oidc enabled with both admin_role and admin_roles is valid",
mutate: func(c *Config) {
applyValidOIDC(c)
c.Auth.OIDC.AdminRoles = []string{"group2", "group3"}
},
wantErrContains: "",
},
{
name: "oidc enabled with empty admin_role and empty admin_roles rejected",
mutate: func(c *Config) {
applyValidOIDC(c)
c.Auth.OIDC.AdminRole = ""
c.Auth.OIDC.AdminRoles = []string{}
},
wantErrContains: "oidc admin_role or admin_roles 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"},
{"::", 80, "[::]:80"},
{"::1", 443, "[::1]:443"},
{"", 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 writeToml(t *testing.T, content string) string {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "garage.toml")
if err := os.WriteFile(path, []byte(content), 0600); err != nil {
t.Fatalf("write toml: %v", err)
}
return path
}
const testGarageToml = `
[admin]
api_bind_addr = "[::]:3903"
admin_token = "toml-token"
[s3_api]
api_bind_addr = "[::]:3900"
s3_region = "garage"
`
func TestLoad_GarageTomlOnly(t *testing.T) {
resetViper(t)
tomlPath := writeToml(t, testGarageToml)
missingYaml := filepath.Join(t.TempDir(), "nope.yaml")
cfg, err := Load(missingYaml, WithGarageToml(tomlPath))
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Garage.AdminToken != "toml-token" {
t.Errorf("AdminToken = %q, want toml-token", cfg.Garage.AdminToken)
}
if cfg.Garage.Endpoint != "http://127.0.0.1:3900" {
t.Errorf("Endpoint = %q, want http://127.0.0.1:3900", cfg.Garage.Endpoint)
}
if cfg.Garage.AdminEndpoint != "http://127.0.0.1:3903" {
t.Errorf("AdminEndpoint = %q, want http://127.0.0.1:3903", cfg.Garage.AdminEndpoint)
}
if cfg.Garage.Region != "garage" {
t.Errorf("Region = %q, want garage", cfg.Garage.Region)
}
}
func TestLoad_YAMLOverridesToml(t *testing.T) {
resetViper(t)
tomlPath := writeToml(t, testGarageToml)
yaml := `
server:
host: "0.0.0.0"
port: 8080
garage:
endpoint: http://custom:3900
admin_endpoint: http://custom:3903
admin_token: yaml-wins
`
yamlPath := writeConfigFile(t, yaml)
cfg, err := Load(yamlPath, WithGarageToml(tomlPath))
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Garage.AdminToken != "yaml-wins" {
t.Errorf("AdminToken = %q, want yaml-wins (yaml overrides toml)", cfg.Garage.AdminToken)
}
if cfg.Garage.Endpoint != "http://custom:3900" {
t.Errorf("Endpoint = %q, want http://custom:3900", cfg.Garage.Endpoint)
}
}
func TestLoad_EnvOverridesToml(t *testing.T) {
resetViper(t)
tomlPath := writeToml(t, testGarageToml)
missingYaml := filepath.Join(t.TempDir(), "nope.yaml")
t.Setenv("GARAGE_UI_GARAGE_ADMIN_TOKEN", "env-wins")
cfg, err := Load(missingYaml, WithGarageToml(tomlPath))
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Garage.AdminToken != "env-wins" {
t.Errorf("AdminToken = %q, want env-wins (env overrides toml)", cfg.Garage.AdminToken)
}
}
func TestEffectiveAdminRoles(t *testing.T) {
tests := []struct {
name string
adminRole string
adminRoles []string
want []string
}{
{"both empty", "", nil, nil},
{"single only", "admin", nil, []string{"admin"}},
{"list only", "", []string{"a", "b"}, []string{"a", "b"}},
{"merge single + list", "admin", []string{"viewer", "ops"}, []string{"admin", "viewer", "ops"}},
{"dedupes overlap", "admin", []string{"admin", "ops"}, []string{"admin", "ops"}},
{"dedupes within list", "", []string{"a", "a", "b"}, []string{"a", "b"}},
{"skips empty strings in list", "admin", []string{"", "ops", ""}, []string{"admin", "ops"}},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
o := OIDCConfig{AdminRole: tc.adminRole, AdminRoles: tc.adminRoles}
got := o.EffectiveAdminRoles()
if !reflect.DeepEqual(got, tc.want) {
t.Errorf("EffectiveAdminRoles() = %v, want %v", got, tc.want)
}
})
}
}
// writeSecretFile is a test helper that writes content to a temp file and
// returns the absolute path. Uses t.TempDir so cleanup is automatic.
func writeSecretFile(t *testing.T, content string) string {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "secret")
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatalf("write temp secret: %v", err)
}
return path
}
func TestApplyFileBackedEnvVars(t *testing.T) {
tests := []struct {
name string
envVar string
configKey string
fileBody string
alsoSetEnv string
useMissingFile bool
wantValue string
wantErr bool
}{
{
name: "reads value from file",
envVar: "GARAGE_UI_AUTH_ADMIN_PASSWORD",
configKey: "auth.admin.password",
fileBody: "s3cret",
wantValue: "s3cret",
},
{
name: "trims trailing newline",
envVar: "GARAGE_UI_GARAGE_ADMIN_TOKEN",
configKey: "garage.admin_token",
fileBody: "tok\n",
wantValue: "tok",
},
{
name: "trims trailing CRLF",
envVar: "GARAGE_UI_AUTH_OIDC_CLIENT_SECRET",
configKey: "auth.oidc.client_secret",
fileBody: "secret\r\n",
wantValue: "secret",
},
{
name: "_FILE wins over plain env var",
envVar: "GARAGE_UI_AUTH_ADMIN_USERNAME",
configKey: "auth.admin.username",
fileBody: "from-file",
alsoSetEnv: "from-env",
wantValue: "from-file",
},
{
name: "missing file returns error",
envVar: "GARAGE_UI_AUTH_JWT_PRIVATE_KEY",
configKey: "auth.jwt_private_key",
useMissingFile: true,
wantErr: true,
},
{
name: "multiline PEM preserved internally, only trailing whitespace trimmed",
envVar: "GARAGE_UI_AUTH_JWT_PRIVATE_KEY",
configKey: "auth.jwt_private_key",
fileBody: "-----BEGIN PRIVATE KEY-----\nABC\n-----END PRIVATE KEY-----\n",
wantValue: "-----BEGIN PRIVATE KEY-----\nABC\n-----END PRIVATE KEY-----",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
resetViper(t)
if tc.useMissingFile {
t.Setenv(tc.envVar+"_FILE", filepath.Join(t.TempDir(), "does-not-exist"))
} else {
path := writeSecretFile(t, tc.fileBody)
t.Setenv(tc.envVar+"_FILE", path)
}
if tc.alsoSetEnv != "" {
t.Setenv(tc.envVar, tc.alsoSetEnv)
}
err := applyFileBackedEnvVars()
if tc.wantErr {
if err == nil {
t.Fatalf("expected error, got nil")
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got := viper.GetString(tc.configKey); got != tc.wantValue {
t.Fatalf("viper.GetString(%q) = %q, want %q", tc.configKey, got, tc.wantValue)
}
})
}
}
func TestApplyFileBackedEnvVars_NoFileEnvSet_NoOp(t *testing.T) {
resetViper(t)
if err := applyFileBackedEnvVars(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got := viper.GetString("auth.admin.password"); got != "" {
t.Fatalf("expected empty password, got %q", got)
}
}
func TestLoad_FileBackedEnvVarMissingFileReturnsError(t *testing.T) {
resetViper(t)
yamlPath := writeConfigFile(t, minimalValidYAML)
t.Setenv("GARAGE_UI_GARAGE_ADMIN_TOKEN_FILE", filepath.Join(t.TempDir(), "does-not-exist"))
_, err := Load(yamlPath)
if err == nil {
t.Fatal("expected error from Load when _FILE points at a missing file, got nil")
}
if !strings.Contains(err.Error(), "error resolving _FILE env vars") {
t.Errorf("error %q does not contain wrapped prefix from Load", err)
}
}
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)
}
})
}
}
+105
View File
@@ -0,0 +1,105 @@
package config
import (
"fmt"
"net"
"os"
"strings"
toml "github.com/pelletier/go-toml/v2"
)
// GarageTomlResult holds the values extracted from a garage.toml file.
type GarageTomlResult struct {
Endpoint string
AdminEndpoint string
AdminToken string
Region string
}
// garageTomlFile represents the subset of garage.toml we care about.
type garageTomlFile struct {
S3API struct {
APIBindAddr string `toml:"api_bind_addr"`
S3Region string `toml:"s3_region"`
} `toml:"s3_api"`
Admin struct {
APIBindAddr string `toml:"api_bind_addr"`
AdminToken string `toml:"admin_token"`
} `toml:"admin"`
}
// ParseGarageToml reads a garage.toml file and extracts the values needed
// for garage-ui configuration.
func ParseGarageToml(path string) (*GarageTomlResult, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("reading garage.toml: %w", err)
}
var f garageTomlFile
if err := toml.Unmarshal(data, &f); err != nil {
return nil, fmt.Errorf("parsing garage.toml: %w", err)
}
if f.Admin.AdminToken == "" {
return nil, fmt.Errorf("garage.toml: [admin].admin_token is required")
}
if f.Admin.APIBindAddr == "" {
return nil, fmt.Errorf("garage.toml: [admin].api_bind_addr is required")
}
if f.S3API.APIBindAddr == "" {
return nil, fmt.Errorf("garage.toml: [s3_api].api_bind_addr is required")
}
adminEndpoint, err := convertBindAddr(f.Admin.APIBindAddr)
if err != nil {
return nil, fmt.Errorf("garage.toml: converting admin api_bind_addr: %w", err)
}
s3Endpoint, err := convertBindAddr(f.S3API.APIBindAddr)
if err != nil {
return nil, fmt.Errorf("garage.toml: converting s3_api api_bind_addr: %w", err)
}
region := f.S3API.S3Region
if region == "" {
region = "garage"
}
return &GarageTomlResult{
Endpoint: s3Endpoint,
AdminEndpoint: adminEndpoint,
AdminToken: f.Admin.AdminToken,
Region: region,
}, nil
}
// convertBindAddr converts a bind address like "[::]:3900" into an HTTP
// endpoint like "http://127.0.0.1:3900". Wildcard hosts (::, 0.0.0.0, empty)
// are replaced with 127.0.0.1.
func convertBindAddr(bindAddr string) (string, error) {
if bindAddr == "" {
return "", fmt.Errorf("bind address is empty")
}
host, port, err := net.SplitHostPort(bindAddr)
if err != nil {
return "", fmt.Errorf("invalid bind address %q: %w", bindAddr, err)
}
if port == "" {
return "", fmt.Errorf("bind address %q has no port", bindAddr)
}
switch host {
case "", "::", "0.0.0.0":
host = "127.0.0.1"
}
if strings.Contains(host, ":") {
host = "[" + host + "]"
}
return fmt.Sprintf("http://%s:%s", host, port), nil
}
+165
View File
@@ -0,0 +1,165 @@
package config
import (
"os"
"path/filepath"
"testing"
)
func writeTomlFile(t *testing.T, content string) string {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "garage.toml")
if err := os.WriteFile(path, []byte(content), 0600); err != nil {
t.Fatalf("write toml: %v", err)
}
return path
}
const validGarageToml = `
metadata_dir = "/var/lib/garage/meta"
data_dir = "/var/lib/garage/data"
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 = "my-secret-admin-token"
`
func TestParseGarageToml_ValidFile(t *testing.T) {
path := writeTomlFile(t, validGarageToml)
result, err := ParseGarageToml(path)
if err != nil {
t.Fatalf("ParseGarageToml: %v", err)
}
if result.AdminToken != "my-secret-admin-token" {
t.Errorf("AdminToken = %q, want my-secret-admin-token", result.AdminToken)
}
if result.AdminEndpoint != "http://127.0.0.1:3903" {
t.Errorf("AdminEndpoint = %q, want http://127.0.0.1:3903", result.AdminEndpoint)
}
if result.Endpoint != "http://127.0.0.1:3900" {
t.Errorf("Endpoint = %q, want http://127.0.0.1:3900", result.Endpoint)
}
if result.Region != "garage" {
t.Errorf("Region = %q, want garage", result.Region)
}
}
func TestParseGarageToml_MissingAdminToken(t *testing.T) {
toml := `
[admin]
api_bind_addr = "[::]:3903"
[s3_api]
api_bind_addr = "[::]:3900"
`
path := writeTomlFile(t, toml)
_, err := ParseGarageToml(path)
if err == nil {
t.Fatal("expected error for missing admin_token, got nil")
}
}
func TestParseGarageToml_MissingAdminBindAddr(t *testing.T) {
toml := `
[admin]
admin_token = "tok"
[s3_api]
api_bind_addr = "[::]:3900"
`
path := writeTomlFile(t, toml)
_, err := ParseGarageToml(path)
if err == nil {
t.Fatal("expected error for missing admin api_bind_addr, got nil")
}
}
func TestParseGarageToml_MissingS3BindAddr(t *testing.T) {
toml := `
[admin]
admin_token = "tok"
api_bind_addr = "[::]:3903"
[s3_api]
s3_region = "garage"
`
path := writeTomlFile(t, toml)
_, err := ParseGarageToml(path)
if err == nil {
t.Fatal("expected error for missing s3_api api_bind_addr, got nil")
}
}
func TestParseGarageToml_DefaultRegion(t *testing.T) {
toml := `
[admin]
admin_token = "tok"
api_bind_addr = "[::]:3903"
[s3_api]
api_bind_addr = "[::]:3900"
`
path := writeTomlFile(t, toml)
result, err := ParseGarageToml(path)
if err != nil {
t.Fatalf("ParseGarageToml: %v", err)
}
if result.Region != "garage" {
t.Errorf("Region = %q, want garage (default)", result.Region)
}
}
func TestParseGarageToml_FileNotFound(t *testing.T) {
_, err := ParseGarageToml("/nonexistent/garage.toml")
if err == nil {
t.Fatal("expected error for missing file, got nil")
}
}
func TestConvertBindAddr(t *testing.T) {
tests := []struct {
name string
bindAddr string
want string
wantErr bool
}{
{"ipv6 wildcard", "[::]:3900", "http://127.0.0.1:3900", false},
{"ipv4 wildcard", "0.0.0.0:3900", "http://127.0.0.1:3900", false},
{"localhost", "127.0.0.1:3900", "http://127.0.0.1:3900", false},
{"specific ipv4", "192.168.1.1:3900", "http://192.168.1.1:3900", false},
{"ipv6 localhost", "[::1]:3900", "http://[::1]:3900", false},
{"specific ipv6", "[2001:db8::1]:3900", "http://[2001:db8::1]:3900", false},
{"empty host", ":3900", "http://127.0.0.1:3900", false},
{"empty string", "", "", true},
{"no port", "127.0.0.1", "", true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got, err := convertBindAddr(tc.bindAddr)
if tc.wantErr {
if err == nil {
t.Fatalf("expected error, got %q", got)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != tc.want {
t.Errorf("convertBindAddr(%q) = %q, want %q", tc.bindAddr, got, tc.want)
}
})
}
}
+75 -26
View File
@@ -1,6 +1,8 @@
package handlers
import (
"crypto/subtle"
"Noooste/garage-ui/internal/auth"
"Noooste/garage-ui/internal/config"
"Noooste/garage-ui/internal/models"
@@ -11,11 +13,11 @@ import (
// AuthHandler handles authentication-related requests
type AuthHandler struct {
cfg *config.Config
authService *auth.AuthService
authService *auth.Service
}
// NewAuthHandler creates a new auth handler
func NewAuthHandler(cfg *config.Config, authService *auth.AuthService) *AuthHandler {
func NewAuthHandler(cfg *config.Config, authService *auth.Service) *AuthHandler {
return &AuthHandler{
cfg: cfg,
authService: authService,
@@ -23,12 +25,13 @@ func NewAuthHandler(cfg *config.Config, authService *auth.AuthService) *AuthHand
}
// GetAuthConfig returns the current authentication configuration
// @Summary Get authentication configuration
// @Description Returns the current auth configuration (admin and/or OIDC)
// @Tags auth
// @Produce json
// @Success 200 {object} object{admin=object,oidc=object} "Auth config"
// @Router /auth/config [get]
//
// @Summary Get authentication configuration
// @Description Returns the current auth configuration (admin and/or OIDC)
// @Tags auth
// @Produce json
// @Success 200 {object} object{admin=object,oidc=object} "Auth config"
// @Router /auth/config [get]
func (h *AuthHandler) GetAuthConfig(c fiber.Ctx) error {
response := fiber.Map{
"admin": fiber.Map{
@@ -37,6 +40,9 @@ func (h *AuthHandler) GetAuthConfig(c fiber.Ctx) error {
"oidc": fiber.Map{
"enabled": h.cfg.Auth.OIDC.Enabled,
},
"token": fiber.Map{
"enabled": h.cfg.Auth.Token.Enabled,
},
}
// Add provider name if OIDC is enabled
@@ -58,16 +64,17 @@ type LoginBasicRequest struct {
}
// LoginAdmin handles admin authentication login
// @Summary Admin auth login
// @Description Authenticate with admin username and password, returns JWT token
// @Tags auth
// @Accept json
// @Produce json
// @Param credentials body LoginBasicRequest true "Login credentials"
// @Success 200 {object} object{success=bool,token=string,user=object} "Login successful"
// @Failure 400 {object} models.APIResponse "Invalid request"
// @Failure 401 {object} models.APIResponse "Invalid credentials"
// @Router /auth/login [post]
//
// @Summary Admin auth login
// @Description Authenticate with admin username and password, returns JWT token
// @Tags auth
// @Accept json
// @Produce json
// @Param credentials body LoginBasicRequest true "Login credentials"
// @Success 200 {object} object{success=bool,token=string,user=object} "Login successful"
// @Failure 400 {object} models.APIResponse "Invalid request"
// @Failure 401 {object} models.APIResponse "Invalid credentials"
// @Router /auth/login [post]
func (h *AuthHandler) LoginAdmin(c fiber.Ctx) error {
// Parse request body
var req LoginBasicRequest
@@ -106,15 +113,57 @@ func (h *AuthHandler) LoginAdmin(c fiber.Ctx) error {
})
}
// LoginTokenRequest represents the token auth login request
type LoginTokenRequest struct {
Token string `json:"token" validate:"required"`
}
// LoginToken handles admin token authentication login
func (h *AuthHandler) LoginToken(c fiber.Ctx) error {
var req LoginTokenRequest
if err := c.Bind().JSON(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(
models.ErrorResponse(models.ErrCodeBadRequest, "Invalid request body"),
)
}
// Constant-time comparison to prevent timing attacks
if subtle.ConstantTimeCompare([]byte(h.cfg.Garage.AdminToken), []byte(req.Token)) != 1 {
return c.Status(fiber.StatusUnauthorized).JSON(
models.ErrorResponse(models.ErrCodeUnauthorized, "Invalid admin token"),
)
}
userInfo := &auth.UserInfo{
Username: "admin-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]
//
// @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")
+465
View File
@@ -0,0 +1,465 @@
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 TestLoginToken_Success(t *testing.T) {
cfg := &config.Config{
Garage: config.GarageConfig{
AdminToken: "test-admin-token",
Endpoint: "http://g:3900",
AdminEndpoint: "http://g:3903",
},
Auth: config.AuthConfig{
Token: config.TokenAuthConfig{Enabled: true},
},
}
svc := newAuthTestService(t, cfg.Auth.Admin)
h := NewAuthHandler(cfg, svc)
app := fiber.New()
app.Post("/auth/login-token", h.LoginToken)
body := `{"token":"test-admin-token"}`
req := httptest.NewRequest(http.MethodPost, "/auth/login-token", strings.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-token" {
t.Errorf("username = %q, want admin-token", decoded.User.Username)
}
}
func TestLoginToken_WrongToken(t *testing.T) {
cfg := &config.Config{
Garage: config.GarageConfig{
AdminToken: "test-admin-token",
Endpoint: "http://g:3900",
AdminEndpoint: "http://g:3903",
},
Auth: config.AuthConfig{
Token: config.TokenAuthConfig{Enabled: true},
},
}
svc := newAuthTestService(t, cfg.Auth.Admin)
h := NewAuthHandler(cfg, svc)
app := fiber.New()
app.Post("/auth/login-token", h.LoginToken)
body := `{"token":"wrong-token"}`
req := httptest.NewRequest(http.MethodPost, "/auth/login-token", strings.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 TestLoginToken_MalformedJSONReturns400(t *testing.T) {
cfg := &config.Config{
Garage: config.GarageConfig{
AdminToken: "test-admin-token",
Endpoint: "http://g:3900",
AdminEndpoint: "http://g:3903",
},
Auth: config.AuthConfig{
Token: config.TokenAuthConfig{Enabled: true},
},
}
svc := newAuthTestService(t, cfg.Auth.Admin)
h := NewAuthHandler(cfg, svc)
app := fiber.New()
app.Post("/auth/login-token", h.LoginToken)
req := httptest.NewRequest(http.MethodPost, "/auth/login-token", 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 TestGetAuthConfig_TokenEnabled(t *testing.T) {
cfg := &config.Config{
Auth: config.AuthConfig{
Token: config.TokenAuthConfig{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()
var body struct {
Token struct {
Enabled bool `json:"enabled"`
} `json:"token"`
}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
t.Fatalf("decode: %v", err)
}
if !body.Token.Enabled {
t.Error("token.enabled = false, want true")
}
}
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)
}
}
+207 -22
View File
@@ -7,14 +7,14 @@ import (
"github.com/gofiber/fiber/v3"
)
// BucketHandler handles bucket-related operations
// BucketHandler handles bucket-related HTTP requests.
type BucketHandler struct {
adminService *services.GarageAdminService
s3Service *services.S3Service
adminService services.AdminService
s3Service services.S3Storage
}
// NewBucketHandler creates a new bucket handler
func NewBucketHandler(adminService *services.GarageAdminService, s3Service *services.S3Service) *BucketHandler {
// NewBucketHandler creates a new bucket handler.
func NewBucketHandler(adminService services.AdminService, s3Service services.S3Storage) *BucketHandler {
return &BucketHandler{
adminService: adminService,
s3Service: s3Service,
@@ -67,11 +67,14 @@ func (h *BucketHandler) ListBuckets(c fiber.Ctx) error {
}
bucketInfo := models.BucketInfo{
Name: bucketName,
CreationDate: adminBucket.Created,
Region: "", // Garage doesn't have regions
ObjectCount: &detailedInfo.Objects,
Size: &detailedInfo.Bytes,
Name: bucketName,
CreationDate: adminBucket.Created,
Region: "",
ObjectCount: &detailedInfo.Objects,
Size: &detailedInfo.Bytes,
WebsiteAccess: detailedInfo.WebsiteAccess,
WebsiteConfig: detailedInfo.WebsiteConfig,
Quotas: detailedInfo.Quotas,
}
buckets = append(buckets, bucketInfo)
@@ -285,22 +288,204 @@ 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)
var result *models.GarageBucketInfo
if allow.Read || allow.Write || allow.Owner {
r, err := h.adminService.AllowBucketKey(ctx, models.BucketKeyPermRequest{
BucketID: bucketInfo.ID,
AccessKeyID: req.AccessKeyID,
Permissions: allow,
})
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to grant permissions: "+err.Error()),
)
}
result = r
}
if deny.Read || deny.Write || deny.Owner {
r, err := h.adminService.DenyBucketKey(ctx, models.BucketKeyPermRequest{
BucketID: bucketInfo.ID,
AccessKeyID: req.AccessKeyID,
Permissions: deny,
})
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to revoke permissions: "+err.Error()),
)
}
result = r
}
if result == nil {
// Caller passed all-false on a key with no existing perms — nothing to do.
// Fetch current bucket state to return a consistent response.
r, err := h.adminService.GetBucketInfo(ctx, bucketInfo.ID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to fetch bucket info: "+err.Error()),
)
}
result = r
}
return c.JSON(models.SuccessResponse(result))
}
// UpdateBucketWebsite updates the website access configuration for a bucket
//
// @Summary Update bucket website configuration
// @Description Enables or disables static website hosting for a bucket
// @Tags Buckets
// @Accept json
// @Produce json
// @Param name path string true "Name of the bucket"
// @Param request body models.UpdateBucketWebsiteRequest true "Website configuration"
// @Success 200 {object} models.APIResponse{data=models.GarageBucketInfo} "Website configuration updated"
// @Failure 400 {object} models.APIResponse{error=models.APIError} "Invalid request"
// @Failure 404 {object} models.APIResponse{error=models.APIError} "Bucket not found"
// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to update bucket"
// @Router /api/v1/buckets/{name}/website [put]
func (h *BucketHandler) UpdateBucketWebsite(c fiber.Ctx) error {
ctx := c.Context()
bucketName := c.Params("name")
if bucketName == "" {
return c.Status(fiber.StatusBadRequest).JSON(
models.ErrorResponse(models.ErrCodeBadRequest, "Bucket name is required"),
)
}
var req models.UpdateBucketWebsiteRequest
if err := c.Bind().JSON(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(
models.ErrorResponse(models.ErrCodeBadRequest, "Invalid request body: "+err.Error()),
)
}
if req.Enabled && req.IndexDocument == "" {
return c.Status(fiber.StatusBadRequest).JSON(
models.ErrorResponse(models.ErrCodeBadRequest, "indexDocument is required when enabling website access"),
)
}
bucketInfo, err := h.adminService.GetBucketInfoByAlias(ctx, bucketName)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to grant permissions: "+err.Error()),
models.ErrorResponse(models.ErrCodeInternalError, "Failed to get bucket info: "+err.Error()),
)
}
if bucketInfo == nil {
return c.Status(fiber.StatusNotFound).JSON(
models.ErrorResponse(models.ErrCodeBucketNotFound, "Bucket does not exist"),
)
}
websiteAccess := &models.UpdateBucketWebsiteAccess{
Enabled: req.Enabled,
}
if req.Enabled {
websiteAccess.IndexDocument = &req.IndexDocument
if req.ErrorDocument != "" {
websiteAccess.ErrorDocument = &req.ErrorDocument
}
}
updateReq := models.UpdateBucketRequest{
WebsiteAccess: websiteAccess,
}
result, err := h.adminService.UpdateBucket(ctx, bucketInfo.ID, updateReq)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to update bucket website: "+err.Error()),
)
}
return c.JSON(models.SuccessResponse(result))
}
// UpdateBucketQuotas updates the quota settings for a bucket
//
// @Summary Update bucket quotas
// @Description Sets or clears the max size (bytes) and max object count quotas for a bucket. A null field clears that quota (unlimited).
// @Tags Buckets
// @Accept json
// @Produce json
// @Param name path string true "Name of the bucket"
// @Param request body models.UpdateBucketQuotasRequest true "Quota configuration"
// @Success 200 {object} models.APIResponse{data=models.GarageBucketInfo} "Quotas 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}/quotas [put]
func (h *BucketHandler) UpdateBucketQuotas(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.UpdateBucketQuotasRequest
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.MaxSize != nil && *req.MaxSize <= 0 {
return c.Status(fiber.StatusBadRequest).JSON(
models.ErrorResponse(models.ErrCodeBadRequest, "maxSize must be greater than 0"),
)
}
if req.MaxObjects != nil && *req.MaxObjects <= 0 {
return c.Status(fiber.StatusBadRequest).JSON(
models.ErrorResponse(models.ErrCodeBadRequest, "maxObjects must be greater than 0"),
)
}
bucketInfo, err := h.adminService.GetBucketInfoByAlias(ctx, bucketName)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to get bucket info: "+err.Error()),
)
}
if bucketInfo == nil {
return c.Status(fiber.StatusNotFound).JSON(
models.ErrorResponse(models.ErrCodeBucketNotFound, "Bucket does not exist"),
)
}
updateReq := models.UpdateBucketRequest{
Quotas: &models.BucketQuotas{
MaxSize: req.MaxSize,
MaxObjects: req.MaxObjects,
},
}
result, err := h.adminService.UpdateBucket(ctx, bucketInfo.ID, updateReq)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to update bucket quotas: "+err.Error()),
)
}
+622
View File
@@ -0,0 +1,622 @@
package handlers
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"Noooste/garage-ui/internal/models"
"Noooste/garage-ui/internal/services/mocks"
"github.com/gofiber/fiber/v3"
)
func newBucketsTestApp(t *testing.T) (*fiber.App, *mocks.AdminMock) {
t.Helper()
admin := &mocks.AdminMock{}
h := NewBucketHandler(admin, nil) // s3 unused in this handler
app := fiber.New()
app.Get("/buckets", h.ListBuckets)
app.Post("/buckets", h.CreateBucket)
app.Get("/buckets/:name", h.GetBucketInfo)
app.Delete("/buckets/:name", h.DeleteBucket)
app.Post("/buckets/:name/permissions", h.GrantBucketPermission)
app.Put("/buckets/:name/website", h.UpdateBucketWebsite)
app.Put("/buckets/:name/quotas", h.UpdateBucketQuotas)
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 TestUpdateBucketQuotas_SetBoth(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.Quotas == nil {
t.Fatalf("Quotas = nil, want non-nil")
}
if req.Quotas.MaxSize == nil || *req.Quotas.MaxSize != 53687091200 {
t.Errorf("MaxSize = %v, want 53687091200", req.Quotas.MaxSize)
}
if req.Quotas.MaxObjects == nil || *req.Quotas.MaxObjects != 10000 {
t.Errorf("MaxObjects = %v, want 10000", req.Quotas.MaxObjects)
}
return &models.GarageBucketInfo{ID: id, Quotas: req.Quotas}, nil
}
maxSize := int64(53687091200)
maxObjects := int64(10000)
body, _ := json.Marshal(models.UpdateBucketQuotasRequest{MaxSize: &maxSize, MaxObjects: &maxObjects})
req := httptest.NewRequest(http.MethodPut, "/buckets/alpha/quotas", 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, want 200", resp.StatusCode)
}
}
func TestUpdateBucketQuotas_SetOnlyMaxSize(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.Quotas == nil {
t.Fatalf("Quotas = nil, want non-nil")
}
if req.Quotas.MaxSize == nil || *req.Quotas.MaxSize != 1024 {
t.Errorf("MaxSize = %v, want 1024", req.Quotas.MaxSize)
}
if req.Quotas.MaxObjects != nil {
t.Errorf("MaxObjects = %v, want nil", req.Quotas.MaxObjects)
}
return &models.GarageBucketInfo{ID: id, Quotas: req.Quotas}, nil
}
maxSize := int64(1024)
body, _ := json.Marshal(models.UpdateBucketQuotasRequest{MaxSize: &maxSize})
req := httptest.NewRequest(http.MethodPut, "/buckets/alpha/quotas", 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, want 200", resp.StatusCode)
}
}
func TestUpdateBucketQuotas_SetOnlyMaxObjects(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.Quotas == nil {
t.Fatalf("Quotas = nil, want non-nil")
}
if req.Quotas.MaxObjects == nil || *req.Quotas.MaxObjects != 500 {
t.Errorf("MaxObjects = %v, want 500", req.Quotas.MaxObjects)
}
if req.Quotas.MaxSize != nil {
t.Errorf("MaxSize = %v, want nil", req.Quotas.MaxSize)
}
return &models.GarageBucketInfo{ID: id, Quotas: req.Quotas}, nil
}
maxObjects := int64(500)
body, _ := json.Marshal(models.UpdateBucketQuotasRequest{MaxObjects: &maxObjects})
req := httptest.NewRequest(http.MethodPut, "/buckets/alpha/quotas", 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, want 200", resp.StatusCode)
}
}
func TestUpdateBucketQuotas_ClearBoth(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.Quotas == nil {
t.Fatalf("Quotas = nil, want non-nil (envelope must be present so service clears both)")
}
if req.Quotas.MaxSize != nil {
t.Errorf("MaxSize = %v, want nil", req.Quotas.MaxSize)
}
if req.Quotas.MaxObjects != nil {
t.Errorf("MaxObjects = %v, want nil", req.Quotas.MaxObjects)
}
return &models.GarageBucketInfo{ID: id}, nil
}
body, _ := json.Marshal(models.UpdateBucketQuotasRequest{})
req := httptest.NewRequest(http.MethodPut, "/buckets/alpha/quotas", 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, want 200", resp.StatusCode)
}
}
func TestUpdateBucketQuotas_RejectsZeroMaxSize(t *testing.T) {
app, _ := newBucketsTestApp(t)
zero := int64(0)
body, _ := json.Marshal(models.UpdateBucketQuotasRequest{MaxSize: &zero})
req := httptest.NewRequest(http.MethodPut, "/buckets/alpha/quotas", 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 TestUpdateBucketQuotas_RejectsNegativeMaxObjects(t *testing.T) {
app, _ := newBucketsTestApp(t)
neg := int64(-1)
body, _ := json.Marshal(models.UpdateBucketQuotasRequest{MaxObjects: &neg})
req := httptest.NewRequest(http.MethodPut, "/buckets/alpha/quotas", 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 TestUpdateBucketQuotas_NotFound(t *testing.T) {
app, admin := newBucketsTestApp(t)
admin.GetBucketInfoByAliasFn = func(_ context.Context, _ string) (*models.GarageBucketInfo, error) {
return nil, nil
}
maxSize := int64(1024)
body, _ := json.Marshal(models.UpdateBucketQuotasRequest{MaxSize: &maxSize})
req := httptest.NewRequest(http.MethodPut, "/buckets/missing/quotas", 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)
}
}
func TestUpdateBucketQuotas_MalformedJSONReturns400(t *testing.T) {
app, _ := newBucketsTestApp(t)
req := httptest.NewRequest(http.MethodPut, "/buckets/alpha/quotas", bytes.NewReader([]byte("{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)
}
}
+27
View File
@@ -0,0 +1,27 @@
package handlers
import (
"Noooste/garage-ui/internal/models"
"Noooste/garage-ui/internal/services"
"github.com/gofiber/fiber/v3"
)
type CapabilitiesHandler struct {
apiVersion string
capabilities services.Capabilities
}
func NewCapabilitiesHandler(apiVersion string, capabilities services.Capabilities) *CapabilitiesHandler {
return &CapabilitiesHandler{
apiVersion: apiVersion,
capabilities: capabilities,
}
}
func (h *CapabilitiesHandler) GetCapabilities(c fiber.Ctx) error {
return c.JSON(models.SuccessResponse(fiber.Map{
"garageApiVersion": h.apiVersion,
"features": h.capabilities,
}))
}
@@ -0,0 +1,78 @@
package handlers
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"Noooste/garage-ui/internal/services"
"github.com/gofiber/fiber/v3"
)
func TestCapabilities_V2(t *testing.T) {
app := fiber.New()
h := NewCapabilitiesHandler("v2", services.CapabilitiesV2())
app.Get("/capabilities", h.GetCapabilities)
req := httptest.NewRequest(http.MethodGet, "/capabilities", nil)
resp, err := app.Test(req)
if err != nil {
t.Fatal(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 {
GarageApiVersion string `json:"garageApiVersion"`
Features services.Capabilities `json:"features"`
} `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
t.Fatal(err)
}
if !body.Success {
t.Fatal("expected success=true")
}
if body.Data.GarageApiVersion != "v2" {
t.Errorf("garageApiVersion = %q, want v2", body.Data.GarageApiVersion)
}
if !body.Data.Features.ClusterStatistics || !body.Data.Features.NodeInfo || !body.Data.Features.NodeStatistics {
t.Errorf("features = %+v, want all true", body.Data.Features)
}
}
func TestCapabilities_V1(t *testing.T) {
app := fiber.New()
h := NewCapabilitiesHandler("v1", services.CapabilitiesV1())
app.Get("/capabilities", h.GetCapabilities)
req := httptest.NewRequest(http.MethodGet, "/capabilities", nil)
resp, err := app.Test(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var body struct {
Data struct {
GarageApiVersion string `json:"garageApiVersion"`
Features services.Capabilities `json:"features"`
} `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
t.Fatal(err)
}
if body.Data.GarageApiVersion != "v1" {
t.Errorf("garageApiVersion = %q, want v1", body.Data.GarageApiVersion)
}
if body.Data.Features.ClusterStatistics || body.Data.Features.NodeInfo || body.Data.Features.NodeStatistics {
t.Errorf("features = %+v, want all false", body.Data.Features)
}
}
+21 -12
View File
@@ -1,19 +1,21 @@
package handlers
import (
"errors"
"Noooste/garage-ui/internal/models"
"Noooste/garage-ui/internal/services"
"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,
}
@@ -69,14 +71,17 @@ func (h *ClusterHandler) GetStatus(c fiber.Ctx) error {
// GET /api/v1/cluster/statistics
func (h *ClusterHandler) GetStatistics(c fiber.Ctx) error {
ctx := c.Context()
stats, err := h.adminService.GetClusterStatistics(ctx)
if err != nil {
if errors.Is(err, services.ErrUnsupported) {
return c.Status(fiber.StatusNotImplemented).JSON(
models.ErrorResponse(models.ErrCodeUnsupported, "This feature requires Garage v2.0+"),
)
}
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to get cluster statistics: "+err.Error()),
)
}
return c.JSON(models.SuccessResponse(stats))
}
@@ -95,20 +100,22 @@ func (h *ClusterHandler) GetStatistics(c fiber.Ctx) error {
func (h *ClusterHandler) GetNodeInfo(c fiber.Ctx) error {
ctx := c.Context()
nodeID := c.Params("node_id")
if nodeID == "" {
return c.Status(fiber.StatusBadRequest).JSON(
models.ErrorResponse(models.ErrCodeBadRequest, "Node ID is required"),
)
}
info, err := h.adminService.GetNodeInfo(ctx, nodeID)
if err != nil {
if errors.Is(err, services.ErrUnsupported) {
return c.Status(fiber.StatusNotImplemented).JSON(
models.ErrorResponse(models.ErrCodeUnsupported, "This feature requires Garage v2.0+"),
)
}
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to get node info: "+err.Error()),
)
}
return c.JSON(models.SuccessResponse(info))
}
@@ -127,19 +134,21 @@ func (h *ClusterHandler) GetNodeInfo(c fiber.Ctx) error {
func (h *ClusterHandler) GetNodeStatistics(c fiber.Ctx) error {
ctx := c.Context()
nodeID := c.Params("node_id")
if nodeID == "" {
return c.Status(fiber.StatusBadRequest).JSON(
models.ErrorResponse(models.ErrCodeBadRequest, "Node ID is required"),
)
}
stats, err := h.adminService.GetNodeStatistics(ctx, nodeID)
if err != nil {
if errors.Is(err, services.ErrUnsupported) {
return c.Status(fiber.StatusNotImplemented).JSON(
models.ErrorResponse(models.ErrCodeUnsupported, "This feature requires Garage v2.0+"),
)
}
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to get node statistics: "+err.Error()),
)
}
return c.JSON(models.SuccessResponse(stats))
}
+213
View File
@@ -0,0 +1,213 @@
package handlers
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"Noooste/garage-ui/internal/models"
"Noooste/garage-ui/internal/services"
"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_GetStatistics_UnsupportedReturns501(t *testing.T) {
app, admin := newClusterTestApp(t)
admin.GetClusterStatisticsFn = func(_ context.Context) (*models.ClusterStatistics, error) {
return nil, services.ErrUnsupported
}
resp := doGet(t, app, "/cluster/statistics")
defer resp.Body.Close()
if resp.StatusCode != http.StatusNotImplemented {
t.Fatalf("status = %d, want 501", resp.StatusCode)
}
}
func TestCluster_GetNodeInfo_UnsupportedReturns501(t *testing.T) {
app, admin := newClusterTestApp(t)
admin.GetNodeInfoFn = func(_ context.Context, _ string) (*models.MultiNodeResponse, error) {
return nil, services.ErrUnsupported
}
resp := doGet(t, app, "/cluster/nodes/n1")
defer resp.Body.Close()
if resp.StatusCode != http.StatusNotImplemented {
t.Fatalf("status = %d, want 501", resp.StatusCode)
}
}
func TestCluster_GetNodeStatistics_UnsupportedReturns501(t *testing.T) {
app, admin := newClusterTestApp(t)
admin.GetNodeStatisticsFn = func(_ context.Context, _ string) (*models.MultiNodeResponse, error) {
return nil, services.ErrUnsupported
}
resp := doGet(t, app, "/cluster/nodes/n1/statistics")
defer resp.Body.Close()
if resp.StatusCode != http.StatusNotImplemented {
t.Fatalf("status = %d, want 501", 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)
}
}
+130 -13
View File
@@ -1,8 +1,12 @@
package handlers
import (
"bufio"
"io"
"net/url"
"path"
"strconv"
"strings"
"time"
"Noooste/garage-ui/internal/models"
@@ -11,13 +15,66 @@ import (
"github.com/gofiber/fiber/v3"
)
// ObjectHandler handles object-related operations
type ObjectHandler struct {
s3Service *services.S3Service
// unsafeInlineContentTypes are MIME types that a browser can execute as
// JavaScript in the response's origin when rendered inline. Since the SPA is
// served from the same origin as the API, any uploader could otherwise plant
// stored XSS by uploading a file with one of these Content-Types.
var unsafeInlineContentTypes = map[string]struct{}{
"text/html": {},
"application/xhtml+xml": {},
"image/svg+xml": {},
"application/xml": {},
"text/xml": {},
"application/javascript": {},
"text/javascript": {},
}
// NewObjectHandler creates a new object handler
func NewObjectHandler(s3Service *services.S3Service) *ObjectHandler {
// safeContentType rewrites Content-Types that the browser would treat as
// executable to application/octet-stream.
func safeContentType(ct string) string {
base := strings.TrimSpace(strings.ToLower(ct))
if i := strings.IndexByte(base, ';'); i >= 0 {
base = strings.TrimSpace(base[:i])
}
if _, bad := unsafeInlineContentTypes[base]; bad {
return "application/octet-stream"
}
return ct
}
// contentDispositionHeader builds an RFC 6266 / RFC 5987 Content-Disposition
// header value with the user-controlled object key safely encoded. Strips
// path components and control characters before emitting the ASCII fallback,
// then appends the percent-encoded UTF-8 filename*= for full fidelity.
func contentDispositionHeader(disposition, key string) string {
name := path.Base(key)
if name == "." || name == "/" || name == "" {
name = "download"
}
// ASCII-safe fallback: drop anything that could break the quoted value.
var asciiFallback strings.Builder
for _, r := range name {
if r < 0x20 || r == 0x7f || r == '"' || r == '\\' || r > 0x7e {
asciiFallback.WriteByte('_')
continue
}
asciiFallback.WriteRune(r)
}
fallback := asciiFallback.String()
if fallback == "" {
fallback = "download"
}
encoded := url.PathEscape(name)
return disposition + "; filename=\"" + fallback + "\"; filename*=UTF-8''" + encoded
}
// ObjectHandler handles object-related HTTP requests.
type ObjectHandler struct {
s3Service services.S3Storage
}
// NewObjectHandler creates a new object handler.
func NewObjectHandler(s3Service services.S3Storage) *ObjectHandler {
return &ObjectHandler{
s3Service: s3Service,
}
@@ -137,6 +194,58 @@ func (h *ObjectHandler) UploadObject(c fiber.Ctx) error {
return c.Status(fiber.StatusCreated).JSON(models.SuccessResponse(uploadResult))
}
// CreateDirectory creates an empty directory marker in a bucket.
//
// @Summary Create directory in bucket
// @Description Creates a zero-byte object whose key ends with "/" so that S3 clients display it as an empty folder.
// @Tags Objects
// @Accept json
// @Produce json
// @Param bucket path string true "Name of the bucket"
// @Param request body object{key=string} true "Directory key (must end with '/')"
// @Success 201 {object} models.APIResponse{data=models.ObjectUploadResponse} "Directory created"
// @Failure 400 {object} models.APIResponse{error=models.APIError} "Invalid request parameters"
// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to create directory"
// @Router /api/v1/buckets/{bucket}/directories [post]
func (h *ObjectHandler) CreateDirectory(c fiber.Ctx) error {
ctx := c.Context()
bucketName := c.Params("bucket")
if bucketName == "" {
return c.Status(fiber.StatusBadRequest).JSON(
models.ErrorResponse(models.ErrCodeBadRequest, "Bucket name is required"),
)
}
var req struct {
Key string `json:"key"`
}
if err := c.Bind().JSON(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(
models.ErrorResponse(models.ErrCodeBadRequest, "Invalid request body: "+err.Error()),
)
}
key := strings.TrimLeft(req.Key, "/")
if key == "" {
return c.Status(fiber.StatusBadRequest).JSON(
models.ErrorResponse(models.ErrCodeBadRequest, "Directory key is required"),
)
}
if !strings.HasSuffix(key, "/") {
key += "/"
}
result, err := h.s3Service.CreateDirectoryMarker(ctx, bucketName, key)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeUploadFailed, "Failed to create directory: "+err.Error()),
)
}
return c.Status(fiber.StatusCreated).JSON(models.SuccessResponse(result))
}
// GetObject retrieves an object from a bucket
//
// @Summary Get object from bucket
@@ -176,21 +285,29 @@ func (h *ObjectHandler) GetObject(c fiber.Ctx) error {
models.ErrorResponse(models.ErrCodeObjectNotFound, "Object not found: "+err.Error()),
)
}
defer body.Close()
// Set response headers
c.Set("Content-Type", objectInfo.ContentType)
c.Set("Content-Length", string(rune(objectInfo.Size)))
// The uploader controls Content-Type. Rewrite executable MIME types to
// application/octet-stream and always disable sniffing so stored HTML/SVG
// cannot run as XSS in the SPA origin when fetched inline.
c.Set("Content-Type", safeContentType(objectInfo.ContentType))
c.Set("X-Content-Type-Options", "nosniff")
c.Set("Content-Length", strconv.FormatInt(objectInfo.Size, 10))
c.Set("ETag", objectInfo.ETag)
c.Set("Last-Modified", objectInfo.LastModified.Format(time.RFC1123))
// Check if client wants to download or view inline
// The object key is attacker-controlled — build the header via the safe
// RFC 6266 helper to avoid quote/semicolon injection into filename=.
disposition := "inline"
if c.Query("download") == "true" {
c.Set("Content-Disposition", "attachment; filename=\""+key+"\"")
disposition = "attachment"
}
c.Set("Content-Disposition", contentDispositionHeader(disposition, key))
// Stream the object body to the client
return c.SendStream(body)
// Stream the object body to the client without buffering the entire file
return c.SendStreamWriter(func(w *bufio.Writer) {
defer body.Close()
io.Copy(w, body)
})
}
// DeleteObject deletes an object from a bucket
@@ -0,0 +1,103 @@
package handlers
import (
"strings"
"testing"
)
func TestSafeContentType_RewritesExecutableTypes(t *testing.T) {
cases := []struct {
in, want string
}{
{"text/html", "application/octet-stream"},
{"text/html; charset=utf-8", "application/octet-stream"},
{"TEXT/HTML", "application/octet-stream"},
{" text/html ", "application/octet-stream"},
{"application/xhtml+xml", "application/octet-stream"},
{"image/svg+xml", "application/octet-stream"},
{"application/xml", "application/octet-stream"},
{"text/xml", "application/octet-stream"},
{"application/javascript", "application/octet-stream"},
{"text/javascript", "application/octet-stream"},
// Safe types pass through unchanged (including parameters).
{"image/png", "image/png"},
{"application/octet-stream", "application/octet-stream"},
{"text/plain; charset=utf-8", "text/plain; charset=utf-8"},
{"", ""},
}
for _, tc := range cases {
t.Run(tc.in, func(t *testing.T) {
if got := safeContentType(tc.in); got != tc.want {
t.Errorf("safeContentType(%q) = %q, want %q", tc.in, got, tc.want)
}
})
}
}
func TestContentDispositionHeader_StripsPathAndEscapes(t *testing.T) {
cases := []struct {
name string
disp, key string
mustContain []string
mustNotContain []string
}{
{
name: "simple filename",
disp: "inline", key: "photo.png",
mustContain: []string{
`inline; filename="photo.png"; filename*=UTF-8''photo.png`,
},
},
{
name: "path components stripped",
disp: "attachment", key: "a/b/c/file.txt",
mustContain: []string{`filename="file.txt"`, `filename*=UTF-8''file.txt`},
mustNotContain: []string{`a/b/c`},
},
{
name: "quote and backslash replaced in ASCII fallback",
disp: "inline", key: `"evil\name".txt`,
mustContain: []string{`filename="_evil_name_.txt"`},
mustNotContain: []string{`"evil`, `\name`},
},
{
name: "control character replaced",
disp: "inline", key: "line\nbreak.txt",
mustContain: []string{`filename="line_break.txt"`},
},
{
name: "non-ASCII preserved in filename* only",
disp: "inline", key: "漢字.txt",
mustContain: []string{
`filename*=UTF-8''`,
// fallback replaced every non-ASCII rune with _ (2 runes + .txt).
`filename="__.txt"`,
},
},
{
name: "empty key falls back to download",
disp: "inline", key: "",
mustContain: []string{`filename="download"`, `filename*=UTF-8''download`},
},
{
name: "key of only slashes falls back to download",
disp: "inline", key: "/",
mustContain: []string{`filename="download"`, `filename*=UTF-8''`},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := contentDispositionHeader(tc.disp, tc.key)
for _, sub := range tc.mustContain {
if !strings.Contains(got, sub) {
t.Errorf("got %q\nmust contain %q", got, sub)
}
}
for _, sub := range tc.mustNotContain {
if strings.Contains(got, sub) {
t.Errorf("got %q\nmust NOT contain %q", got, sub)
}
}
})
}
}
+866
View File
@@ -0,0 +1,866 @@
package handlers
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"Noooste/garage-ui/internal/models"
"Noooste/garage-ui/internal/services"
"Noooste/garage-ui/internal/services/mocks"
"github.com/gofiber/fiber/v3"
)
func newObjectsTestApp(t *testing.T) (*fiber.App, *mocks.S3Mock) {
t.Helper()
s3 := &mocks.S3Mock{}
h := NewObjectHandler(s3)
app := fiber.New()
app.Get("/buckets/:bucket/objects", h.ListObjects)
app.Post("/buckets/:bucket/objects", h.UploadObject)
app.Post("/buckets/:bucket/directories", h.CreateDirectory)
app.Post("/buckets/:bucket/objects/upload-multiple", h.UploadMultipleObjects)
app.Post("/buckets/:bucket/objects/delete-multiple", h.DeleteMultipleObjects)
// Wildcard endpoints — mount under :key for tests. Handlers prefer
// c.Locals("objectKey") but fall back to c.Params("key"), so :key works.
app.Get("/buckets/:bucket/objects/:key", h.GetObject)
app.Get("/buckets/:bucket/objects/:key/metadata", h.GetObjectMetadata)
app.Get("/buckets/:bucket/objects/:key/presigned", h.GetPresignedURL)
app.Delete("/buckets/:bucket/objects/:key", h.DeleteObject)
return app, s3
}
// --- ListObjects ---
func TestListObjects_Success(t *testing.T) {
app, s3 := newObjectsTestApp(t)
s3.ListObjectsFn = func(_ context.Context, bucket, prefix string, max int, tok string) (*models.ObjectListResponse, error) {
if bucket != "b1" || prefix != "p/" || max != 50 || tok != "T" {
t.Errorf("args = (%q, %q, %d, %q)", bucket, prefix, max, tok)
}
return &models.ObjectListResponse{
Bucket: bucket, Count: 1,
Objects: []models.ObjectInfo{{Key: "k1", Size: 1}},
}, nil
}
req := httptest.NewRequest(http.MethodGet, "/buckets/b1/objects?prefix=p/&max_keys=50&continuation_token=T", nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d", resp.StatusCode)
}
var body struct {
Data models.ObjectListResponse `json:"data"`
}
decodeJSON(t, resp.Body, &body)
if body.Data.Count != 1 {
t.Errorf("count = %d", body.Data.Count)
}
}
func TestListObjects_DefaultMaxKeys(t *testing.T) {
app, s3 := newObjectsTestApp(t)
s3.ListObjectsFn = func(_ context.Context, _, _ string, max int, _ string) (*models.ObjectListResponse, error) {
if max != 100 {
t.Errorf("max = %d, want default 100", max)
}
return &models.ObjectListResponse{}, nil
}
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/objects", nil))
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d", resp.StatusCode)
}
}
func TestListObjects_InvalidMaxKeys400(t *testing.T) {
app, _ := newObjectsTestApp(t)
cases := []string{"0", "-1", "abc"}
for _, mk := range cases {
t.Run(mk, func(t *testing.T) {
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/objects?max_keys="+mk, nil))
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", resp.StatusCode)
}
})
}
}
func TestListObjects_ServiceError500(t *testing.T) {
app, s3 := newObjectsTestApp(t)
s3.ListObjectsFn = func(_ context.Context, _, _ string, _ int, _ string) (*models.ObjectListResponse, error) {
return nil, errors.New("boom")
}
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/objects", nil))
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusInternalServerError {
t.Fatalf("status = %d, want 500", resp.StatusCode)
}
}
// --- GetObjectMetadata ---
func TestGetObjectMetadata_Success(t *testing.T) {
app, s3 := newObjectsTestApp(t)
s3.GetObjectMetadataFn = func(_ context.Context, b, k string) (*models.ObjectInfo, error) {
return &models.ObjectInfo{Key: k, Size: 42, ContentType: "image/png"}, nil
}
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/objects/k1/metadata", nil))
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d", resp.StatusCode)
}
var body struct {
Data models.ObjectInfo `json:"data"`
}
decodeJSON(t, resp.Body, &body)
if body.Data.Size != 42 {
t.Errorf("size = %d", body.Data.Size)
}
}
func TestGetObjectMetadata_NotFound404(t *testing.T) {
app, s3 := newObjectsTestApp(t)
s3.GetObjectMetadataFn = func(_ context.Context, _, _ string) (*models.ObjectInfo, error) {
return nil, errors.New("not found")
}
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/objects/nope/metadata", nil))
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("status = %d, want 404", resp.StatusCode)
}
}
// --- DeleteObject ---
func TestDeleteObject_Success(t *testing.T) {
app, s3 := newObjectsTestApp(t)
s3.ObjectExistsFn = func(_ context.Context, _, _ string) (bool, error) { return true, nil }
s3.DeleteObjectFn = func(_ context.Context, b, k string) error {
if b != "b1" || k != "k1" {
t.Errorf("args = (%q, %q)", b, k)
}
return nil
}
resp, err := app.Test(httptest.NewRequest(http.MethodDelete, "/buckets/b1/objects/k1", nil))
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d", resp.StatusCode)
}
}
func TestDeleteObject_NotExists404(t *testing.T) {
app, s3 := newObjectsTestApp(t)
s3.ObjectExistsFn = func(_ context.Context, _, _ string) (bool, error) { return false, nil }
resp, err := app.Test(httptest.NewRequest(http.MethodDelete, "/buckets/b1/objects/nope", nil))
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("status = %d, want 404", resp.StatusCode)
}
}
func TestDeleteObject_ExistsCheckError500(t *testing.T) {
app, s3 := newObjectsTestApp(t)
s3.ObjectExistsFn = func(_ context.Context, _, _ string) (bool, error) { return false, errors.New("boom") }
resp, err := app.Test(httptest.NewRequest(http.MethodDelete, "/buckets/b1/objects/k1", nil))
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusInternalServerError {
t.Fatalf("status = %d, want 500", resp.StatusCode)
}
}
func TestDeleteObject_DeleteError500(t *testing.T) {
app, s3 := newObjectsTestApp(t)
s3.ObjectExistsFn = func(_ context.Context, _, _ string) (bool, error) { return true, nil }
s3.DeleteObjectFn = func(_ context.Context, _, _ string) error { return errors.New("boom") }
resp, err := app.Test(httptest.NewRequest(http.MethodDelete, "/buckets/b1/objects/k1", nil))
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusInternalServerError {
t.Fatalf("status = %d, want 500", resp.StatusCode)
}
}
// --- GetPresignedURL ---
func TestGetPresignedURL_DefaultExpiration(t *testing.T) {
app, s3 := newObjectsTestApp(t)
s3.ObjectExistsFn = func(_ context.Context, _, _ string) (bool, error) { return true, nil }
s3.GetPresignedURLFn = func(_ context.Context, b, k string, exp time.Duration) (string, error) {
if exp != 3600*time.Second {
t.Errorf("exp = %v, want 1h", exp)
}
return "https://example/signed", nil
}
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/objects/k1/presigned", nil))
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d", resp.StatusCode)
}
var body struct {
Data models.PresignedURLResponse `json:"data"`
}
decodeJSON(t, resp.Body, &body)
if body.Data.URL != "https://example/signed" || body.Data.ExpiresIn != 3600 {
t.Errorf("body = %+v", body.Data)
}
}
func TestGetPresignedURL_CustomExpirationWithinRange(t *testing.T) {
app, s3 := newObjectsTestApp(t)
s3.ObjectExistsFn = func(_ context.Context, _, _ string) (bool, error) { return true, nil }
s3.GetPresignedURLFn = func(_ context.Context, _, _ string, exp time.Duration) (string, error) {
if exp != 60*time.Second {
t.Errorf("exp = %v, want 60s", exp)
}
return "u", nil
}
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/objects/k1/presigned?expires_in=60", nil))
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d", resp.StatusCode)
}
}
func TestGetPresignedURL_InvalidExpiration400(t *testing.T) {
app, _ := newObjectsTestApp(t)
cases := []string{"0", "-1", "604801", "abc"}
for _, val := range cases {
t.Run(val, func(t *testing.T) {
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/objects/k1/presigned?expires_in="+val, nil))
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", resp.StatusCode)
}
})
}
}
func TestGetPresignedURL_ObjectMissing404(t *testing.T) {
app, s3 := newObjectsTestApp(t)
s3.ObjectExistsFn = func(_ context.Context, _, _ string) (bool, error) { return false, nil }
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/objects/nope/presigned", nil))
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("status = %d, want 404", resp.StatusCode)
}
}
// --- GetObject ---
func TestGetObject_Success_StreamsBodyAndHeaders(t *testing.T) {
app, s3 := newObjectsTestApp(t)
content := []byte("hello world")
s3.GetObjectFn = func(_ context.Context, b, k string) (io.ReadCloser, *models.ObjectInfo, error) {
return io.NopCloser(bytes.NewReader(content)), &models.ObjectInfo{
Key: k, Size: int64(len(content)), ETag: `"etag-1"`,
ContentType: "image/png", LastModified: time.Date(2026, 4, 1, 12, 0, 0, 0, time.UTC),
}, nil
}
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/objects/k1", nil))
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d", resp.StatusCode)
}
if got := resp.Header.Get("Content-Type"); got != "image/png" {
t.Errorf("Content-Type = %q", got)
}
if got := resp.Header.Get("X-Content-Type-Options"); got != "nosniff" {
t.Errorf("X-Content-Type-Options = %q", got)
}
if !strings.Contains(resp.Header.Get("Content-Disposition"), `filename="k1"`) {
t.Errorf("Content-Disposition = %q", resp.Header.Get("Content-Disposition"))
}
body, _ := io.ReadAll(resp.Body)
if !bytes.Equal(body, content) {
t.Errorf("body = %q, want %q", body, content)
}
}
func TestGetObject_RewritesExecutableContentType(t *testing.T) {
app, s3 := newObjectsTestApp(t)
s3.GetObjectFn = func(_ context.Context, _, _ string) (io.ReadCloser, *models.ObjectInfo, error) {
return io.NopCloser(strings.NewReader("<script>alert(1)</script>")),
&models.ObjectInfo{Key: "evil.html", Size: 25, ContentType: "text/html"},
nil
}
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/objects/evil.html", nil))
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if got := resp.Header.Get("Content-Type"); got != "application/octet-stream" {
t.Errorf("Content-Type = %q, want application/octet-stream", got)
}
}
func TestGetObject_DownloadQuerySetsAttachment(t *testing.T) {
app, s3 := newObjectsTestApp(t)
s3.GetObjectFn = func(_ context.Context, _, _ string) (io.ReadCloser, *models.ObjectInfo, error) {
return io.NopCloser(strings.NewReader("x")), &models.ObjectInfo{Key: "file.txt", Size: 1}, nil
}
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/objects/file.txt?download=true", nil))
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if !strings.HasPrefix(resp.Header.Get("Content-Disposition"), "attachment") {
t.Errorf("Content-Disposition = %q, want attachment", resp.Header.Get("Content-Disposition"))
}
}
func TestGetObject_ServiceErrorReturns404(t *testing.T) {
app, s3 := newObjectsTestApp(t)
s3.GetObjectFn = func(_ context.Context, _, _ string) (io.ReadCloser, *models.ObjectInfo, error) {
return nil, nil, errors.New("not found")
}
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/objects/nope", nil))
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("status = %d, want 404", resp.StatusCode)
}
}
// buildMultipart builds a multipart body with a single file field plus
// optional additional form fields. Returns body bytes and the Content-Type
// header value (which includes the boundary).
func buildMultipart(t *testing.T, fields map[string]string, files map[string]struct {
Filename string
Content []byte
ContentType string
}) ([]byte, string) {
t.Helper()
buf := &bytes.Buffer{}
w := multipart.NewWriter(buf)
for k, v := range fields {
if err := w.WriteField(k, v); err != nil {
t.Fatalf("WriteField: %v", err)
}
}
for name, f := range files {
h := make(map[string][]string)
h["Content-Disposition"] = []string{
`form-data; name="` + name + `"; filename="` + f.Filename + `"`,
}
ct := f.ContentType
if ct == "" {
ct = "application/octet-stream"
}
h["Content-Type"] = []string{ct}
part, err := w.CreatePart(h)
if err != nil {
t.Fatalf("CreatePart: %v", err)
}
if _, err := part.Write(f.Content); err != nil {
t.Fatalf("part.Write: %v", err)
}
}
if err := w.Close(); err != nil {
t.Fatalf("writer.Close: %v", err)
}
return buf.Bytes(), w.FormDataContentType()
}
func TestUploadObject_Success(t *testing.T) {
app, s3 := newObjectsTestApp(t)
s3.UploadObjectFn = func(_ context.Context, bucket, key string, body io.Reader, ct string) (*models.ObjectUploadResponse, error) {
if bucket != "b1" || key != "myfile.bin" {
t.Errorf("args = (%q, %q)", bucket, key)
}
if ct != "application/octet-stream" {
t.Errorf("contentType = %q", ct)
}
b, _ := io.ReadAll(body)
if string(b) != "payload" {
t.Errorf("body = %q, want 'payload'", b)
}
return &models.ObjectUploadResponse{Bucket: bucket, Key: key, Size: int64(len(b))}, nil
}
body, ct := buildMultipart(t, nil, map[string]struct {
Filename string
Content []byte
ContentType string
}{
"file": {Filename: "myfile.bin", Content: []byte("payload")},
})
req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects", bytes.NewReader(body))
req.Header.Set("Content-Type", ct)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
raw, _ := io.ReadAll(resp.Body)
t.Fatalf("status = %d, want 201\nbody: %s", resp.StatusCode, raw)
}
}
func TestUploadObject_ExplicitKeyOverridesFilename(t *testing.T) {
app, s3 := newObjectsTestApp(t)
s3.UploadObjectFn = func(_ context.Context, _, key string, _ io.Reader, _ string) (*models.ObjectUploadResponse, error) {
if key != "custom/key.txt" {
t.Errorf("key = %q, want custom/key.txt", key)
}
return &models.ObjectUploadResponse{Key: key}, nil
}
body, ct := buildMultipart(t, map[string]string{"key": "custom/key.txt"}, map[string]struct {
Filename string
Content []byte
ContentType string
}{
"file": {Filename: "whatever.txt", Content: []byte("x")},
})
req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects", bytes.NewReader(body))
req.Header.Set("Content-Type", ct)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
t.Fatalf("status = %d", resp.StatusCode)
}
}
func TestUploadObject_MissingFileReturns400(t *testing.T) {
app, _ := newObjectsTestApp(t)
body, ct := buildMultipart(t, map[string]string{"key": "k"}, nil)
req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects", bytes.NewReader(body))
req.Header.Set("Content-Type", ct)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", resp.StatusCode)
}
}
func TestUploadObject_ServiceError500(t *testing.T) {
app, s3 := newObjectsTestApp(t)
s3.UploadObjectFn = func(_ context.Context, _, _ string, _ io.Reader, _ string) (*models.ObjectUploadResponse, error) {
return nil, errors.New("boom")
}
body, ct := buildMultipart(t, nil, map[string]struct {
Filename string
Content []byte
ContentType string
}{"file": {Filename: "f.bin", Content: []byte("x")}})
req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects", bytes.NewReader(body))
req.Header.Set("Content-Type", ct)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusInternalServerError {
t.Fatalf("status = %d, want 500", resp.StatusCode)
}
}
func TestDeleteMultipleObjects_Success(t *testing.T) {
app, s3 := newObjectsTestApp(t)
s3.DeleteMultipleObjectsFn = func(_ context.Context, bucket string, keys []string) error {
if bucket != "b1" || len(keys) != 3 {
t.Errorf("args = (%q, %v)", bucket, keys)
}
return nil
}
body, _ := json.Marshal(map[string]any{"keys": []string{"a", "b", "c"}})
req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects/delete-multiple", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d", resp.StatusCode)
}
var out struct {
Data models.ObjectDeleteMultipleResponse `json:"data"`
}
decodeJSON(t, resp.Body, &out)
if out.Data.Deleted != 3 {
t.Errorf("Deleted = %d, want 3", out.Data.Deleted)
}
}
func TestDeleteMultipleObjects_EmptyKeys400(t *testing.T) {
app, _ := newObjectsTestApp(t)
body, _ := json.Marshal(map[string]any{"keys": []string{}})
req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects/delete-multiple", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", resp.StatusCode)
}
}
func TestDeleteMultipleObjects_MalformedJSON400(t *testing.T) {
app, _ := newObjectsTestApp(t)
req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects/delete-multiple", strings.NewReader("{not-json"))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d", resp.StatusCode)
}
}
func TestDeleteMultipleObjects_ServiceError500(t *testing.T) {
app, s3 := newObjectsTestApp(t)
s3.DeleteMultipleObjectsFn = func(_ context.Context, _ string, _ []string) error { return errors.New("boom") }
body, _ := json.Marshal(map[string]any{"keys": []string{"a"}})
req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects/delete-multiple", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusInternalServerError {
t.Fatalf("status = %d", resp.StatusCode)
}
}
// buildMultipartMulti builds a body with N "files" parts.
func buildMultipartMulti(t *testing.T, files []struct {
Filename, ContentType string
Content []byte
}) ([]byte, string) {
t.Helper()
buf := &bytes.Buffer{}
w := multipart.NewWriter(buf)
for _, f := range files {
h := map[string][]string{
"Content-Disposition": {`form-data; name="files"; filename="` + f.Filename + `"`},
"Content-Type": {f.ContentType},
}
part, err := w.CreatePart(h)
if err != nil {
t.Fatalf("CreatePart: %v", err)
}
_, _ = part.Write(f.Content)
}
_ = w.Close()
return buf.Bytes(), w.FormDataContentType()
}
func TestUploadMultiple_AllSuccess201(t *testing.T) {
app, s3 := newObjectsTestApp(t)
s3.UploadMultipleObjectsFn = func(_ context.Context, bucket string, files []struct {
Key string
Body io.Reader
ContentType string
}) []services.UploadResult {
if bucket != "b1" || len(files) != 2 {
t.Errorf("got bucket=%q files=%d", bucket, len(files))
}
out := make([]services.UploadResult, len(files))
for i, f := range files {
out[i] = services.UploadResult{Key: f.Key, Success: true, ContentType: f.ContentType, Size: 1}
}
return out
}
body, ct := buildMultipartMulti(t, []struct {
Filename, ContentType string
Content []byte
}{
{"a.txt", "text/plain", []byte("a")},
{"b.txt", "text/plain", []byte("b")},
})
req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects/upload-multiple", bytes.NewReader(body))
req.Header.Set("Content-Type", ct)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
raw, _ := io.ReadAll(resp.Body)
t.Fatalf("status = %d, want 201\nbody: %s", resp.StatusCode, raw)
}
var out struct {
Data models.ObjectUploadMultipleResponse `json:"data"`
}
decodeJSON(t, resp.Body, &out)
if out.Data.SuccessCount != 2 || out.Data.FailureCount != 0 {
t.Errorf("counts = (%d, %d)", out.Data.SuccessCount, out.Data.FailureCount)
}
}
func TestUploadMultiple_PartialReturns207(t *testing.T) {
app, s3 := newObjectsTestApp(t)
s3.UploadMultipleObjectsFn = func(_ context.Context, _ string, files []struct {
Key string
Body io.Reader
ContentType string
}) []services.UploadResult {
return []services.UploadResult{
{Key: files[0].Key, Success: true, Size: 1, ContentType: files[0].ContentType},
{Key: files[1].Key, Success: false, Error: errors.New("upload failed"), ContentType: files[1].ContentType},
}
}
body, ct := buildMultipartMulti(t, []struct {
Filename, ContentType string
Content []byte
}{
{"a.txt", "text/plain", []byte("a")},
{"b.txt", "text/plain", []byte("b")},
})
req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects/upload-multiple", bytes.NewReader(body))
req.Header.Set("Content-Type", ct)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusMultiStatus {
t.Fatalf("status = %d, want 207", resp.StatusCode)
}
}
func TestUploadMultiple_AllFailReturns500(t *testing.T) {
app, s3 := newObjectsTestApp(t)
s3.UploadMultipleObjectsFn = func(_ context.Context, _ string, files []struct {
Key string
Body io.Reader
ContentType string
}) []services.UploadResult {
out := make([]services.UploadResult, len(files))
for i, f := range files {
out[i] = services.UploadResult{Key: f.Key, Success: false, Error: errors.New("boom")}
}
return out
}
body, ct := buildMultipartMulti(t, []struct {
Filename, ContentType string
Content []byte
}{{"a.txt", "text/plain", []byte("a")}})
req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects/upload-multiple", bytes.NewReader(body))
req.Header.Set("Content-Type", ct)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusInternalServerError {
t.Fatalf("status = %d, want 500", resp.StatusCode)
}
}
func TestUploadMultiple_NoFiles400(t *testing.T) {
app, _ := newObjectsTestApp(t)
body, ct := buildMultipartMulti(t, nil)
req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects/upload-multiple", bytes.NewReader(body))
req.Header.Set("Content-Type", ct)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", resp.StatusCode)
}
}
func TestUploadMultiple_DefaultsContentType(t *testing.T) {
app, s3 := newObjectsTestApp(t)
s3.UploadMultipleObjectsFn = func(_ context.Context, _ string, files []struct {
Key string
Body io.Reader
ContentType string
}) []services.UploadResult {
if files[0].ContentType != "application/octet-stream" {
t.Errorf("ContentType = %q, want default application/octet-stream", files[0].ContentType)
}
return []services.UploadResult{{Key: files[0].Key, Success: true}}
}
// Write a part with an empty Content-Type header explicitly.
buf := &bytes.Buffer{}
w := multipart.NewWriter(buf)
part, err := w.CreatePart(map[string][]string{
"Content-Disposition": {`form-data; name="files"; filename="a.txt"`},
"Content-Type": {""},
})
if err != nil {
t.Fatalf("CreatePart: %v", err)
}
_, _ = part.Write([]byte("x"))
_ = w.Close()
req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects/upload-multiple", bytes.NewReader(buf.Bytes()))
req.Header.Set("Content-Type", w.FormDataContentType())
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
t.Fatalf("status = %d, want 201", resp.StatusCode)
}
}
// --- CreateDirectory ---
func TestCreateDirectory_Success_AppendsTrailingSlash(t *testing.T) {
app, s3 := newObjectsTestApp(t)
var gotKey string
s3.CreateDirectoryMarkerFn = func(_ context.Context, bucket, key string) (*models.ObjectUploadResponse, error) {
gotKey = key
return &models.ObjectUploadResponse{Bucket: bucket, Key: key, Size: 0, ContentType: "application/x-directory"}, nil
}
body := bytes.NewBufferString(`{"key": "photos/2024"}`)
req := httptest.NewRequest(http.MethodPost, "/buckets/b1/directories", body)
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
t.Fatalf("status = %d, want 201", resp.StatusCode)
}
if gotKey != "photos/2024/" {
t.Errorf("service key = %q, want trailing slash appended", gotKey)
}
}
func TestCreateDirectory_StripsLeadingSlashes(t *testing.T) {
app, s3 := newObjectsTestApp(t)
var gotKey string
s3.CreateDirectoryMarkerFn = func(_ context.Context, _, key string) (*models.ObjectUploadResponse, error) {
gotKey = key
return &models.ObjectUploadResponse{Key: key}, nil
}
body := bytes.NewBufferString(`{"key": "///already/"}`)
req := httptest.NewRequest(http.MethodPost, "/buckets/b1/directories", body)
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
t.Fatalf("status = %d, want 201", resp.StatusCode)
}
if gotKey != "already/" {
t.Errorf("service key = %q, want 'already/'", gotKey)
}
}
func TestCreateDirectory_MissingKey400(t *testing.T) {
app, _ := newObjectsTestApp(t)
body := bytes.NewBufferString(`{"key": ""}`)
req := httptest.NewRequest(http.MethodPost, "/buckets/b1/directories", body)
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", resp.StatusCode)
}
}
func TestCreateDirectory_MalformedJSON400(t *testing.T) {
app, _ := newObjectsTestApp(t)
req := httptest.NewRequest(http.MethodPost, "/buckets/b1/directories", bytes.NewBufferString("not json"))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", resp.StatusCode)
}
}
func TestCreateDirectory_ServiceError500(t *testing.T) {
app, s3 := newObjectsTestApp(t)
s3.CreateDirectoryMarkerFn = func(_ context.Context, _, _ string) (*models.ObjectUploadResponse, error) {
return nil, errors.New("boom")
}
body := bytes.NewBufferString(`{"key": "x/"}`)
req := httptest.NewRequest(http.MethodPost, "/buckets/b1/directories", body)
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusInternalServerError {
t.Fatalf("status = %d, want 500", resp.StatusCode)
}
}
+4 -4
View File
@@ -9,13 +9,13 @@ import (
"github.com/gofiber/fiber/v3"
)
// UserHandler handles user/key management operations using Garage Admin API
// UserHandler handles user and access key HTTP requests.
type UserHandler struct {
adminService *services.GarageAdminService
adminService services.AdminService
}
// NewUserHandler creates a new user handler
func NewUserHandler(adminService *services.GarageAdminService) *UserHandler {
// NewUserHandler creates a new user handler.
func NewUserHandler(adminService services.AdminService) *UserHandler {
return &UserHandler{
adminService: adminService,
}
+367
View File
@@ -0,0 +1,367 @@
package handlers
import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"Noooste/garage-ui/internal/models"
"Noooste/garage-ui/internal/services/mocks"
"github.com/gofiber/fiber/v3"
)
func newUsersTestApp(t *testing.T) (*fiber.App, *mocks.AdminMock) {
t.Helper()
admin := &mocks.AdminMock{}
h := NewUserHandler(admin)
app := fiber.New()
app.Get("/users", h.ListUsers)
app.Post("/users", h.CreateUser)
app.Get("/users/:access_key", h.GetUser)
app.Get("/users/:access_key/secret", h.GetUserSecretKey)
app.Delete("/users/:access_key", h.DeleteUser)
app.Patch("/users/:access_key", h.UpdateUserPermissions)
return app, admin
}
// --- ListUsers ---
func TestListUsers_MapsAndSkipsFailed(t *testing.T) {
app, admin := newUsersTestApp(t)
admin.ListKeysFn = func(_ context.Context) ([]models.ListKeysResponseItem, error) {
return []models.ListKeysResponseItem{
{ID: "AKIA-1", Name: "one"},
{ID: "AKIA-2", Name: "bad-detail"},
{ID: "AKIA-3", Name: "three"},
}, nil
}
admin.GetKeyInfoFn = func(_ context.Context, id string, showSecret bool) (*models.GarageKeyInfo, error) {
if showSecret {
t.Error("ListUsers must not request secret")
}
if id == "AKIA-2" {
return nil, errors.New("forbidden")
}
return &models.GarageKeyInfo{AccessKeyID: id, Name: id, Expired: false}, nil
}
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/users", nil))
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d", resp.StatusCode)
}
var body struct {
Data models.UserListResponse `json:"data"`
}
decodeJSON(t, resp.Body, &body)
if body.Data.Count != 2 {
t.Errorf("count = %d, want 2 (failed detail skipped)", body.Data.Count)
}
}
func TestListUsers_ListError500(t *testing.T) {
app, admin := newUsersTestApp(t)
admin.ListKeysFn = func(_ context.Context) ([]models.ListKeysResponseItem, error) {
return nil, errors.New("boom")
}
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/users", nil))
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusInternalServerError {
t.Fatalf("status = %d, want 500", resp.StatusCode)
}
}
// --- CreateUser ---
func TestCreateUser_Success201(t *testing.T) {
app, admin := newUsersTestApp(t)
admin.CreateKeyFn = func(_ context.Context, req models.CreateKeyRequest) (*models.GarageKeyInfo, error) {
if req.Name == nil || *req.Name != "alice" {
t.Errorf("Name = %v", req.Name)
}
sk := "secret-xyz"
return &models.GarageKeyInfo{AccessKeyID: "AKIA-1", Name: "alice", SecretAccessKey: &sk}, nil
}
body, _ := json.Marshal(models.CreateUserRequest{Name: "alice"})
req := httptest.NewRequest(http.MethodPost, "/users", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
t.Fatalf("status = %d, want 201", resp.StatusCode)
}
var decoded struct {
Data models.UserInfo `json:"data"`
}
decodeJSON(t, resp.Body, &decoded)
if decoded.Data.SecretKey == nil || *decoded.Data.SecretKey != "secret-xyz" {
t.Errorf("SecretKey = %v, want secret-xyz", decoded.Data.SecretKey)
}
}
func TestCreateUser_MalformedJSON400(t *testing.T) {
app, _ := newUsersTestApp(t)
req := httptest.NewRequest(http.MethodPost, "/users", strings.NewReader("{not-json"))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", resp.StatusCode)
}
}
func TestCreateUser_AdminError500(t *testing.T) {
app, admin := newUsersTestApp(t)
admin.CreateKeyFn = func(_ context.Context, _ models.CreateKeyRequest) (*models.GarageKeyInfo, error) {
return nil, errors.New("boom")
}
body, _ := json.Marshal(models.CreateUserRequest{})
req := httptest.NewRequest(http.MethodPost, "/users", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusInternalServerError {
t.Fatalf("status = %d, want 500", resp.StatusCode)
}
}
// --- GetUser ---
func TestGetUser_Success(t *testing.T) {
app, admin := newUsersTestApp(t)
admin.GetKeyInfoFn = func(_ context.Context, id string, showSecret bool) (*models.GarageKeyInfo, error) {
if showSecret {
t.Error("GetUser must not request secret")
}
return &models.GarageKeyInfo{AccessKeyID: id, Name: "alice"}, nil
}
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/users/AKIA-1", nil))
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d", resp.StatusCode)
}
}
func TestGetUser_ServiceError500(t *testing.T) {
app, admin := newUsersTestApp(t)
admin.GetKeyInfoFn = func(_ context.Context, _ string, _ bool) (*models.GarageKeyInfo, error) {
return nil, errors.New("boom")
}
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/users/AKIA-1", nil))
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusInternalServerError {
t.Fatalf("status = %d, want 500", resp.StatusCode)
}
}
// --- GetUserSecretKey ---
func TestGetUserSecretKey_Success(t *testing.T) {
app, admin := newUsersTestApp(t)
admin.GetKeyInfoFn = func(_ context.Context, id string, showSecret bool) (*models.GarageKeyInfo, error) {
if !showSecret {
t.Error("GetUserSecretKey must request secret")
}
sk := "s3cr3t"
return &models.GarageKeyInfo{AccessKeyID: id, SecretAccessKey: &sk}, nil
}
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/users/AKIA-1/secret", nil))
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d", resp.StatusCode)
}
var body struct {
Data map[string]string `json:"data"`
}
decodeJSON(t, resp.Body, &body)
if body.Data["secretKey"] != "s3cr3t" {
t.Errorf("secretKey = %q", body.Data["secretKey"])
}
}
// --- DeleteUser ---
func TestDeleteUser_Success(t *testing.T) {
app, admin := newUsersTestApp(t)
admin.DeleteKeyFn = func(_ context.Context, id string) error {
if id != "AKIA-1" {
t.Errorf("id = %q", id)
}
return nil
}
resp, err := app.Test(httptest.NewRequest(http.MethodDelete, "/users/AKIA-1", nil))
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d", resp.StatusCode)
}
}
func TestDeleteUser_ServiceError500(t *testing.T) {
app, admin := newUsersTestApp(t)
admin.DeleteKeyFn = func(_ context.Context, _ string) error { return errors.New("boom") }
resp, err := app.Test(httptest.NewRequest(http.MethodDelete, "/users/AKIA-1", nil))
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusInternalServerError {
t.Fatalf("status = %d, want 500", resp.StatusCode)
}
}
// --- UpdateUserPermissions ---
func TestUpdateUser_StatusInactiveSetsPastExpiration(t *testing.T) {
app, admin := newUsersTestApp(t)
admin.UpdateKeyFn = func(_ context.Context, _ string, req models.UpdateKeyRequest) (*models.GarageKeyInfo, error) {
if req.NeverExpires {
t.Error("NeverExpires should be false when deactivating")
}
if req.Expiration == nil || !req.Expiration.Before(time.Now()) {
t.Errorf("Expiration = %v, want past time", req.Expiration)
}
return &models.GarageKeyInfo{}, nil
}
status := "inactive"
body, _ := json.Marshal(models.UpdateUserRequest{Status: &status})
req := httptest.NewRequest(http.MethodPatch, "/users/AKIA-1", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d", resp.StatusCode)
}
}
func TestUpdateUser_StatusActiveSetsNeverExpires(t *testing.T) {
app, admin := newUsersTestApp(t)
admin.UpdateKeyFn = func(_ context.Context, _ string, req models.UpdateKeyRequest) (*models.GarageKeyInfo, error) {
if !req.NeverExpires {
t.Error("NeverExpires should be true when activating")
}
return &models.GarageKeyInfo{}, nil
}
status := "active"
body, _ := json.Marshal(models.UpdateUserRequest{Status: &status})
req := httptest.NewRequest(http.MethodPatch, "/users/AKIA-1", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d", resp.StatusCode)
}
}
func TestUpdateUser_ExplicitExpiration(t *testing.T) {
app, admin := newUsersTestApp(t)
wantTime, _ := time.Parse(time.RFC3339, "2030-01-02T03:04:05Z")
admin.UpdateKeyFn = func(_ context.Context, _ string, req models.UpdateKeyRequest) (*models.GarageKeyInfo, error) {
if req.Expiration == nil || !req.Expiration.Equal(wantTime) {
t.Errorf("Expiration = %v, want %v", req.Expiration, wantTime)
}
if req.NeverExpires {
t.Error("NeverExpires should be false with explicit expiration")
}
return &models.GarageKeyInfo{}, nil
}
exp := "2030-01-02T03:04:05Z"
body, _ := json.Marshal(models.UpdateUserRequest{Expiration: &exp})
req := httptest.NewRequest(http.MethodPatch, "/users/AKIA-1", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d", resp.StatusCode)
}
}
func TestUpdateUser_BadExpirationFormat400(t *testing.T) {
app, _ := newUsersTestApp(t)
exp := "not-a-date"
body, _ := json.Marshal(models.UpdateUserRequest{Expiration: &exp})
req := httptest.NewRequest(http.MethodPatch, "/users/AKIA-1", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", resp.StatusCode)
}
}
func TestUpdateUser_MalformedJSON400(t *testing.T) {
app, _ := newUsersTestApp(t)
req := httptest.NewRequest(http.MethodPatch, "/users/AKIA-1", strings.NewReader("{not-json"))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", resp.StatusCode)
}
}
func TestUpdateUser_AdminError500(t *testing.T) {
app, admin := newUsersTestApp(t)
admin.UpdateKeyFn = func(_ context.Context, _ string, _ models.UpdateKeyRequest) (*models.GarageKeyInfo, error) {
return nil, errors.New("boom")
}
status := "active"
body, _ := json.Marshal(models.UpdateUserRequest{Status: &status})
req := httptest.NewRequest(http.MethodPatch, "/users/AKIA-1", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusInternalServerError {
t.Fatalf("status = %d, want 500", resp.StatusCode)
}
}
+46 -48
View File
@@ -1,101 +1,99 @@
package middleware
import (
"strings"
"Noooste/garage-ui/internal/auth"
"Noooste/garage-ui/internal/config"
"Noooste/garage-ui/internal/models"
logpkg "Noooste/garage-ui/pkg/logger"
"github.com/gofiber/fiber/v3"
)
// AuthMiddleware supports admin and OIDC authentication
func AuthMiddleware(cfg *config.AuthConfig, authService *auth.AuthService) fiber.Handler {
// AuthMiddleware supports admin and OIDC authentication. On success it
// enriches the per-request logger (stored in c.Context() by the Logging
// middleware) with user_id and auth_method so downstream service log lines
// carry user identity. On failure it emits a warn log with the auth_method
// tried and a reason — never the token value.
func AuthMiddleware(cfg *config.AuthConfig, authService *auth.Service) fiber.Handler {
return func(c fiber.Ctx) error {
// If no auth is enabled, allow all requests
if !cfg.Admin.Enabled && !cfg.OIDC.Enabled {
// If no auth is enabled, allow all requests.
if !cfg.Admin.Enabled && !cfg.OIDC.Enabled && !cfg.Token.Enabled {
return c.Next()
}
// Get Authorization header
authHeader := c.Get("Authorization")
// Try admin auth if enabled and header is present
if cfg.Admin.Enabled && authHeader != "" {
// Check if it's a Bearer token (JWT from admin login)
// Try bearer token auth (works for admin, token, or any JWT session)
if (cfg.Admin.Enabled || cfg.Token.Enabled) && authHeader != "" {
if len(authHeader) > 7 && authHeader[:7] == "Bearer " {
token := authHeader[7:]
// Validate JWT session token
userInfo, err := authService.ValidateSessionToken(token)
if err == nil {
// Valid admin token
c.Locals("userInfo", userInfo)
c.Locals("username", userInfo.Username)
if userInfo.Email != "" {
c.Locals("email", userInfo.Email)
}
enrichRequestLogger(c, userInfo.Username, "admin")
return c.Next()
}
}
}
// Try OIDC auth if enabled
// Try OIDC auth if enabled.
if cfg.OIDC.Enabled {
sessionCookie := c.Cookies(cfg.OIDC.CookieName)
if sessionCookie != "" {
// Validate JWT session token from cookie
userInfo, err := authService.ValidateSessionToken(sessionCookie)
if err == nil {
// Valid OIDC token
c.Locals("userInfo", userInfo)
c.Locals("username", userInfo.Username)
c.Locals("email", userInfo.Email)
enrichRequestLogger(c, userInfo.Username, "oidc")
return c.Next()
}
}
}
// No valid authentication found
// Auth failed — log at warn without exposing token material.
logpkg.FromCtx(c.Context()).Warn().
Str("auth_method", authMethodsEnabled(cfg)).
Str("reason", "no_valid_credentials").
Msg("authentication_failed")
return c.Status(fiber.StatusUnauthorized).JSON(
models.ErrorResponse(models.ErrCodeUnauthorized, "Authentication required"),
)
}
}
func RequireAuth(cfg *config.AuthConfig) fiber.Handler {
return func(c fiber.Ctx) error {
if !cfg.Admin.Enabled && !cfg.OIDC.Enabled {
return c.Status(fiber.StatusForbidden).JSON(
models.ErrorResponse(models.ErrCodeForbidden, "Authentication is required but not configured"),
)
}
return c.Next()
}
// enrichRequestLogger rebinds the per-request logger in c.Context() with
// user_id and auth_method. Subsequent logpkg.FromCtx(c.Context()) calls
// return the enriched logger.
func enrichRequestLogger(c fiber.Ctx, userID, authMethod string) {
l := logpkg.FromCtx(c.Context()).With().
Str("user_id", userID).
Str("auth_method", authMethod).
Logger()
c.Locals(LoggerLocalsKey, l)
c.SetContext(logpkg.IntoCtx(c.Context(), l))
}
func RequireAdmin(authService *auth.AuthService) fiber.Handler {
return func(c fiber.Ctx) error {
userInfoInterface := c.Locals("userInfo")
if userInfoInterface == nil {
return c.Status(fiber.StatusForbidden).JSON(
models.ErrorResponse(models.ErrCodeForbidden, "Admin access required"),
)
}
userInfo, ok := userInfoInterface.(*auth.UserInfo)
if !ok {
return c.Status(fiber.StatusForbidden).JSON(
models.ErrorResponse(models.ErrCodeForbidden, "Admin access required"),
)
}
// Check if user has admin role
if !authService.IsAdmin(userInfo) {
return c.Status(fiber.StatusForbidden).JSON(
models.ErrorResponse(models.ErrCodeForbidden, "Admin role required"),
)
}
return c.Next()
func authMethodsEnabled(cfg *config.AuthConfig) string {
methods := []string{}
if cfg.Admin.Enabled {
methods = append(methods, "admin")
}
if cfg.OIDC.Enabled {
methods = append(methods, "oidc")
}
if cfg.Token.Enabled {
methods = append(methods, "token")
}
if len(methods) == 0 {
return "none"
}
return strings.Join(methods, "+")
}
+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)
}
}
@@ -1,10 +1,9 @@
package models
import "time"
// ====================================
// Access Key Models
// ====================================
import (
"encoding/json"
"time"
)
// GarageKeyInfo represents detailed information about a Garage access key
type GarageKeyInfo struct {
@@ -72,10 +71,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 +148,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 +164,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 +171,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"`
@@ -200,6 +183,24 @@ type ClusterHealth struct {
PartitionsAllOk int `json:"partitionsAllOk"`
}
// UnmarshalJSON handles both "storageNodesOk" (Garage v2.0.0) and
// "storageNodesUp" (Garage v2.1.0+, v1.x) field names.
func (h *ClusterHealth) UnmarshalJSON(data []byte) error {
type plain ClusterHealth
var aux struct {
plain
StorageNodesOk int `json:"storageNodesOk"`
}
if err := json.Unmarshal(data, &aux); err != nil {
return err
}
*h = ClusterHealth(aux.plain)
if h.StorageNodesUp == 0 && aux.StorageNodesOk != 0 {
h.StorageNodesUp = aux.StorageNodesOk
}
return nil
}
// ClusterStatus represents the current status of the cluster
type ClusterStatus struct {
LayoutVersion int `json:"layoutVersion"`
@@ -211,10 +212,6 @@ type ClusterStatistics struct {
Freeform string `json:"freeform"`
}
// ====================================
// Node Models
// ====================================
// NodeInfo represents information about a cluster node
type NodeInfo struct {
ID string `json:"id"`
@@ -0,0 +1,46 @@
package models
import (
"encoding/json"
"testing"
)
func TestClusterHealth_StorageNodesOk_BackCompat(t *testing.T) {
raw := `{
"status": "healthy",
"knownNodes": 3,
"connectedNodes": 3,
"storageNodes": 3,
"storageNodesOk": 3,
"partitions": 256,
"partitionsQuorum": 256,
"partitionsAllOk": 256
}`
var h ClusterHealth
if err := json.Unmarshal([]byte(raw), &h); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if h.StorageNodesUp != 3 {
t.Fatalf("expected StorageNodesUp=3, got %d", h.StorageNodesUp)
}
}
func TestClusterHealth_StorageNodesUp(t *testing.T) {
raw := `{
"status": "healthy",
"knownNodes": 3,
"connectedNodes": 3,
"storageNodes": 3,
"storageNodesUp": 3,
"partitions": 256,
"partitionsQuorum": 256,
"partitionsAllOk": 256
}`
var h ClusterHealth
if err := json.Unmarshal([]byte(raw), &h); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if h.StorageNodesUp != 3 {
t.Fatalf("expected StorageNodesUp=3, got %d", h.StorageNodesUp)
}
}
+15 -37
View File
@@ -12,50 +12,28 @@ 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"`
}
// UpdateBucketQuotasRequest represents a request to update bucket quota settings.
// A nil field means "clear this quota" (unlimited). A non-nil field must be > 0;
// Garage rejects 0.
type UpdateBucketQuotasRequest struct {
MaxSize *int64 `json:"maxSize,omitempty"`
MaxObjects *int64 `json:"maxObjects,omitempty"`
}
+9 -12
View File
@@ -40,11 +40,14 @@ type HealthResponse struct {
// BucketInfo represents information about a bucket
type BucketInfo struct {
Name string `json:"name"`
CreationDate time.Time `json:"creationDate"`
ObjectCount *int64 `json:"objectCount,omitempty"`
Size *int64 `json:"size,omitempty"`
Region string `json:"region,omitempty"`
Name string `json:"name"`
CreationDate time.Time `json:"creationDate"`
ObjectCount *int64 `json:"objectCount,omitempty"`
Size *int64 `json:"size,omitempty"`
Region string `json:"region,omitempty"`
WebsiteAccess bool `json:"websiteAccess"`
WebsiteConfig *BucketWebsiteConfig `json:"websiteConfig,omitempty"`
Quotas *BucketQuotas `json:"quotas,omitempty"`
}
// BucketListResponse represents a list of buckets
@@ -136,13 +139,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 +197,5 @@ const (
ErrCodeUploadFailed = "UPLOAD_FAILED"
ErrCodeDeleteFailed = "DELETE_FAILED"
ErrCodeListFailed = "LIST_FAILED"
ErrCodeUnsupported = "UNSUPPORTED"
)
+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)
}
}
+71 -51
View File
@@ -5,7 +5,7 @@ import (
"Noooste/garage-ui/internal/config"
"Noooste/garage-ui/internal/handlers"
"Noooste/garage-ui/internal/middleware"
"fmt"
"Noooste/garage-ui/pkg/logger"
"net/url"
"os"
"path/filepath"
@@ -22,13 +22,14 @@ import (
func SetupRoutes(
app *fiber.App,
cfg *config.Config,
authService *auth.AuthService,
authService *auth.Service,
healthHandler *handlers.HealthHandler,
bucketHandler *handlers.BucketHandler,
objectHandler *handlers.ObjectHandler,
userHandler *handlers.UserHandler,
clusterHandler *handlers.ClusterHandler,
monitoringHandler *handlers.MonitoringHandler,
capabilitiesHandler *handlers.CapabilitiesHandler,
) {
// Apply CORS middleware globally
app.Use(middleware.CORSMiddleware(&cfg.CORS))
@@ -52,6 +53,8 @@ func SetupRoutes(
// Apply authentication middleware to all API routes
api.Use(middleware.AuthMiddleware(&cfg.Auth, authService))
api.Get("/capabilities", capabilitiesHandler.GetCapabilities)
// Bucket routes
buckets := api.Group("/buckets")
{
@@ -60,6 +63,8 @@ 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
buckets.Put("/:name/quotas", bucketHandler.UpdateBucketQuotas) // Update bucket quotas
}
// Object routes
@@ -71,64 +76,41 @@ func SetupRoutes(
objects.Post("/delete-multiple", objectHandler.DeleteMultipleObjects) // Delete multiple objects
}
// Object-specific routes with wildcard key parameter (supports paths with slashes)
// These need to be registered on the main app with auth middleware applied
// Directory routes (zero-byte directory markers)
api.Post("/buckets/:bucket/directories", objectHandler.CreateDirectory)
// Fiber v3 does not auto-decode wildcard params; fall back to the raw
// value when QueryUnescape fails.
decodeObjectKey := func(c fiber.Ctx) string {
raw := c.Params("*")
if decoded, err := url.QueryUnescape(raw); err == nil {
return decoded
}
return raw
}
objectWildcardHandler := func(c fiber.Ctx) error {
// Get the full path from wildcard parameter
// Note: Fiber v3 does NOT automatically decode params, we need to do it manually
path := c.Params("*")
// Decode the full path using QueryUnescape (handles %20, %2F, etc.)
decodedPath, err := url.QueryUnescape(path)
if err != nil {
// If decoding fails, use the original path
decodedPath = path
}
// Check if it's a metadata request
if strings.HasSuffix(decodedPath, "/metadata") {
// Remove /metadata suffix to get the actual key
key := strings.TrimSuffix(decodedPath, "/metadata")
c.Locals("objectKey", key)
path := decodeObjectKey(c)
switch {
case strings.HasSuffix(path, "/metadata"):
c.Locals("objectKey", strings.TrimSuffix(path, "/metadata"))
return objectHandler.GetObjectMetadata(c)
}
// Check if it's a presign request
if strings.HasSuffix(decodedPath, "/presign") {
// Remove /presign suffix to get the actual key
key := strings.TrimSuffix(decodedPath, "/presign")
c.Locals("objectKey", key)
case strings.HasSuffix(path, "/presign"):
c.Locals("objectKey", strings.TrimSuffix(path, "/presign"))
return objectHandler.GetPresignedURL(c)
default:
c.Locals("objectKey", path)
return objectHandler.GetObject(c)
}
// Otherwise, it's a regular object download
c.Locals("objectKey", decodedPath)
return objectHandler.GetObject(c)
}
objectDeleteHandler := func(c fiber.Ctx) error {
path := c.Params("*")
// Decode the full path using QueryUnescape
key, err := url.QueryUnescape(path)
if err != nil {
// If decoding fails, use the original path
key = path
}
c.Locals("objectKey", key)
c.Locals("objectKey", decodeObjectKey(c))
return objectHandler.DeleteObject(c)
}
objectHeadHandler := func(c fiber.Ctx) error {
path := c.Params("*")
// Decode the full path using QueryUnescape
key, err := url.QueryUnescape(path)
if err != nil {
// If decoding fails, use the original path
key = path
}
c.Locals("objectKey", key)
c.Locals("objectKey", decodeObjectKey(c))
return objectHandler.GetObjectMetadata(c)
}
@@ -171,8 +153,13 @@ func SetupRoutes(
app.Post("/auth/login", authHandler.LoginAdmin)
}
// Token auth login endpoint (only if token auth is enabled)
if cfg.Auth.Token.Enabled {
app.Post("/auth/login-token", authHandler.LoginToken)
}
// Auth "me" endpoint (if any auth is enabled)
if cfg.Auth.Admin.Enabled || cfg.Auth.OIDC.Enabled {
if cfg.Auth.Admin.Enabled || cfg.Auth.OIDC.Enabled || cfg.Auth.Token.Enabled {
app.Get("/auth/me", middleware.AuthMiddleware(&cfg.Auth, authService), authHandler.GetMe)
}
@@ -225,6 +212,11 @@ func SetupRoutes(
})
}
logger.Debug().
Str("access_token", token.AccessToken).
Interface("token", token).
Msg("Exchanged authorization code for token")
// Extract ID token from OAuth2 token
rawIDToken, ok := token.Extra("id_token").(string)
if !ok {
@@ -241,6 +233,34 @@ 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.
adminRoles := cfg.Auth.OIDC.EffectiveAdminRoles()
if len(adminRoles) > 0 {
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).
Strs("required_roles", adminRoles).
Strs("roles", userInfo.Roles).
Msg("OIDC login denied: user does not have any required admin role")
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{
"error": "User does not have the required admin role",
})
}
}
// Generate JWT session token
sessionToken, err := authService.GenerateSessionToken(userInfo)
if err != nil {
@@ -292,7 +312,7 @@ func SetupRoutes(
strings.HasPrefix(path, "/auth") ||
strings.HasPrefix(path, "/health") ||
strings.HasPrefix(path, "/docs") {
fmt.Println("API or health check route, skipping SPA fallback:", path)
logger.Debug().Str("path", path).Msg("API or health check route, skipping SPA fallback")
return c.Next()
}
+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)
}
+606
View File
@@ -0,0 +1,606 @@
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"
"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),
handlers.NewCapabilitiesHandler("v2", services.CapabilitiesV2()),
)
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)
}
@@ -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 GarageV2AdminService pointed at a test server
// and returns it with a cleanup hook.
func newAdminWithServer(t *testing.T, handler http.HandlerFunc) *GarageV2AdminService {
t.Helper()
srv := httptest.NewServer(handler)
t.Cleanup(srv.Close)
cfg := &config.GarageConfig{
AdminEndpoint: srv.URL,
AdminToken: "test-token",
}
return NewGarageV2AdminService(cfg, "info")
}
// ctxWithBufferLogger attaches a zerolog.Logger writing to buf onto ctx.
func ctxWithBufferLogger(buf *bytes.Buffer) context.Context {
l := zerolog.New(buf)
return logpkg.IntoCtx(context.Background(), l)
}
func parseJSONLines(t *testing.T, s string) []map[string]any {
t.Helper()
out := []map[string]any{}
for _, line := range strings.Split(s, "\n") {
if strings.TrimSpace(line) == "" {
continue
}
var m map[string]any
if err := json.Unmarshal([]byte(line), &m); err != nil {
t.Fatalf("not JSON: %v — %s", err, line)
}
out = append(out, m)
}
return out
}
func TestCreateBucket_SuccessLogsInfoWithFields(t *testing.T) {
admin := newAdminWithServer(t, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(models.GarageBucketInfo{ID: "bkt-123"})
})
var buf bytes.Buffer
ctx := ctxWithBufferLogger(&buf)
alias := "my-bucket"
if _, err := admin.CreateBucket(ctx, models.CreateBucketAdminRequest{GlobalAlias: &alias}); err != nil {
t.Fatalf("CreateBucket: %v", err)
}
lines := parseJSONLines(t, buf.String())
if len(lines) < 2 {
t.Fatalf("expected >=2 lines (start + success), got %d: %s", len(lines), buf.String())
}
final := lines[len(lines)-1]
if final["component"] != "admin" {
t.Errorf("component = %v, want admin", final["component"])
}
if final["operation"] != "create_bucket" {
t.Errorf("operation = %v, want create_bucket", final["operation"])
}
if final["outcome"] != "success" {
t.Errorf("outcome = %v, want success", final["outcome"])
}
if final["level"] != "info" {
t.Errorf("level = %v, want info", final["level"])
}
if _, ok := final["duration_ms"].(float64); !ok {
t.Errorf("missing duration_ms: %v", final)
}
if final["bucket_id"] != "bkt-123" {
t.Errorf("bucket_id = %v, want bkt-123", final["bucket_id"])
}
}
func TestCreateBucket_FailureLogsError(t *testing.T) {
admin := newAdminWithServer(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusConflict)
_, _ = w.Write([]byte(`{"error":"already exists"}`))
})
var buf bytes.Buffer
ctx := ctxWithBufferLogger(&buf)
alias := "duplicate"
_, err := admin.CreateBucket(ctx, models.CreateBucketAdminRequest{GlobalAlias: &alias})
if err == nil {
t.Fatal("expected error, got nil")
}
lines := parseJSONLines(t, buf.String())
var failure map[string]any
for _, line := range lines {
if line["level"] == "error" {
failure = line
break
}
}
if failure == nil {
t.Fatalf("no error-level log line: %s", buf.String())
}
if failure["outcome"] != "failure" {
t.Errorf("outcome = %v, want failure", failure["outcome"])
}
if failure["operation"] != "create_bucket" {
t.Errorf("operation = %v, want create_bucket", failure["operation"])
}
if _, ok := failure["error"].(string); !ok {
t.Errorf("missing error field: %v", failure)
}
if _, ok := failure["duration_ms"].(float64); !ok {
t.Errorf("missing duration_ms: %v", failure)
}
}
@@ -0,0 +1,93 @@
package services
import (
"context"
"errors"
"fmt"
"net/http"
"time"
"Noooste/garage-ui/internal/config"
"Noooste/garage-ui/pkg/logger"
"github.com/Noooste/azuretls-client"
)
var ErrUnsupported = errors.New("operation not supported by this Garage version")
type Capabilities struct {
ClusterStatistics bool `json:"clusterStatistics"`
NodeInfo bool `json:"nodeInfo"`
NodeStatistics bool `json:"nodeStatistics"`
}
func CapabilitiesV2() Capabilities {
return Capabilities{
ClusterStatistics: true,
NodeInfo: true,
NodeStatistics: true,
}
}
func CapabilitiesV1() Capabilities {
return Capabilities{}
}
type AdminServiceResult struct {
Service AdminService
Capabilities Capabilities
APIVersion string
}
func NewAdminService(cfg *config.GarageConfig, logLevel string) (*AdminServiceResult, error) {
if err := probeEndpoint(cfg, "/v2/GetClusterHealth"); err == nil {
logger.Info().Str("api_version", "v2").Msg("Detected Garage admin API v2")
svc := NewGarageV2AdminService(cfg, logLevel)
return &AdminServiceResult{
Service: svc,
Capabilities: CapabilitiesV2(),
APIVersion: "v2",
}, nil
}
if err := probeEndpoint(cfg, "/v1/health"); err == nil {
logger.Info().
Str("api_version", "v1").
Msg("Detected Garage admin API v1 — cluster statistics and per-node details will be unavailable")
svc := NewGarageV1AdminService(cfg, logLevel)
return &AdminServiceResult{
Service: svc,
Capabilities: CapabilitiesV1(),
APIVersion: "v1",
}, nil
}
return nil, fmt.Errorf(
"could not connect to Garage admin API at %s. Ensure Garage v1.1+ is running and the admin API is enabled",
cfg.AdminEndpoint,
)
}
func probeEndpoint(cfg *config.GarageConfig, path string) error {
session := azuretls.NewSession()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
resp, err := session.Do(&azuretls.Request{
Method: http.MethodGet,
Url: cfg.AdminEndpoint + path,
IgnoreBody: true,
OrderedHeaders: azuretls.OrderedHeaders{
{"Authorization", fmt.Sprintf("Bearer %s", cfg.AdminToken)},
},
}, ctx)
if err != nil {
return err
}
defer resp.RawBody.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("probe %s returned status %d", path, resp.StatusCode)
}
return nil
}
@@ -0,0 +1,91 @@
package services
import (
"errors"
"net/http"
"net/http/httptest"
"testing"
"Noooste/garage-ui/internal/config"
)
func TestErrUnsupportedIsSentinel(t *testing.T) {
err := ErrUnsupported
if !errors.Is(err, ErrUnsupported) {
t.Fatal("ErrUnsupported should match itself via errors.Is")
}
}
func TestCapabilitiesV2AllTrue(t *testing.T) {
caps := CapabilitiesV2()
if !caps.ClusterStatistics || !caps.NodeInfo || !caps.NodeStatistics {
t.Fatalf("v2 capabilities should all be true, got %+v", caps)
}
}
func TestCapabilitiesV1AllFalse(t *testing.T) {
caps := CapabilitiesV1()
if caps.ClusterStatistics || caps.NodeInfo || caps.NodeStatistics {
t.Fatalf("v1 capabilities should all be false, got %+v", caps)
}
}
func TestDetectVersion_V2(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/v2/GetClusterHealth" {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"status":"healthy"}`))
return
}
w.WriteHeader(404)
}))
t.Cleanup(srv.Close)
cfg := &config.GarageConfig{AdminEndpoint: srv.URL, AdminToken: "tok"}
result, err := NewAdminService(cfg, "")
if err != nil {
t.Fatal(err)
}
if result.APIVersion != "v2" {
t.Fatalf("expected v2, got %s", result.APIVersion)
}
if !result.Capabilities.ClusterStatistics {
t.Fatal("v2 should have ClusterStatistics capability")
}
}
func TestDetectVersion_V1(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/v2/GetClusterHealth" {
w.WriteHeader(404)
return
}
if r.URL.Path == "/v1/health" {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"status":"healthy"}`))
return
}
w.WriteHeader(404)
}))
t.Cleanup(srv.Close)
cfg := &config.GarageConfig{AdminEndpoint: srv.URL, AdminToken: "tok"}
result, err := NewAdminService(cfg, "")
if err != nil {
t.Fatal(err)
}
if result.APIVersion != "v1" {
t.Fatalf("expected v1, got %s", result.APIVersion)
}
if result.Capabilities.ClusterStatistics {
t.Fatal("v1 should not have ClusterStatistics capability")
}
}
func TestDetectVersion_Unreachable(t *testing.T) {
cfg := &config.GarageConfig{AdminEndpoint: "http://127.0.0.1:1", AdminToken: "tok"}
_, err := NewAdminService(cfg, "")
if err == nil {
t.Fatal("expected error for unreachable server")
}
}
+375
View File
@@ -0,0 +1,375 @@
package services
import (
"Noooste/garage-ui/internal/config"
"Noooste/garage-ui/internal/models"
"Noooste/garage-ui/pkg/utils"
logpkg "Noooste/garage-ui/pkg/logger"
"context"
"fmt"
"io"
"net/http"
"time"
"github.com/Noooste/azuretls-client"
)
type GarageV1AdminService struct {
baseURL string
token string
httpClient *azuretls.Session
}
func NewGarageV1AdminService(cfg *config.GarageConfig, logLevel string) *GarageV1AdminService {
session := azuretls.NewSession()
if logLevel == "debug" {
session.Log()
}
return &GarageV1AdminService{
baseURL: cfg.AdminEndpoint,
token: cfg.AdminToken,
httpClient: session,
}
}
func (s *GarageV1AdminService) doRequest(ctx context.Context, method, path string, body interface{}) (*azuretls.Response, error) {
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,
OrderedHeaders: azuretls.OrderedHeaders{
{"Authorization", fmt.Sprintf("Bearer %s", s.token)},
},
}, ctx)
return reqErr
})
if err != nil {
return nil, err
}
return resp, nil
}
func (s *GarageV1AdminService) ListKeys(ctx context.Context) ([]models.ListKeysResponseItem, error) {
resp, err := s.doRequest(ctx, http.MethodGet, "/v1/key?list=true", nil)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
var result []models.ListKeysResponseItem
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return result, nil
}
func (s *GarageV1AdminService) CreateKey(ctx context.Context, req models.CreateKeyRequest) (*models.GarageKeyInfo, error) {
resp, err := s.doRequest(ctx, http.MethodPost, "/v1/key?list", req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
var result models.GarageKeyInfo
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &result, nil
}
func (s *GarageV1AdminService) GetKeyInfo(ctx context.Context, keyID string, showSecret bool) (*models.GarageKeyInfo, error) {
path := fmt.Sprintf("/v1/key?id=%s", keyID)
if showSecret {
path += "&showSecretKey=true"
}
resp, err := s.doRequest(ctx, http.MethodGet, path, nil)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
var result models.GarageKeyInfo
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &result, nil
}
func (s *GarageV1AdminService) UpdateKey(ctx context.Context, keyID string, req models.UpdateKeyRequest) (*models.GarageKeyInfo, error) {
path := fmt.Sprintf("/v1/key?id=%s", keyID)
resp, err := s.doRequest(ctx, http.MethodPost, path, req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
var result models.GarageKeyInfo
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &result, nil
}
func (s *GarageV1AdminService) DeleteKey(ctx context.Context, keyID string) error {
path := fmt.Sprintf("/v1/key?id=%s", keyID)
resp, err := s.doRequest(ctx, http.MethodDelete, path, nil)
if err != nil {
return fmt.Errorf("request failed: %w", err)
}
if err := decodeResponse(resp, nil); err != nil {
return fmt.Errorf("failed to process response: %w", err)
}
return nil
}
func (s *GarageV1AdminService) ImportKey(ctx context.Context, req models.ImportKeyRequest) (*models.GarageKeyInfo, error) {
resp, err := s.doRequest(ctx, http.MethodPost, "/v1/key/import", req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
var result models.GarageKeyInfo
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &result, nil
}
func (s *GarageV1AdminService) ListBuckets(ctx context.Context) ([]models.ListBucketsResponseItem, error) {
log := logpkg.FromCtx(ctx).With().Str("component", "admin-v1").Str("operation", "list_buckets").Logger()
log.Debug().Msg("listing buckets")
start := time.Now()
resp, err := s.doRequest(ctx, http.MethodGet, "/v1/bucket?list", nil)
if err != nil {
log.Error().Err(err).Float64("duration_ms", msSince(start)).Msg("list_buckets request failed")
return nil, fmt.Errorf("request failed: %w", err)
}
var result []models.ListBucketsResponseItem
if err := decodeResponse(resp, &result); err != nil {
log.Error().Err(err).Float64("duration_ms", msSince(start)).Msg("list_buckets decode failed")
return nil, fmt.Errorf("failed to decode response: %w", err)
}
log.Debug().Float64("duration_ms", msSince(start)).Int("count", len(result)).Msg("listed buckets")
return result, nil
}
func (s *GarageV1AdminService) GetBucketInfo(ctx context.Context, bucketID string) (*models.GarageBucketInfo, error) {
resp, err := s.doRequest(ctx, http.MethodGet, fmt.Sprintf("/v1/bucket?id=%s", bucketID), nil)
if err != nil {
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)
}
return &result, nil
}
func (s *GarageV1AdminService) GetBucketInfoByAlias(ctx context.Context, globalAlias string) (*models.GarageBucketInfo, error) {
resp, err := s.doRequest(ctx, http.MethodGet, fmt.Sprintf("/v1/bucket?globalAlias=%s", globalAlias), nil)
if err != nil {
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)
}
return &result, nil
}
func (s *GarageV1AdminService) CreateBucket(ctx context.Context, req models.CreateBucketAdminRequest) (*models.GarageBucketInfo, error) {
resp, err := s.doRequest(ctx, http.MethodPost, "/v1/bucket", req)
if err != nil {
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)
}
return &result, nil
}
func (s *GarageV1AdminService) UpdateBucket(ctx context.Context, bucketID string, req models.UpdateBucketRequest) (*models.GarageBucketInfo, error) {
resp, err := s.doRequest(ctx, http.MethodPut, fmt.Sprintf("/v1/bucket?id=%s", bucketID), req)
if err != nil {
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)
}
return &result, nil
}
func (s *GarageV1AdminService) DeleteBucket(ctx context.Context, bucketID string) error {
resp, err := s.doRequest(ctx, http.MethodDelete, fmt.Sprintf("/v1/bucket?id=%s", bucketID), nil)
if err != nil {
return fmt.Errorf("request failed: %w", err)
}
if err := decodeResponse(resp, nil); err != nil {
return fmt.Errorf("failed to process response: %w", err)
}
return nil
}
func (s *GarageV1AdminService) AllowBucketKey(ctx context.Context, req models.BucketKeyPermRequest) (*models.GarageBucketInfo, error) {
resp, err := s.doRequest(ctx, http.MethodPost, "/v1/bucket/allow", req)
if err != nil {
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)
}
return &result, nil
}
func (s *GarageV1AdminService) DenyBucketKey(ctx context.Context, req models.BucketKeyPermRequest) (*models.GarageBucketInfo, error) {
resp, err := s.doRequest(ctx, http.MethodPost, "/v1/bucket/deny", req)
if err != nil {
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)
}
return &result, nil
}
func (s *GarageV1AdminService) AddBucketAlias(ctx context.Context, req models.AddBucketAliasRequest) (*models.GarageBucketInfo, error) {
var path string
if req.GlobalAlias != nil {
path = fmt.Sprintf("/v1/bucket/alias/global?id=%s&alias=%s", req.BucketID, *req.GlobalAlias)
} else if req.LocalAlias != nil && req.AccessKeyID != nil {
path = fmt.Sprintf("/v1/bucket/alias/local?id=%s&accessKeyId=%s&alias=%s", req.BucketID, *req.AccessKeyID, *req.LocalAlias)
} else {
return nil, fmt.Errorf("AddBucketAlias requires either globalAlias or localAlias+accessKeyId")
}
resp, err := s.doRequest(ctx, http.MethodPut, path, nil)
if err != nil {
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)
}
return &result, nil
}
func (s *GarageV1AdminService) RemoveBucketAlias(ctx context.Context, req models.RemoveBucketAliasRequest) (*models.GarageBucketInfo, error) {
var path string
if req.GlobalAlias != nil {
path = fmt.Sprintf("/v1/bucket/alias/global?id=%s&alias=%s", req.BucketID, *req.GlobalAlias)
} else if req.LocalAlias != nil && req.AccessKeyID != nil {
path = fmt.Sprintf("/v1/bucket/alias/local?id=%s&accessKeyId=%s&alias=%s", req.BucketID, *req.AccessKeyID, *req.LocalAlias)
} else {
return nil, fmt.Errorf("RemoveBucketAlias requires either globalAlias or localAlias+accessKeyId")
}
resp, err := s.doRequest(ctx, http.MethodDelete, path, nil)
if err != nil {
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)
}
return &result, nil
}
func (s *GarageV1AdminService) GetClusterHealth(ctx context.Context) (*models.ClusterHealth, error) {
resp, err := s.doRequest(ctx, http.MethodGet, "/v1/health", nil)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
var result models.ClusterHealth
if err := decodeResponse(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &result, nil
}
type v1StatusResponse struct {
Node string `json:"node"`
GarageVersion string `json:"garageVersion"`
KnownNodes []v1KnownNode `json:"knownNodes"`
Layout *v1Layout `json:"layout"`
}
type v1KnownNode struct {
ID string `json:"id"`
Addr string `json:"addr"`
IsUp bool `json:"isUp"`
LastSeenSecsAgo *int64 `json:"lastSeenSecsAgo"`
Hostname string `json:"hostname"`
}
type v1Layout struct {
Version int `json:"version"`
}
func (s *GarageV1AdminService) GetClusterStatus(ctx context.Context) (*models.ClusterStatus, error) {
resp, err := s.doRequest(ctx, http.MethodGet, "/v1/status", nil)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
var raw v1StatusResponse
if err := decodeResponse(resp, &raw); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
nodes := make([]models.NodeInfo, len(raw.KnownNodes))
for i, n := range raw.KnownNodes {
addr := n.Addr
hostname := n.Hostname
nodes[i] = models.NodeInfo{
ID: n.ID,
IsUp: n.IsUp,
LastSeenSecsAgo: n.LastSeenSecsAgo,
Hostname: &hostname,
Addr: &addr,
}
}
layoutVersion := 0
if raw.Layout != nil {
layoutVersion = raw.Layout.Version
}
return &models.ClusterStatus{
LayoutVersion: layoutVersion,
Nodes: nodes,
}, nil
}
func (s *GarageV1AdminService) GetClusterStatistics(ctx context.Context) (*models.ClusterStatistics, error) {
return nil, ErrUnsupported
}
func (s *GarageV1AdminService) GetNodeInfo(ctx context.Context, nodeID string) (*models.MultiNodeResponse, error) {
return nil, ErrUnsupported
}
func (s *GarageV1AdminService) GetNodeStatistics(ctx context.Context, nodeID string) (*models.MultiNodeResponse, error) {
return nil, ErrUnsupported
}
func (s *GarageV1AdminService) HealthCheck(ctx context.Context) error {
resp, err := s.doRequest(ctx, http.MethodGet, "/health", nil)
if err != nil {
return fmt.Errorf("health check failed: %w", err)
}
if err := decodeResponse(resp, nil); err != nil {
return fmt.Errorf("health check returned error: %w", err)
}
return nil
}
func (s *GarageV1AdminService) GetMetrics(ctx context.Context) (string, error) {
resp, err := s.doRequest(ctx, http.MethodGet, "/metrics", nil)
if err != nil {
return "", fmt.Errorf("request failed: %w", err)
}
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))
}
bodyBytes, err := io.ReadAll(resp.RawBody)
if err != nil {
return "", fmt.Errorf("failed to read response: %w", err)
}
return string(bodyBytes), nil
}
+635
View File
@@ -0,0 +1,635 @@
package services
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"Noooste/garage-ui/internal/config"
"Noooste/garage-ui/internal/models"
)
func newV1TestServer(t *testing.T, handler http.Handler) *GarageV1AdminService {
t.Helper()
srv := httptest.NewServer(handler)
t.Cleanup(srv.Close)
return NewGarageV1AdminService(&config.GarageConfig{
AdminEndpoint: srv.URL,
AdminToken: "test-token",
}, "")
}
func TestV1_ListKeys(t *testing.T) {
items := []models.ListKeysResponseItem{{ID: "GK1", Name: "key1"}}
svc := newV1TestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet || r.URL.Path != "/v1/key" || r.URL.Query().Get("list") == "" {
t.Errorf("unexpected request: %s %s", r.Method, r.URL)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(items)
}))
result, err := svc.ListKeys(context.Background())
if err != nil {
t.Fatal(err)
}
if len(result) != 1 || result[0].ID != "GK1" {
t.Fatalf("unexpected result: %+v", result)
}
}
func TestV1_GetClusterHealth(t *testing.T) {
svc := newV1TestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/health" {
t.Errorf("unexpected path: %s", r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"status":"healthy","knownNodes":3,"connectedNodes":3,"storageNodes":3,"storageNodesOk":3,"partitions":256,"partitionsQuorum":256,"partitionsAllOk":256}`))
}))
health, err := svc.GetClusterHealth(context.Background())
if err != nil {
t.Fatal(err)
}
if health.StorageNodesUp != 3 {
t.Fatalf("expected StorageNodesUp=3, got %d", health.StorageNodesUp)
}
}
func TestV1_GetClusterStatistics_Unsupported(t *testing.T) {
svc := newV1TestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("should not make any HTTP request for unsupported operations")
}))
_, err := svc.GetClusterStatistics(context.Background())
if !errors.Is(err, ErrUnsupported) {
t.Fatalf("expected ErrUnsupported, got %v", err)
}
}
func TestV1_GetNodeInfo_Unsupported(t *testing.T) {
svc := newV1TestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("should not make any HTTP request for unsupported operations")
}))
_, err := svc.GetNodeInfo(context.Background(), "abc")
if !errors.Is(err, ErrUnsupported) {
t.Fatalf("expected ErrUnsupported, got %v", err)
}
}
func TestV1_GetNodeStatistics_Unsupported(t *testing.T) {
svc := newV1TestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("should not make any HTTP request for unsupported operations")
}))
_, err := svc.GetNodeStatistics(context.Background(), "abc")
if !errors.Is(err, ErrUnsupported) {
t.Fatalf("expected ErrUnsupported, got %v", err)
}
}
// v1RecordingHandler returns a handler that records the request and responds with JSON.
func v1RecordingHandler(t *testing.T, status int, body 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, _ := io.ReadAll(r.Body)
rec.body = b
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if body != nil {
json.NewEncoder(w).Encode(body)
}
})
return h, rec
}
func newV1RecordingServer(t *testing.T, status int, body any) (*GarageV1AdminService, *recordedRequest) {
t.Helper()
h, rec := v1RecordingHandler(t, status, body)
srv := httptest.NewServer(h)
t.Cleanup(srv.Close)
svc := NewGarageV1AdminService(&config.GarageConfig{
AdminEndpoint: srv.URL,
AdminToken: "test-token",
}, "")
return svc, rec
}
func TestV1_CreateKey(t *testing.T) {
name := "mykey"
want := &models.GarageKeyInfo{AccessKeyID: "GK1", Name: name}
svc, rec := newV1RecordingServer(t, 200, want)
got, err := svc.CreateKey(context.Background(), models.CreateKeyRequest{Name: &name})
if err != nil {
t.Fatal(err)
}
if rec.method != http.MethodPost || rec.path != "/v1/key" {
t.Errorf("request = %s %s", rec.method, rec.path)
}
if got.AccessKeyID != "GK1" {
t.Errorf("AccessKeyID = %q, want GK1", got.AccessKeyID)
}
}
func TestV1_GetKeyInfo(t *testing.T) {
want := &models.GarageKeyInfo{AccessKeyID: "ABC"}
svc, rec := newV1RecordingServer(t, 200, want)
got, err := svc.GetKeyInfo(context.Background(), "ABC", true)
if err != nil {
t.Fatal(err)
}
if rec.method != http.MethodGet {
t.Errorf("method = %q, want GET", rec.method)
}
if !strings.Contains(rec.rawURL, "id=ABC") || !strings.Contains(rec.rawURL, "showSecretKey=true") {
t.Errorf("rawURL = %q, want id=ABC&showSecretKey=true", rec.rawURL)
}
if got.AccessKeyID != "ABC" {
t.Errorf("got %q, want ABC", got.AccessKeyID)
}
}
func TestV1_UpdateKey(t *testing.T) {
want := &models.GarageKeyInfo{AccessKeyID: "K1"}
svc, rec := newV1RecordingServer(t, 200, want)
_, err := svc.UpdateKey(context.Background(), "K1", models.UpdateKeyRequest{})
if err != nil {
t.Fatal(err)
}
if rec.method != http.MethodPost {
t.Errorf("method = %q, want POST", rec.method)
}
if !strings.Contains(rec.rawURL, "id=K1") {
t.Errorf("rawURL = %q, want id=K1", rec.rawURL)
}
}
func TestV1_DeleteKey(t *testing.T) {
svc, rec := newV1RecordingServer(t, 200, nil)
err := svc.DeleteKey(context.Background(), "K1")
if err != nil {
t.Fatal(err)
}
if rec.method != http.MethodDelete {
t.Errorf("method = %q, want DELETE", rec.method)
}
if !strings.Contains(rec.rawURL, "id=K1") {
t.Errorf("rawURL = %q, want id=K1", rec.rawURL)
}
}
func TestV1_ImportKey(t *testing.T) {
want := &models.GarageKeyInfo{AccessKeyID: "GKimported"}
svc, rec := newV1RecordingServer(t, 200, want)
got, err := svc.ImportKey(context.Background(), models.ImportKeyRequest{
AccessKeyID: "GKimported",
SecretAccessKey: "secret",
})
if err != nil {
t.Fatal(err)
}
if rec.method != http.MethodPost || rec.path != "/v1/key/import" {
t.Errorf("request = %s %s", rec.method, rec.path)
}
if got.AccessKeyID != "GKimported" {
t.Errorf("got %q", got.AccessKeyID)
}
}
func TestV1_ListBuckets(t *testing.T) {
want := []models.ListBucketsResponseItem{{ID: "b1", GlobalAliases: []string{"mybucket"}}}
svc, rec := newV1RecordingServer(t, 200, want)
got, err := svc.ListBuckets(context.Background())
if err != nil {
t.Fatal(err)
}
if rec.method != http.MethodGet || rec.path != "/v1/bucket" {
t.Errorf("request = %s %s", rec.method, rec.path)
}
if len(got) != 1 || got[0].ID != "b1" {
t.Errorf("got %+v", got)
}
}
func TestV1_GetBucketInfo(t *testing.T) {
want := &models.GarageBucketInfo{ID: "b1"}
svc, rec := newV1RecordingServer(t, 200, want)
got, err := svc.GetBucketInfo(context.Background(), "b1")
if err != nil {
t.Fatal(err)
}
if !strings.Contains(rec.rawURL, "id=b1") {
t.Errorf("rawURL = %q", rec.rawURL)
}
if got.ID != "b1" {
t.Errorf("got %q", got.ID)
}
}
func TestV1_GetBucketInfoByAlias(t *testing.T) {
want := &models.GarageBucketInfo{ID: "b2"}
svc, rec := newV1RecordingServer(t, 200, want)
got, err := svc.GetBucketInfoByAlias(context.Background(), "myalias")
if err != nil {
t.Fatal(err)
}
if !strings.Contains(rec.rawURL, "globalAlias=myalias") {
t.Errorf("rawURL = %q", rec.rawURL)
}
if got.ID != "b2" {
t.Errorf("got %q", got.ID)
}
}
func TestV1_CreateBucket(t *testing.T) {
want := &models.GarageBucketInfo{ID: "newb"}
svc, rec := newV1RecordingServer(t, 200, want)
alias := "test-bucket"
got, err := svc.CreateBucket(context.Background(), models.CreateBucketAdminRequest{GlobalAlias: &alias})
if err != nil {
t.Fatal(err)
}
if rec.method != http.MethodPost || rec.path != "/v1/bucket" {
t.Errorf("request = %s %s", rec.method, rec.path)
}
if got.ID != "newb" {
t.Errorf("got %q", got.ID)
}
}
func TestV1_UpdateBucket(t *testing.T) {
want := &models.GarageBucketInfo{ID: "b1"}
svc, rec := newV1RecordingServer(t, 200, want)
_, err := svc.UpdateBucket(context.Background(), "b1", models.UpdateBucketRequest{})
if err != nil {
t.Fatal(err)
}
if rec.method != http.MethodPut {
t.Errorf("method = %q, want PUT", rec.method)
}
if !strings.Contains(rec.rawURL, "id=b1") {
t.Errorf("rawURL = %q", rec.rawURL)
}
}
func TestV1_DeleteBucket(t *testing.T) {
svc, rec := newV1RecordingServer(t, 200, nil)
err := svc.DeleteBucket(context.Background(), "b1")
if err != nil {
t.Fatal(err)
}
if rec.method != http.MethodDelete {
t.Errorf("method = %q, want DELETE", rec.method)
}
if !strings.Contains(rec.rawURL, "id=b1") {
t.Errorf("rawURL = %q", rec.rawURL)
}
}
func TestV1_AllowBucketKey(t *testing.T) {
want := &models.GarageBucketInfo{ID: "b1"}
svc, rec := newV1RecordingServer(t, 200, want)
_, err := svc.AllowBucketKey(context.Background(), models.BucketKeyPermRequest{
BucketID: "b1", AccessKeyID: "k1",
Permissions: models.BucketKeyPermission{Read: true},
})
if err != nil {
t.Fatal(err)
}
if rec.method != http.MethodPost || rec.path != "/v1/bucket/allow" {
t.Errorf("request = %s %s", rec.method, rec.path)
}
}
func TestV1_DenyBucketKey(t *testing.T) {
want := &models.GarageBucketInfo{ID: "b1"}
svc, rec := newV1RecordingServer(t, 200, want)
_, err := svc.DenyBucketKey(context.Background(), models.BucketKeyPermRequest{
BucketID: "b1", AccessKeyID: "k1",
})
if err != nil {
t.Fatal(err)
}
if rec.method != http.MethodPost || rec.path != "/v1/bucket/deny" {
t.Errorf("request = %s %s", rec.method, rec.path)
}
}
func TestV1_AddBucketAlias_Global(t *testing.T) {
want := &models.GarageBucketInfo{ID: "b1"}
svc, rec := newV1RecordingServer(t, 200, want)
alias := "myalias"
_, err := svc.AddBucketAlias(context.Background(), models.AddBucketAliasRequest{
BucketID: "b1", GlobalAlias: &alias,
})
if err != nil {
t.Fatal(err)
}
if rec.method != http.MethodPut || rec.path != "/v1/bucket/alias/global" {
t.Errorf("request = %s %s", rec.method, rec.path)
}
if !strings.Contains(rec.rawURL, "id=b1") || !strings.Contains(rec.rawURL, "alias=myalias") {
t.Errorf("rawURL = %q", rec.rawURL)
}
}
func TestV1_AddBucketAlias_Local(t *testing.T) {
want := &models.GarageBucketInfo{ID: "b1"}
svc, rec := newV1RecordingServer(t, 200, want)
alias := "localname"
keyID := "GK1"
_, err := svc.AddBucketAlias(context.Background(), models.AddBucketAliasRequest{
BucketID: "b1", LocalAlias: &alias, AccessKeyID: &keyID,
})
if err != nil {
t.Fatal(err)
}
if rec.method != http.MethodPut || rec.path != "/v1/bucket/alias/local" {
t.Errorf("request = %s %s", rec.method, rec.path)
}
}
func TestV1_RemoveBucketAlias_Global(t *testing.T) {
want := &models.GarageBucketInfo{ID: "b1"}
svc, rec := newV1RecordingServer(t, 200, want)
alias := "myalias"
_, err := svc.RemoveBucketAlias(context.Background(), models.RemoveBucketAliasRequest{
BucketID: "b1", GlobalAlias: &alias,
})
if err != nil {
t.Fatal(err)
}
if rec.method != http.MethodDelete || rec.path != "/v1/bucket/alias/global" {
t.Errorf("request = %s %s", rec.method, rec.path)
}
}
func TestV1_GetClusterStatus(t *testing.T) {
raw := map[string]any{
"node": "abc123",
"garageVersion": "1.3.0",
"knownNodes": []map[string]any{
{"id": "n1", "addr": "1.2.3.4:3901", "isUp": true, "hostname": "node1"},
{"id": "n2", "addr": "5.6.7.8:3901", "isUp": false, "lastSeenSecsAgo": 60, "hostname": "node2"},
},
"layout": map[string]any{"version": 3},
}
svc, rec := newV1RecordingServer(t, 200, raw)
got, err := svc.GetClusterStatus(context.Background())
if err != nil {
t.Fatal(err)
}
if rec.path != "/v1/status" {
t.Errorf("path = %q, want /v1/status", rec.path)
}
if got.LayoutVersion != 3 {
t.Errorf("LayoutVersion = %d, want 3", got.LayoutVersion)
}
if len(got.Nodes) != 2 {
t.Fatalf("len(Nodes) = %d, want 2", len(got.Nodes))
}
if got.Nodes[0].ID != "n1" || !got.Nodes[0].IsUp {
t.Errorf("Nodes[0] = %+v", got.Nodes[0])
}
if got.Nodes[1].ID != "n2" || got.Nodes[1].IsUp {
t.Errorf("Nodes[1] = %+v", got.Nodes[1])
}
}
func TestV1_HealthCheck(t *testing.T) {
svc, rec := newV1RecordingServer(t, 200, nil)
err := svc.HealthCheck(context.Background())
if err != nil {
t.Fatal(err)
}
if rec.path != "/health" {
t.Errorf("path = %q, want /health", rec.path)
}
}
func TestV1_ErrorPaths(t *testing.T) {
// Server that returns 500 for all requests to exercise error branches.
srv500 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(500)
w.Write([]byte(`{"error":"internal"}`))
}))
t.Cleanup(srv500.Close)
svc := NewGarageV1AdminService(&config.GarageConfig{
AdminEndpoint: srv500.URL,
AdminToken: "tok",
}, "")
ctx := context.Background()
if _, err := svc.ListKeys(ctx); err == nil {
t.Error("ListKeys: expected error")
}
if _, err := svc.CreateKey(ctx, models.CreateKeyRequest{}); err == nil {
t.Error("CreateKey: expected error")
}
if _, err := svc.GetKeyInfo(ctx, "k", false); err == nil {
t.Error("GetKeyInfo: expected error")
}
if _, err := svc.UpdateKey(ctx, "k", models.UpdateKeyRequest{}); err == nil {
t.Error("UpdateKey: expected error")
}
if err := svc.DeleteKey(ctx, "k"); err == nil {
t.Error("DeleteKey: expected error")
}
if _, err := svc.ImportKey(ctx, models.ImportKeyRequest{}); err == nil {
t.Error("ImportKey: expected error")
}
if _, err := svc.ListBuckets(ctx); err == nil {
t.Error("ListBuckets: expected error")
}
if _, err := svc.GetBucketInfo(ctx, "b"); err == nil {
t.Error("GetBucketInfo: expected error")
}
if _, err := svc.GetBucketInfoByAlias(ctx, "a"); err == nil {
t.Error("GetBucketInfoByAlias: expected error")
}
if _, err := svc.CreateBucket(ctx, models.CreateBucketAdminRequest{}); err == nil {
t.Error("CreateBucket: expected error")
}
if _, err := svc.UpdateBucket(ctx, "b", models.UpdateBucketRequest{}); err == nil {
t.Error("UpdateBucket: expected error")
}
if err := svc.DeleteBucket(ctx, "b"); err == nil {
t.Error("DeleteBucket: expected error")
}
if _, err := svc.AllowBucketKey(ctx, models.BucketKeyPermRequest{}); err == nil {
t.Error("AllowBucketKey: expected error")
}
if _, err := svc.DenyBucketKey(ctx, models.BucketKeyPermRequest{}); err == nil {
t.Error("DenyBucketKey: expected error")
}
alias := "a"
if _, err := svc.AddBucketAlias(ctx, models.AddBucketAliasRequest{BucketID: "b", GlobalAlias: &alias}); err == nil {
t.Error("AddBucketAlias: expected error")
}
if _, err := svc.RemoveBucketAlias(ctx, models.RemoveBucketAliasRequest{BucketID: "b", GlobalAlias: &alias}); err == nil {
t.Error("RemoveBucketAlias: expected error")
}
if _, err := svc.GetClusterHealth(ctx); err == nil {
t.Error("GetClusterHealth: expected error")
}
if _, err := svc.GetClusterStatus(ctx); err == nil {
t.Error("GetClusterStatus: expected error")
}
if err := svc.HealthCheck(ctx); err == nil {
t.Error("HealthCheck: expected error")
}
if _, err := svc.GetMetrics(ctx); err == nil {
t.Error("GetMetrics: expected error")
}
}
func TestV1_RequestFailurePaths(t *testing.T) {
// Use a cancelled context to make doRequest fail immediately (no retry wait).
svc, _ := newV1RecordingServer(t, 200, nil)
ctx, cancel := context.WithCancel(context.Background())
cancel() // cancel immediately
if _, err := svc.ListKeys(ctx); err == nil {
t.Error("ListKeys: expected error")
}
if _, err := svc.CreateKey(ctx, models.CreateKeyRequest{}); err == nil {
t.Error("CreateKey: expected error")
}
if _, err := svc.GetKeyInfo(ctx, "k", false); err == nil {
t.Error("GetKeyInfo: expected error")
}
if _, err := svc.UpdateKey(ctx, "k", models.UpdateKeyRequest{}); err == nil {
t.Error("UpdateKey: expected error")
}
if err := svc.DeleteKey(ctx, "k"); err == nil {
t.Error("DeleteKey: expected error")
}
if _, err := svc.ImportKey(ctx, models.ImportKeyRequest{}); err == nil {
t.Error("ImportKey: expected error")
}
if _, err := svc.ListBuckets(ctx); err == nil {
t.Error("ListBuckets: expected error")
}
if _, err := svc.GetBucketInfo(ctx, "b"); err == nil {
t.Error("GetBucketInfo: expected error")
}
if _, err := svc.GetBucketInfoByAlias(ctx, "a"); err == nil {
t.Error("GetBucketInfoByAlias: expected error")
}
if _, err := svc.CreateBucket(ctx, models.CreateBucketAdminRequest{}); err == nil {
t.Error("CreateBucket: expected error")
}
if _, err := svc.UpdateBucket(ctx, "b", models.UpdateBucketRequest{}); err == nil {
t.Error("UpdateBucket: expected error")
}
if err := svc.DeleteBucket(ctx, "b"); err == nil {
t.Error("DeleteBucket: expected error")
}
if _, err := svc.AllowBucketKey(ctx, models.BucketKeyPermRequest{}); err == nil {
t.Error("AllowBucketKey: expected error")
}
if _, err := svc.DenyBucketKey(ctx, models.BucketKeyPermRequest{}); err == nil {
t.Error("DenyBucketKey: expected error")
}
alias := "a"
if _, err := svc.AddBucketAlias(ctx, models.AddBucketAliasRequest{BucketID: "b", GlobalAlias: &alias}); err == nil {
t.Error("AddBucketAlias: expected error")
}
if _, err := svc.RemoveBucketAlias(ctx, models.RemoveBucketAliasRequest{BucketID: "b", GlobalAlias: &alias}); err == nil {
t.Error("RemoveBucketAlias: expected error")
}
if _, err := svc.GetClusterHealth(ctx); err == nil {
t.Error("GetClusterHealth: expected error")
}
if _, err := svc.GetClusterStatus(ctx); err == nil {
t.Error("GetClusterStatus: expected error")
}
if err := svc.HealthCheck(ctx); err == nil {
t.Error("HealthCheck: expected error")
}
if _, err := svc.GetMetrics(ctx); err == nil {
t.Error("GetMetrics: expected error")
}
}
func TestV1_AddBucketAlias_MissingFields(t *testing.T) {
svc, _ := newV1RecordingServer(t, 200, nil)
// Neither globalAlias nor localAlias set
_, err := svc.AddBucketAlias(context.Background(), models.AddBucketAliasRequest{BucketID: "b"})
if err == nil {
t.Fatal("expected error for missing alias fields")
}
}
func TestV1_RemoveBucketAlias_MissingFields(t *testing.T) {
svc, _ := newV1RecordingServer(t, 200, nil)
_, err := svc.RemoveBucketAlias(context.Background(), models.RemoveBucketAliasRequest{BucketID: "b"})
if err == nil {
t.Fatal("expected error for missing alias fields")
}
}
func TestV1_RemoveBucketAlias_Local(t *testing.T) {
want := &models.GarageBucketInfo{ID: "b1"}
svc, rec := newV1RecordingServer(t, 200, want)
alias := "localname"
keyID := "GK1"
_, err := svc.RemoveBucketAlias(context.Background(), models.RemoveBucketAliasRequest{
BucketID: "b1", LocalAlias: &alias, AccessKeyID: &keyID,
})
if err != nil {
t.Fatal(err)
}
if rec.method != http.MethodDelete || rec.path != "/v1/bucket/alias/local" {
t.Errorf("request = %s %s", rec.method, rec.path)
}
}
func TestV1_GetMetrics(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(200)
w.Write([]byte("garage_up 1\n"))
}))
t.Cleanup(srv.Close)
svc := NewGarageV1AdminService(&config.GarageConfig{
AdminEndpoint: srv.URL,
AdminToken: "tok",
}, "")
got, err := svc.GetMetrics(context.Background())
if err != nil {
t.Fatal(err)
}
if !strings.Contains(got, "garage_up") {
t.Errorf("got %q", got)
}
}
@@ -4,28 +4,33 @@ import (
"Noooste/garage-ui/internal/config"
"Noooste/garage-ui/internal/models"
"Noooste/garage-ui/pkg/utils"
logpkg "Noooste/garage-ui/pkg/logger"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
"github.com/Noooste/azuretls-client"
)
// GarageAdminService handles interactions with the Garage Admin API
type GarageAdminService struct {
// GarageV2AdminService handles interactions with the Garage Admin API
type GarageV2AdminService struct {
baseURL string
token string
httpClient *azuretls.Session
}
// NewGarageAdminService creates a new Garage Admin API service
func NewGarageAdminService(cfg *config.GarageConfig) *GarageAdminService {
// NewGarageV2AdminService creates a new Garage Admin API service
func NewGarageV2AdminService(cfg *config.GarageConfig, logLevel string) *GarageV2AdminService {
session := azuretls.NewSession()
session.Log()
return &GarageAdminService{
if logLevel == "debug" {
session.Log()
}
return &GarageV2AdminService{
baseURL: cfg.AdminEndpoint,
token: cfg.AdminToken,
httpClient: session,
@@ -33,7 +38,7 @@ func NewGarageAdminService(cfg *config.GarageConfig) *GarageAdminService {
}
// 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) {
func (s *GarageV2AdminService) doRequest(ctx context.Context, method, path string, body interface{}) (*azuretls.Response, error) {
var resp *azuretls.Response
retryConfig := utils.DefaultRetryConfig()
@@ -76,12 +81,8 @@ func decodeResponse(resp *azuretls.Response, target interface{}) error {
return nil
}
// ====================================
// Access Key Operations
// ====================================
// ListKeys returns all access keys in the cluster
func (s *GarageAdminService) ListKeys(ctx context.Context) ([]models.ListKeysResponseItem, error) {
func (s *GarageV2AdminService) ListKeys(ctx context.Context) ([]models.ListKeysResponseItem, error) {
resp, err := s.doRequest(ctx, http.MethodGet, "/v2/ListKeys", nil)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
@@ -96,7 +97,7 @@ func (s *GarageAdminService) ListKeys(ctx context.Context) ([]models.ListKeysRes
}
// CreateKey creates a new API access key
func (s *GarageAdminService) CreateKey(ctx context.Context, req models.CreateKeyRequest) (*models.GarageKeyInfo, error) {
func (s *GarageV2AdminService) CreateKey(ctx context.Context, req models.CreateKeyRequest) (*models.GarageKeyInfo, error) {
resp, err := s.doRequest(ctx, http.MethodPost, "/v2/CreateKey", req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
@@ -111,7 +112,7 @@ func (s *GarageAdminService) CreateKey(ctx context.Context, req models.CreateKey
}
// GetKeyInfo returns information about a specific access key
func (s *GarageAdminService) GetKeyInfo(ctx context.Context, keyID string, showSecret bool) (*models.GarageKeyInfo, error) {
func (s *GarageV2AdminService) GetKeyInfo(ctx context.Context, keyID string, showSecret bool) (*models.GarageKeyInfo, error) {
path := fmt.Sprintf("/v2/GetKeyInfo?id=%s", keyID)
if showSecret {
path += "&showSecretKey=true"
@@ -131,7 +132,7 @@ func (s *GarageAdminService) GetKeyInfo(ctx context.Context, keyID string, showS
}
// UpdateKey updates information about an access key
func (s *GarageAdminService) UpdateKey(ctx context.Context, keyID string, req models.UpdateKeyRequest) (*models.GarageKeyInfo, error) {
func (s *GarageV2AdminService) UpdateKey(ctx context.Context, keyID string, req models.UpdateKeyRequest) (*models.GarageKeyInfo, error) {
path := fmt.Sprintf("/v2/UpdateKey?id=%s", keyID)
resp, err := s.doRequest(ctx, http.MethodPost, path, req)
@@ -148,7 +149,7 @@ func (s *GarageAdminService) UpdateKey(ctx context.Context, keyID string, req mo
}
// DeleteKey deletes an access key from the cluster
func (s *GarageAdminService) DeleteKey(ctx context.Context, keyID string) error {
func (s *GarageV2AdminService) DeleteKey(ctx context.Context, keyID string) error {
path := fmt.Sprintf("/v2/DeleteKey?id=%s", keyID)
resp, err := s.doRequest(ctx, http.MethodPost, path, nil)
@@ -164,7 +165,7 @@ func (s *GarageAdminService) DeleteKey(ctx context.Context, keyID string) error
}
// ImportKey imports an existing API access key
func (s *GarageAdminService) ImportKey(ctx context.Context, req models.ImportKeyRequest) (*models.GarageKeyInfo, error) {
func (s *GarageV2AdminService) ImportKey(ctx context.Context, req models.ImportKeyRequest) (*models.GarageKeyInfo, error) {
resp, err := s.doRequest(ctx, http.MethodPost, "/v2/ImportKey", req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
@@ -178,113 +179,182 @@ func (s *GarageAdminService) ImportKey(ctx context.Context, req models.ImportKey
return &result, nil
}
// ====================================
// Bucket Operations (Admin API)
// ====================================
// ListBuckets returns all buckets in the cluster.
func (s *GarageV2AdminService) 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()
// ListBuckets returns all buckets in the cluster
func (s *GarageAdminService) ListBuckets(ctx context.Context) ([]models.ListBucketsResponseItem, error) {
resp, err := s.doRequest(ctx, http.MethodGet, "/v2/ListBuckets", nil)
if err != nil {
log.Error().Err(err).
Float64("duration_ms", msSince(start)).
Str("outcome", "failure").
Msg("garage list_buckets request failed")
return nil, fmt.Errorf("request failed: %w", err)
}
var result []models.ListBucketsResponseItem
if err := decodeResponse(resp, &result); err != nil {
log.Error().Err(err).
Float64("duration_ms", msSince(start)).
Str("outcome", "failure").
Msg("garage list_buckets decode failed")
return nil, fmt.Errorf("failed to decode response: %w", err)
}
log.Debug().
Float64("duration_ms", msSince(start)).
Str("outcome", "success").
Int("count", len(result)).
Msg("listed buckets")
return result, nil
}
// GetBucketInfo returns detailed information about a bucket by ID
func (s *GarageAdminService) GetBucketInfo(ctx context.Context, bucketID string) (*models.GarageBucketInfo, error) {
path := fmt.Sprintf("/v2/GetBucketInfo?id=%s", bucketID)
// GetBucketInfo returns detailed information about a bucket by ID.
func (s *GarageV2AdminService) GetBucketInfo(ctx context.Context, bucketID string) (*models.GarageBucketInfo, error) {
log := logpkg.FromCtx(ctx).With().
Str("component", "admin").
Str("operation", "get_bucket_info").
Str("bucket_id", bucketID).
Logger()
resp, err := s.doRequest(ctx, http.MethodGet, path, nil)
log.Debug().Msg("getting bucket info")
start := time.Now()
resp, err := s.doRequest(ctx, http.MethodGet, fmt.Sprintf("/v2/GetBucketInfo?id=%s", bucketID), nil)
if err != nil {
log.Error().Err(err).Float64("duration_ms", msSince(start)).Str("outcome", "failure").Msg("garage get_bucket_info request failed")
return nil, fmt.Errorf("request failed: %w", err)
}
var result models.GarageBucketInfo
if err := decodeResponse(resp, &result); err != nil {
log.Error().Err(err).Float64("duration_ms", msSince(start)).Str("outcome", "failure").Msg("garage get_bucket_info decode failed")
return nil, fmt.Errorf("failed to decode response: %w", err)
}
log.Debug().Float64("duration_ms", msSince(start)).Str("outcome", "success").Msg("got bucket info")
return &result, nil
}
// GetBucketInfoByAlias returns detailed information about a bucket by its global alias
func (s *GarageAdminService) GetBucketInfoByAlias(ctx context.Context, globalAlias string) (*models.GarageBucketInfo, error) {
path := fmt.Sprintf("/v2/GetBucketInfo?globalAlias=%s", globalAlias)
// GetBucketInfoByAlias returns detailed information about a bucket by its global alias.
func (s *GarageV2AdminService) GetBucketInfoByAlias(ctx context.Context, globalAlias string) (*models.GarageBucketInfo, error) {
log := logpkg.FromCtx(ctx).With().
Str("component", "admin").
Str("operation", "get_bucket_info_by_alias").
Str("bucket", globalAlias).
Logger()
resp, err := s.doRequest(ctx, http.MethodGet, path, nil)
log.Debug().Msg("getting bucket info by alias")
start := time.Now()
resp, err := s.doRequest(ctx, http.MethodGet, fmt.Sprintf("/v2/GetBucketInfo?globalAlias=%s", globalAlias), nil)
if err != nil {
log.Error().Err(err).Float64("duration_ms", msSince(start)).Str("outcome", "failure").Msg("garage get_bucket_info_by_alias request failed")
return nil, fmt.Errorf("request failed: %w", err)
}
var result models.GarageBucketInfo
if err = decodeResponse(resp, &result); err != nil {
log.Error().Err(err).Float64("duration_ms", msSince(start)).Str("outcome", "failure").Msg("garage get_bucket_info_by_alias decode failed")
return nil, fmt.Errorf("failed to decode response: %w", err)
}
log.Debug().Float64("duration_ms", msSince(start)).Str("outcome", "success").Str("bucket_id", result.ID).Msg("got bucket info by alias")
return &result, nil
}
// CreateBucket creates a new bucket via the Admin API
func (s *GarageAdminService) CreateBucket(ctx context.Context, req models.CreateBucketAdminRequest) (*models.GarageBucketInfo, error) {
// CreateBucket creates a new bucket via the Admin API.
func (s *GarageV2AdminService) CreateBucket(ctx context.Context, req models.CreateBucketAdminRequest) (*models.GarageBucketInfo, error) {
var alias string
if req.GlobalAlias != nil {
alias = *req.GlobalAlias
}
log := logpkg.FromCtx(ctx).With().
Str("component", "admin").
Str("operation", "create_bucket").
Str("bucket", alias).
Logger()
log.Info().Msg("creating bucket")
start := time.Now()
resp, err := s.doRequest(ctx, http.MethodPost, "/v2/CreateBucket", req)
if err != nil {
log.Error().Err(err).Float64("duration_ms", msSince(start)).Str("outcome", "failure").Msg("garage create_bucket request failed")
return nil, fmt.Errorf("request failed: %w", err)
}
var result models.GarageBucketInfo
if err := decodeResponse(resp, &result); err != nil {
log.Error().Err(err).Float64("duration_ms", msSince(start)).Str("outcome", "failure").Msg("garage create_bucket decode failed")
return nil, fmt.Errorf("failed to decode response: %w", err)
}
log.Info().Float64("duration_ms", msSince(start)).Str("outcome", "success").Str("bucket_id", result.ID).Msg("bucket created")
return &result, nil
}
// UpdateBucket updates bucket settings
func (s *GarageAdminService) UpdateBucket(ctx context.Context, bucketID string, req models.UpdateBucketRequest) (*models.GarageBucketInfo, error) {
path := fmt.Sprintf("/v2/UpdateBucket?id=%s", bucketID)
// UpdateBucket updates bucket settings.
func (s *GarageV2AdminService) UpdateBucket(ctx context.Context, bucketID string, req models.UpdateBucketRequest) (*models.GarageBucketInfo, error) {
log := logpkg.FromCtx(ctx).With().
Str("component", "admin").
Str("operation", "update_bucket").
Str("bucket_id", bucketID).
Logger()
resp, err := s.doRequest(ctx, http.MethodPost, path, req)
log.Info().Msg("updating bucket")
start := time.Now()
resp, err := s.doRequest(ctx, http.MethodPost, fmt.Sprintf("/v2/UpdateBucket?id=%s", bucketID), req)
if err != nil {
log.Error().Err(err).Float64("duration_ms", msSince(start)).Str("outcome", "failure").Msg("garage update_bucket request failed")
return nil, fmt.Errorf("request failed: %w", err)
}
var result models.GarageBucketInfo
if err := decodeResponse(resp, &result); err != nil {
log.Error().Err(err).Float64("duration_ms", msSince(start)).Str("outcome", "failure").Msg("garage update_bucket decode failed")
return nil, fmt.Errorf("failed to decode response: %w", err)
}
log.Info().Float64("duration_ms", msSince(start)).Str("outcome", "success").Msg("bucket updated")
return &result, nil
}
// DeleteBucket deletes a bucket
func (s *GarageAdminService) DeleteBucket(ctx context.Context, bucketID string) error {
path := fmt.Sprintf("/v2/DeleteBucket?id=%s", bucketID)
// DeleteBucket deletes a bucket.
func (s *GarageV2AdminService) DeleteBucket(ctx context.Context, bucketID string) error {
log := logpkg.FromCtx(ctx).With().
Str("component", "admin").
Str("operation", "delete_bucket").
Str("bucket_id", bucketID).
Logger()
resp, err := s.doRequest(ctx, http.MethodPost, path, nil)
log.Info().Msg("deleting bucket")
start := time.Now()
resp, err := s.doRequest(ctx, http.MethodPost, fmt.Sprintf("/v2/DeleteBucket?id=%s", bucketID), nil)
if err != nil {
log.Error().Err(err).Float64("duration_ms", msSince(start)).Str("outcome", "failure").Msg("garage delete_bucket request failed")
return fmt.Errorf("request failed: %w", err)
}
if err := decodeResponse(resp, nil); err != nil {
log.Error().Err(err).Float64("duration_ms", msSince(start)).Str("outcome", "failure").Msg("garage delete_bucket decode failed")
return fmt.Errorf("failed to process response: %w", err)
}
log.Info().Float64("duration_ms", msSince(start)).Str("outcome", "success").Msg("bucket deleted")
return nil
}
// ====================================
// Bucket Alias Operations
// ====================================
// AddBucketAlias adds an alias to a bucket
func (s *GarageAdminService) AddBucketAlias(ctx context.Context, req models.AddBucketAliasRequest) (*models.GarageBucketInfo, error) {
func (s *GarageV2AdminService) AddBucketAlias(ctx context.Context, req models.AddBucketAliasRequest) (*models.GarageBucketInfo, error) {
resp, err := s.doRequest(ctx, http.MethodPost, "/v2/AddBucketAlias", req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
@@ -299,7 +369,7 @@ func (s *GarageAdminService) AddBucketAlias(ctx context.Context, req models.AddB
}
// RemoveBucketAlias removes an alias from a bucket
func (s *GarageAdminService) RemoveBucketAlias(ctx context.Context, req models.RemoveBucketAliasRequest) (*models.GarageBucketInfo, error) {
func (s *GarageV2AdminService) RemoveBucketAlias(ctx context.Context, req models.RemoveBucketAliasRequest) (*models.GarageBucketInfo, error) {
resp, err := s.doRequest(ctx, http.MethodPost, "/v2/RemoveBucketAlias", req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
@@ -313,27 +383,39 @@ func (s *GarageAdminService) RemoveBucketAlias(ctx context.Context, req models.R
return &result, nil
}
// ====================================
// Permission Operations
// ====================================
// AllowBucketKey grants permissions for a key on a bucket.
func (s *GarageV2AdminService) 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()
// AllowBucketKey grants permissions for a key on a bucket
func (s *GarageAdminService) AllowBucketKey(ctx context.Context, req models.BucketKeyPermRequest) (*models.GarageBucketInfo, error) {
resp, err := s.doRequest(ctx, http.MethodPost, "/v2/AllowBucketKey", req)
if err != nil {
log.Error().Err(err).Float64("duration_ms", msSince(start)).Str("outcome", "failure").Msg("garage allow_bucket_key request failed")
return nil, fmt.Errorf("request failed: %w", err)
}
var result models.GarageBucketInfo
if err := decodeResponse(resp, &result); err != nil {
log.Error().Err(err).Float64("duration_ms", msSince(start)).Str("outcome", "failure").Msg("garage allow_bucket_key decode failed")
return nil, fmt.Errorf("failed to decode response: %w", err)
}
log.Info().Float64("duration_ms", msSince(start)).Str("outcome", "success").Msg("bucket key permissions granted")
return &result, nil
}
// DenyBucketKey revokes permissions for a key on a bucket
func (s *GarageAdminService) DenyBucketKey(ctx context.Context, req models.BucketKeyPermRequest) (*models.GarageBucketInfo, error) {
func (s *GarageV2AdminService) DenyBucketKey(ctx context.Context, req models.BucketKeyPermRequest) (*models.GarageBucketInfo, error) {
resp, err := s.doRequest(ctx, http.MethodPost, "/v2/DenyBucketKey", req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
@@ -347,12 +429,8 @@ func (s *GarageAdminService) DenyBucketKey(ctx context.Context, req models.Bucke
return &result, nil
}
// ====================================
// Cluster Operations
// ====================================
// GetClusterHealth returns the health status of the cluster
func (s *GarageAdminService) GetClusterHealth(ctx context.Context) (*models.ClusterHealth, error) {
func (s *GarageV2AdminService) GetClusterHealth(ctx context.Context) (*models.ClusterHealth, error) {
resp, err := s.doRequest(ctx, http.MethodGet, "/v2/GetClusterHealth", nil)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
@@ -367,7 +445,7 @@ func (s *GarageAdminService) GetClusterHealth(ctx context.Context) (*models.Clus
}
// GetClusterStatus returns the current status of the cluster
func (s *GarageAdminService) GetClusterStatus(ctx context.Context) (*models.ClusterStatus, error) {
func (s *GarageV2AdminService) GetClusterStatus(ctx context.Context) (*models.ClusterStatus, error) {
resp, err := s.doRequest(ctx, http.MethodGet, "/v2/GetClusterStatus", nil)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
@@ -382,7 +460,7 @@ func (s *GarageAdminService) GetClusterStatus(ctx context.Context) (*models.Clus
}
// GetClusterStatistics returns global cluster statistics
func (s *GarageAdminService) GetClusterStatistics(ctx context.Context) (*models.ClusterStatistics, error) {
func (s *GarageV2AdminService) GetClusterStatistics(ctx context.Context) (*models.ClusterStatistics, error) {
resp, err := s.doRequest(ctx, http.MethodGet, "/v2/GetClusterStatistics", nil)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
@@ -396,12 +474,8 @@ func (s *GarageAdminService) GetClusterStatistics(ctx context.Context) (*models.
return &result, nil
}
// ====================================
// Node Operations
// ====================================
// GetNodeInfo returns information about a specific node
func (s *GarageAdminService) GetNodeInfo(ctx context.Context, nodeID string) (*models.MultiNodeResponse, error) {
func (s *GarageV2AdminService) GetNodeInfo(ctx context.Context, nodeID string) (*models.MultiNodeResponse, error) {
path := fmt.Sprintf("/v2/GetNodeInfo?node=%s", nodeID)
resp, err := s.doRequest(ctx, http.MethodGet, path, nil)
@@ -418,7 +492,7 @@ func (s *GarageAdminService) GetNodeInfo(ctx context.Context, nodeID string) (*m
}
// GetNodeStatistics returns statistics for a specific node
func (s *GarageAdminService) GetNodeStatistics(ctx context.Context, nodeID string) (*models.MultiNodeResponse, error) {
func (s *GarageV2AdminService) GetNodeStatistics(ctx context.Context, nodeID string) (*models.MultiNodeResponse, error) {
path := fmt.Sprintf("/v2/GetNodeStatistics?node=%s", nodeID)
resp, err := s.doRequest(ctx, http.MethodGet, path, nil)
@@ -434,12 +508,8 @@ func (s *GarageAdminService) GetNodeStatistics(ctx context.Context, nodeID strin
return &result, nil
}
// ====================================
// Monitoring Operations
// ====================================
// HealthCheck checks if the Admin API is reachable
func (s *GarageAdminService) HealthCheck(ctx context.Context) error {
func (s *GarageV2AdminService) HealthCheck(ctx context.Context) error {
resp, err := s.doRequest(ctx, http.MethodGet, "/health", nil)
if err != nil {
return fmt.Errorf("health check failed: %w", err)
@@ -452,8 +522,13 @@ func (s *GarageAdminService) HealthCheck(ctx context.Context) error {
return nil
}
// msSince returns duration since t in milliseconds as a float64.
func msSince(t time.Time) float64 {
return float64(time.Since(t).Microseconds()) / 1000.0
}
// GetMetrics returns Prometheus metrics from the Admin API
func (s *GarageAdminService) GetMetrics(ctx context.Context) (string, error) {
func (s *GarageV2AdminService) GetMetrics(ctx context.Context) (string, error) {
resp, err := s.doRequest(ctx, http.MethodGet, "/metrics", nil)
if err != nil {
return "", fmt.Errorf("request failed: %w", err)
+622
View File
@@ -0,0 +1,622 @@
package services
import (
"context"
"encoding/json"
"io"
"net"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"Noooste/garage-ui/internal/config"
"Noooste/garage-ui/internal/models"
)
// newAdminTestServer wires an httptest.Server (with the supplied handler) to a
// fresh *GarageV2AdminService configured with a known bearer token.
func newAdminTestServer(t *testing.T, handler http.Handler) (*GarageV2AdminService, *httptest.Server) {
t.Helper()
srv := httptest.NewServer(handler)
t.Cleanup(srv.Close)
svc := NewGarageV2AdminService(&config.GarageConfig{
AdminEndpoint: srv.URL,
AdminToken: "test-token-xyz",
}, "")
return svc, srv
}
// jsonHandler returns an http.Handler that writes the supplied status code and
// JSON-encoded body.
func jsonHandler(t *testing.T, status int, body any) http.Handler {
t.Helper()
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if body == nil {
return
}
if err := json.NewEncoder(w).Encode(body); err != nil {
t.Errorf("server: encode response: %v", err)
}
})
}
type recordedRequest struct {
method string
path string
rawURL string
auth string
body []byte
}
func recordingHandler(t *testing.T, status int, respBody any) (http.Handler, *recordedRequest) {
t.Helper()
rec := &recordedRequest{}
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
rec.method = r.Method
rec.path = r.URL.Path
rec.rawURL = r.URL.RequestURI()
rec.auth = r.Header.Get("Authorization")
if r.Body != nil {
b, err := io.ReadAll(r.Body)
if err != nil {
t.Errorf("server: read body: %v", err)
}
rec.body = b
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if respBody != nil {
_ = json.NewEncoder(w).Encode(respBody)
}
})
return h, rec
}
func TestHealthCheck_Success(t *testing.T) {
h, rec := recordingHandler(t, http.StatusOK, nil)
svc, _ := newAdminTestServer(t, h)
if err := svc.HealthCheck(context.Background()); err != nil {
t.Fatalf("HealthCheck: %v", err)
}
if rec.method != http.MethodGet {
t.Errorf("method = %q, want GET", rec.method)
}
if rec.path != "/health" {
t.Errorf("path = %q, want /health", rec.path)
}
if rec.auth != "Bearer test-token-xyz" {
t.Errorf("Authorization = %q, want Bearer test-token-xyz", rec.auth)
}
}
func TestHealthCheck_Non2xxReturnsError(t *testing.T) {
h := jsonHandler(t, http.StatusServiceUnavailable, map[string]string{"error": "down"})
svc, _ := newAdminTestServer(t, h)
err := svc.HealthCheck(context.Background())
if err == nil {
t.Fatal("expected error for 503, got nil")
}
if !strings.Contains(err.Error(), "503") {
t.Errorf("error should mention status code, got %v", err)
}
}
func TestListKeys_Success(t *testing.T) {
want := []models.ListKeysResponseItem{
{ID: "k1", Name: "alice", Expired: false},
{ID: "k2", Name: "bob", Expired: true},
}
h, rec := recordingHandler(t, http.StatusOK, want)
svc, _ := newAdminTestServer(t, h)
got, err := svc.ListKeys(context.Background())
if err != nil {
t.Fatalf("ListKeys: %v", err)
}
if rec.method != http.MethodGet || rec.path != "/v2/ListKeys" {
t.Errorf("request = %s %s, want GET /v2/ListKeys", rec.method, rec.path)
}
if len(got) != 2 || got[0].ID != "k1" || got[1].Name != "bob" {
t.Errorf("got %+v, want %+v", got, want)
}
}
func TestCreateKey_PostsBodyAndDecodesResponse(t *testing.T) {
name := "service-account"
want := &models.GarageKeyInfo{
AccessKeyID: "GK123",
Name: name,
}
h, rec := recordingHandler(t, http.StatusOK, want)
svc, _ := newAdminTestServer(t, h)
req := models.CreateKeyRequest{Name: &name, NeverExpires: true}
got, err := svc.CreateKey(context.Background(), req)
if err != nil {
t.Fatalf("CreateKey: %v", err)
}
if rec.method != http.MethodPost || rec.path != "/v2/CreateKey" {
t.Errorf("request = %s %s", rec.method, rec.path)
}
var sent models.CreateKeyRequest
if err := json.Unmarshal(rec.body, &sent); err != nil {
t.Fatalf("unmarshal sent body: %v (raw=%q)", err, rec.body)
}
if sent.Name == nil || *sent.Name != name {
t.Errorf("sent name = %v, want %q", sent.Name, name)
}
if !sent.NeverExpires {
t.Errorf("NeverExpires should round-trip true")
}
if got.AccessKeyID != want.AccessKeyID {
t.Errorf("AccessKeyID = %q, want %q", got.AccessKeyID, want.AccessKeyID)
}
}
func TestGetKeyInfo_PassesShowSecretQueryParam(t *testing.T) {
cases := []struct {
name string
showSecret bool
wantInRawURL string
}{
{"without secret", false, "?id=ABC"},
{"with secret", true, "?id=ABC&showSecretKey=true"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
h, rec := recordingHandler(t, http.StatusOK, &models.GarageKeyInfo{AccessKeyID: "ABC"})
svc, _ := newAdminTestServer(t, h)
_, err := svc.GetKeyInfo(context.Background(), "ABC", tc.showSecret)
if err != nil {
t.Fatalf("GetKeyInfo: %v", err)
}
if rec.method != http.MethodGet {
t.Errorf("method = %q, want GET", rec.method)
}
if !strings.Contains(rec.rawURL, tc.wantInRawURL) {
t.Errorf("raw URL = %q, want substring %q", rec.rawURL, tc.wantInRawURL)
}
})
}
}
func TestUpdateKey_UsesIDInQuery(t *testing.T) {
h, rec := recordingHandler(t, http.StatusOK, &models.GarageKeyInfo{AccessKeyID: "K"})
svc, _ := newAdminTestServer(t, h)
if _, err := svc.UpdateKey(context.Background(), "K", models.UpdateKeyRequest{}); err != nil {
t.Fatalf("UpdateKey: %v", err)
}
if rec.method != http.MethodPost {
t.Errorf("method = %q, want POST", rec.method)
}
if !strings.Contains(rec.rawURL, "id=K") {
t.Errorf("raw URL = %q, want substring id=K", rec.rawURL)
}
}
func TestDeleteKey_NoBodyOnSuccess(t *testing.T) {
h, rec := recordingHandler(t, http.StatusOK, nil)
svc, _ := newAdminTestServer(t, h)
if err := svc.DeleteKey(context.Background(), "K"); err != nil {
t.Fatalf("DeleteKey: %v", err)
}
if rec.method != http.MethodPost {
t.Errorf("method = %q, want POST", rec.method)
}
if !strings.Contains(rec.rawURL, "id=K") {
t.Errorf("raw URL = %q", rec.rawURL)
}
}
func TestImportKey_PostsToImportEndpoint(t *testing.T) {
want := &models.GarageKeyInfo{AccessKeyID: "imported"}
h, rec := recordingHandler(t, http.StatusOK, want)
svc, _ := newAdminTestServer(t, h)
req := models.ImportKeyRequest{
AccessKeyID: "imported",
SecretAccessKey: "shhh",
}
got, err := svc.ImportKey(context.Background(), req)
if err != nil {
t.Fatalf("ImportKey: %v", err)
}
if rec.path != "/v2/ImportKey" {
t.Errorf("path = %q", rec.path)
}
if got.AccessKeyID != "imported" {
t.Errorf("AccessKeyID = %q, want imported", got.AccessKeyID)
}
}
func TestListBuckets_Success(t *testing.T) {
want := []models.ListBucketsResponseItem{{ID: "b1"}, {ID: "b2"}}
h, rec := recordingHandler(t, http.StatusOK, want)
svc, _ := newAdminTestServer(t, h)
got, err := svc.ListBuckets(context.Background())
if err != nil {
t.Fatalf("ListBuckets: %v", err)
}
if rec.path != "/v2/ListBuckets" {
t.Errorf("path = %q", rec.path)
}
if len(got) != 2 {
t.Errorf("len = %d, want 2", len(got))
}
}
func TestGetBucketInfo_UsesIDQuery(t *testing.T) {
h, rec := recordingHandler(t, http.StatusOK, &models.GarageBucketInfo{ID: "B"})
svc, _ := newAdminTestServer(t, h)
if _, err := svc.GetBucketInfo(context.Background(), "B"); err != nil {
t.Fatalf("GetBucketInfo: %v", err)
}
if !strings.Contains(rec.rawURL, "id=B") {
t.Errorf("raw URL = %q", rec.rawURL)
}
}
func TestGetBucketInfoByAlias_UsesGlobalAliasQuery(t *testing.T) {
h, rec := recordingHandler(t, http.StatusOK, &models.GarageBucketInfo{ID: "B", GlobalAliases: []string{"my-bucket"}})
svc, _ := newAdminTestServer(t, h)
got, err := svc.GetBucketInfoByAlias(context.Background(), "my-bucket")
if err != nil {
t.Fatalf("GetBucketInfoByAlias: %v", err)
}
if !strings.Contains(rec.rawURL, "globalAlias=my-bucket") {
t.Errorf("raw URL = %q", rec.rawURL)
}
if got.ID != "B" {
t.Errorf("ID = %q, want B", got.ID)
}
}
func TestCreateBucket_PostsBody(t *testing.T) {
want := &models.GarageBucketInfo{ID: "new"}
h, rec := recordingHandler(t, http.StatusOK, want)
svc, _ := newAdminTestServer(t, h)
if _, err := svc.CreateBucket(context.Background(), models.CreateBucketAdminRequest{}); err != nil {
t.Fatalf("CreateBucket: %v", err)
}
if rec.method != http.MethodPost || rec.path != "/v2/CreateBucket" {
t.Errorf("request = %s %s", rec.method, rec.path)
}
}
func TestUpdateBucket_UsesIDQueryAndPostsBody(t *testing.T) {
h, rec := recordingHandler(t, http.StatusOK, &models.GarageBucketInfo{ID: "B"})
svc, _ := newAdminTestServer(t, h)
if _, err := svc.UpdateBucket(context.Background(), "B", models.UpdateBucketRequest{}); err != nil {
t.Fatalf("UpdateBucket: %v", err)
}
if rec.method != http.MethodPost {
t.Errorf("method = %q", rec.method)
}
if !strings.Contains(rec.rawURL, "id=B") {
t.Errorf("raw URL = %q", rec.rawURL)
}
}
func TestDeleteBucket_PostWithIDQuery(t *testing.T) {
h, rec := recordingHandler(t, http.StatusOK, nil)
svc, _ := newAdminTestServer(t, h)
if err := svc.DeleteBucket(context.Background(), "B"); err != nil {
t.Fatalf("DeleteBucket: %v", err)
}
if rec.method != http.MethodPost {
t.Errorf("method = %q", rec.method)
}
if !strings.Contains(rec.rawURL, "id=B") {
t.Errorf("raw URL = %q", rec.rawURL)
}
}
func TestBucketAliasAndPermissionEndpoints(t *testing.T) {
cases := []struct {
name string
fn func(s *GarageV2AdminService) error
path string
}{
{
name: "AddBucketAlias",
fn: func(s *GarageV2AdminService) error {
_, err := s.AddBucketAlias(context.Background(), models.AddBucketAliasRequest{})
return err
},
path: "/v2/AddBucketAlias",
},
{
name: "RemoveBucketAlias",
fn: func(s *GarageV2AdminService) error {
_, err := s.RemoveBucketAlias(context.Background(), models.RemoveBucketAliasRequest{})
return err
},
path: "/v2/RemoveBucketAlias",
},
{
name: "AllowBucketKey",
fn: func(s *GarageV2AdminService) error {
_, err := s.AllowBucketKey(context.Background(), models.BucketKeyPermRequest{})
return err
},
path: "/v2/AllowBucketKey",
},
{
name: "DenyBucketKey",
fn: func(s *GarageV2AdminService) 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 *GarageV2AdminService) error
path string
}{
{
name: "GetClusterHealth",
fn: func(s *GarageV2AdminService) error {
_, err := s.GetClusterHealth(context.Background())
return err
},
path: "/v2/GetClusterHealth",
},
{
name: "GetClusterStatus",
fn: func(s *GarageV2AdminService) error {
_, err := s.GetClusterStatus(context.Background())
return err
},
path: "/v2/GetClusterStatus",
},
{
name: "GetClusterStatistics",
fn: func(s *GarageV2AdminService) 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 *GarageV2AdminService, id string) error
path string
}{
{
name: "GetNodeInfo",
fn: func(s *GarageV2AdminService, id string) error {
_, err := s.GetNodeInfo(context.Background(), id)
return err
},
path: "/v2/GetNodeInfo",
},
{
name: "GetNodeStatistics",
fn: func(s *GarageV2AdminService, id string) error {
_, err := s.GetNodeStatistics(context.Background(), id)
return err
},
path: "/v2/GetNodeStatistics",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
h, rec := recordingHandler(t, http.StatusOK, struct{}{})
svc, _ := newAdminTestServer(t, h)
if err := tc.fn(svc, "node-7"); err != nil {
t.Fatalf("%s: %v", tc.name, err)
}
if rec.path != tc.path {
t.Errorf("path = %q, want %q", rec.path, tc.path)
}
if !strings.Contains(rec.rawURL, "node=node-7") {
t.Errorf("raw URL = %q, want substring node=node-7", rec.rawURL)
}
})
}
}
func TestGetMetrics_ReturnsRawBodyOn2xx(t *testing.T) {
body := "# HELP garage_up Number of running nodes\ngarage_up 3\n"
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(http.StatusOK)
_, _ = io.WriteString(w, body)
})
svc, _ := newAdminTestServer(t, h)
got, err := svc.GetMetrics(context.Background())
if err != nil {
t.Fatalf("GetMetrics: %v", err)
}
if got != body {
t.Errorf("GetMetrics body mismatch:\n got %q\nwant %q", got, body)
}
}
func TestGetMetrics_Non2xxReturnsErrorWithStatus(t *testing.T) {
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusForbidden)
_, _ = io.WriteString(w, "forbidden")
})
svc, _ := newAdminTestServer(t, h)
_, err := svc.GetMetrics(context.Background())
if err == nil {
t.Fatal("expected error for 403, got nil")
}
if !strings.Contains(err.Error(), "403") {
t.Errorf("error should mention 403, got %v", err)
}
}
func TestDoRequest_Non2xxBodyEchoedInError(t *testing.T) {
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
_, _ = io.WriteString(w, `{"error":"bad request"}`)
})
svc, _ := newAdminTestServer(t, h)
_, err := svc.ListKeys(context.Background())
if err == nil {
t.Fatal("expected error for 400, got nil")
}
if !strings.Contains(err.Error(), "400") || !strings.Contains(err.Error(), "bad request") {
t.Errorf("error %v should contain 400 and the response body", err)
}
}
func TestDoRequest_MalformedJSONReturnsDecodeError(t *testing.T) {
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = io.WriteString(w, "not-json-at-all")
})
svc, _ := newAdminTestServer(t, h)
_, err := svc.ListKeys(context.Background())
if err == nil {
t.Fatal("expected decode error, got nil")
}
if !strings.Contains(err.Error(), "decode") {
t.Errorf("error %v should mention decoding", err)
}
}
// TestAllMethods_Non2xxReturnsError exercises the decodeResponse error branch
// of every admin method by pointing them at a server that always returns 500.
// This is a single sweep over the near-identical "if err := decodeResponse ...
// return nil, fmt.Errorf(...)" branches that each wrapper repeats.
func TestAllMethods_Non2xxReturnsError(t *testing.T) {
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "boom", http.StatusInternalServerError)
})
svc, _ := newAdminTestServer(t, h)
ctx := context.Background()
calls := map[string]func() error{
"ListKeys": func() error { _, err := svc.ListKeys(ctx); return err },
"CreateKey": func() error { _, err := svc.CreateKey(ctx, models.CreateKeyRequest{}); return err },
"GetKeyInfo": func() error { _, err := svc.GetKeyInfo(ctx, "k", false); return err },
"UpdateKey": func() error { _, err := svc.UpdateKey(ctx, "k", models.UpdateKeyRequest{}); return err },
"DeleteKey": func() error { return svc.DeleteKey(ctx, "k") },
"ImportKey": func() error { _, err := svc.ImportKey(ctx, models.ImportKeyRequest{}); return err },
"ListBuckets": func() error { _, err := svc.ListBuckets(ctx); return err },
"GetBucketInfo": func() error { _, err := svc.GetBucketInfo(ctx, "b"); return err },
"GetBucketInfoByAlias": func() error { _, err := svc.GetBucketInfoByAlias(ctx, "b"); return err },
"CreateBucket": func() error { _, err := svc.CreateBucket(ctx, models.CreateBucketAdminRequest{}); return err },
"UpdateBucket": func() error { _, err := svc.UpdateBucket(ctx, "b", models.UpdateBucketRequest{}); return err },
"DeleteBucket": func() error { return svc.DeleteBucket(ctx, "b") },
"AddBucketAlias": func() error { _, err := svc.AddBucketAlias(ctx, models.AddBucketAliasRequest{}); return err },
"RemoveBucketAlias": func() error { _, err := svc.RemoveBucketAlias(ctx, models.RemoveBucketAliasRequest{}); return err },
"AllowBucketKey": func() error { _, err := svc.AllowBucketKey(ctx, models.BucketKeyPermRequest{}); return err },
"DenyBucketKey": func() error { _, err := svc.DenyBucketKey(ctx, models.BucketKeyPermRequest{}); return err },
"GetClusterHealth": func() error { _, err := svc.GetClusterHealth(ctx); return err },
"GetClusterStatus": func() error { _, err := svc.GetClusterStatus(ctx); return err },
"GetClusterStatistics": func() error { _, err := svc.GetClusterStatistics(ctx); return err },
"GetNodeInfo": func() error { _, err := svc.GetNodeInfo(ctx, "n"); return err },
"GetNodeStatistics": func() error { _, err := svc.GetNodeStatistics(ctx, "n"); return err },
"HealthCheck": func() error { return svc.HealthCheck(ctx) },
}
for name, fn := range calls {
t.Run(name, func(t *testing.T) {
if err := fn(); err == nil {
t.Fatalf("%s: expected error on 500, got nil", name)
}
})
}
}
// TestDebugLogLevelEnablesSessionLog exercises the NewGarageV2AdminService
// branch that enables azuretls' session logging when logLevel == "debug".
func TestDebugLogLevelEnablesSessionLog(t *testing.T) {
svc := NewGarageV2AdminService(&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 := NewGarageV2AdminService(&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)
}
}
+71
View File
@@ -0,0 +1,71 @@
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 *GarageV2AdminService in admin_v2.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 = (*GarageV2AdminService)(nil)
_ AdminService = (*GarageV1AdminService)(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)
}
+149 -53
View File
@@ -1,6 +1,7 @@
package services
import (
"bytes"
"context"
"fmt"
"io"
@@ -20,11 +21,11 @@ import (
type S3Service struct {
client *minio.Client
config *config.GarageConfig
adminService *GarageAdminService
adminService AdminService
}
// NewS3Service creates a new S3 service instance using MinIO SDK
func NewS3Service(cfg *config.GarageConfig, adminService *GarageAdminService) *S3Service {
func NewS3Service(cfg *config.GarageConfig, adminService AdminService) *S3Service {
// Create MinIO client for Garage
// trim http or https from endpoint
if strings.HasPrefix(cfg.Endpoint, "http://") {
@@ -37,7 +38,6 @@ func NewS3Service(cfg *config.GarageConfig, adminService *GarageAdminService) *S
}
client, err := minio.New(cfg.Endpoint, &minio.Options{
//Creds: credentials.NewStaticV4(cfg.AccessKey, cfg.SecretKey, ""),
Secure: cfg.UseSSL,
Region: cfg.Region,
})
@@ -52,56 +52,77 @@ func NewS3Service(cfg *config.GarageConfig, adminService *GarageAdminService) *S
}
}
func (s *S3Service) getBucketCredentials(ctx context.Context, bucketName string) (*credentials.Credentials, error) {
cacheKey := fmt.Sprintf("key:%s", bucketName)
cacheData := utils.GlobalCache.Get(cacheKey)
// Operation is a bitmask of S3 permissions a call needs. Combine with bitwise
// OR (e.g. OpRead | OpWrite) when more than one is required.
type Operation byte
if cacheData != nil {
return cacheData.(*credentials.Credentials), nil
const (
OpRead Operation = 0x1
OpWrite Operation = 0x2
)
// satisfies reports whether perms grants every bit set in op.
func (op Operation) satisfies(perms models.BucketKeyPermission) bool {
if op&OpRead != 0 && !perms.Read {
return false
}
if op&OpWrite != 0 && !perms.Write {
return false
}
return true
}
func setKeyInCache(bucketName string, permissions models.BucketKeyPermission, creds *credentials.Credentials) {
canWrite := permissions.Write
canRead := permissions.Read
if canWrite {
key := fmt.Sprintf("key:%s:%d", bucketName, OpWrite)
utils.GlobalCache.Set(key, creds, time.Hour)
}
if canRead {
key := fmt.Sprintf("key:%s:%d", bucketName, OpRead)
utils.GlobalCache.Set(key, creds, time.Hour)
}
if canRead && canWrite {
key := fmt.Sprintf("key:%s:%d", bucketName, OpRead|OpWrite)
utils.GlobalCache.Set(key, creds, time.Hour)
}
}
func (s *S3Service) getBucketCredentials(ctx context.Context, bucketName string, op Operation) (*credentials.Credentials, error) {
cacheKey := fmt.Sprintf("key:%s:%d", bucketName, op)
if cached := utils.GlobalCache.Get(cacheKey); cached != nil {
return cached.(*credentials.Credentials), nil
}
// Get bucket info from Garage Admin API
bucketInfo, err := s.adminService.GetBucketInfoByAlias(ctx, bucketName)
if err != nil {
return nil, fmt.Errorf("failed to get bucket info: %w", err)
}
// Find a key with read and write permissions
var accessKeyID, secretAccessKey string
for _, keyInfo := range bucketInfo.Keys {
if !keyInfo.Permissions.Read || !keyInfo.Permissions.Write {
if !op.satisfies(keyInfo.Permissions) {
continue
}
// Get key details with secret
keyDetails, err := s.adminService.GetKeyInfo(ctx, keyInfo.AccessKeyID, true)
if err != nil {
return nil, fmt.Errorf("failed to get key info: %w", err)
}
if keyDetails.SecretAccessKey != nil {
accessKeyID = keyDetails.AccessKeyID
secretAccessKey = *keyDetails.SecretAccessKey
break
if err != nil || keyDetails.SecretAccessKey == nil {
continue
}
creds := credentials.NewStaticV4(keyDetails.AccessKeyID, *keyDetails.SecretAccessKey, "")
setKeyInCache(bucketName, keyInfo.Permissions, creds)
return creds, nil
}
if accessKeyID == "" || secretAccessKey == "" {
return nil, fmt.Errorf("no valid credentials found for bucket %s", bucketName)
}
// Create credentials
creds := credentials.NewStaticV4(accessKeyID, secretAccessKey, "")
// Cache credentials for 1 hour
utils.GlobalCache.Set(cacheKey, creds, time.Hour)
return creds, nil
return nil, fmt.Errorf("no valid credentials found for bucket %s", bucketName)
}
// getMinioClient creates a MinIO client for a specific bucket with dynamic credentials
func (s *S3Service) getMinioClient(ctx context.Context, bucketName string) (*minio.Client, error) {
creds, err := s.getBucketCredentials(ctx, bucketName)
// getMinioClient creates a MinIO client for a specific bucket with credentials
// that satisfy op.
func (s *S3Service) getMinioClient(ctx context.Context, bucketName string, op Operation) (*minio.Client, error) {
creds, err := s.getBucketCredentials(ctx, bucketName, op)
if err != nil {
return nil, fmt.Errorf("cannot get credentials for bucket %s: %w", bucketName, err)
}
@@ -151,7 +172,7 @@ func (s *S3Service) ListBuckets(ctx context.Context) (*models.BucketListResponse
// CreateBucket creates a new bucket in Garage
func (s *S3Service) CreateBucket(ctx context.Context, bucketName string) error {
client, err := s.getMinioClient(ctx, bucketName)
client, err := s.getMinioClient(ctx, bucketName, OpRead|OpWrite)
if err != nil {
return fmt.Errorf("failed to get MinIO client for bucket %s: %w", bucketName, err)
}
@@ -172,7 +193,7 @@ func (s *S3Service) CreateBucket(ctx context.Context, bucketName string) error {
// DeleteBucket deletes a bucket from Garage
func (s *S3Service) DeleteBucket(ctx context.Context, bucketName string) error {
client, err := s.getMinioClient(ctx, bucketName)
client, err := s.getMinioClient(ctx, bucketName, OpRead|OpWrite)
if err != nil {
return fmt.Errorf("failed to get MinIO client for bucket %s: %w", bucketName, err)
}
@@ -192,7 +213,7 @@ func (s *S3Service) DeleteBucket(ctx context.Context, bucketName string) error {
// ListObjects lists objects in a bucket with optional prefix filter and pagination
func (s *S3Service) ListObjects(ctx context.Context, bucketName, prefix string, maxKeys int, continuationToken string) (*models.ObjectListResponse, error) {
// Get bucket-specific MinIO client
client, err := s.getMinioClient(ctx, bucketName)
client, err := s.getMinioClient(ctx, bucketName, OpRead)
if err != nil {
return nil, fmt.Errorf("failed to get MinIO client for bucket %s: %w", bucketName, err)
}
@@ -219,9 +240,28 @@ func (s *S3Service) ListObjects(ctx context.Context, bucketName, prefix string,
return nil, fmt.Errorf("failed to list objects in bucket %s: %w", bucketName, err)
}
// Drop directory marker objects (zero-byte keys ending in "/"). Garage
// returns them in Contents, but the UI renders folders from Prefixes — a
// marker shown as both a folder and a file is confusing. Any marker not
// already covered by a CommonPrefix is promoted to Prefixes below.
contents := make([]minio.ObjectInfo, 0, len(result.Contents))
markerKeys := make([]string, 0)
for _, obj := range result.Contents {
if strings.HasSuffix(obj.Key, "/") && obj.Size == 0 {
// A marker whose key equals the current listing prefix is the
// folder itself — drop it entirely so it doesn't render as a
// nameless child of itself.
if obj.Key != prefix {
markerKeys = append(markerKeys, obj.Key)
}
continue
}
contents = append(contents, obj)
}
// Process objects from result.Contents
// Note: ListObjectsV2 doesn't return ContentType, so we need to fetch it separately
objects := make([]models.ObjectInfo, len(result.Contents))
objects := make([]models.ObjectInfo, len(contents))
// Use goroutines to fetch ContentType concurrently for better performance
type statResult struct {
@@ -232,7 +272,7 @@ func (s *S3Service) ListObjects(ctx context.Context, bucketName, prefix string,
statChan := make(chan statResult, len(result.Contents))
for i, obj := range result.Contents {
for i, obj := range contents {
go func(idx int, objKey string) {
// Fetch object metadata to get ContentType
stat, err := client.StatObject(ctx, bucketName, objKey, minio.StatObjectOptions{})
@@ -255,7 +295,7 @@ func (s *S3Service) ListObjects(ctx context.Context, bucketName, prefix string,
}
// Collect results from goroutines
for range result.Contents {
for range contents {
res := <-statChan
if res.err == nil {
objects[res.index].ContentType = res.contentType
@@ -265,9 +305,20 @@ func (s *S3Service) ListObjects(ctx context.Context, bucketName, prefix string,
close(statChan)
// Process folders from result.CommonPrefixes
prefixList := make([]string, 0, len(result.CommonPrefixes))
prefixList := make([]string, 0, len(result.CommonPrefixes)+len(markerKeys))
seen := make(map[string]struct{}, len(result.CommonPrefixes))
for _, p := range result.CommonPrefixes {
prefixList = append(prefixList, p.Prefix)
seen[p.Prefix] = struct{}{}
}
// Promote filtered directory markers into Prefixes so empty folders still
// appear in the listing.
for _, k := range markerKeys {
if _, ok := seen[k]; ok {
continue
}
prefixList = append(prefixList, k)
seen[k] = struct{}{}
}
return &models.ObjectListResponse{
@@ -283,7 +334,7 @@ func (s *S3Service) ListObjects(ctx context.Context, bucketName, prefix string,
// UploadObject uploads an object to a bucket
func (s *S3Service) UploadObject(ctx context.Context, bucketName, key string, body io.Reader, contentType string) (*models.ObjectUploadResponse, error) {
// Get bucket-specific MinIO client
client, err := s.getMinioClient(ctx, bucketName)
client, err := s.getMinioClient(ctx, bucketName, OpWrite)
if err != nil {
return nil, fmt.Errorf("failed to get MinIO client for bucket %s: %w", bucketName, err)
}
@@ -315,15 +366,54 @@ func (s *S3Service) UploadObject(ctx context.Context, bucketName, key string, bo
}, nil
}
// CreateDirectoryMarker creates a zero-byte object whose key ends with "/".
// Garage rejects the streaming path (size=-1) with "Empty body" because the
// MinIO client switches to multipart upload, which requires payload. Passing
// size=0 forces a single PutObject request with Content-Length: 0, which
// Garage accepts as a directory marker.
func (s *S3Service) CreateDirectoryMarker(ctx context.Context, bucketName, key string) (*models.ObjectUploadResponse, error) {
client, err := s.getMinioClient(ctx, bucketName, OpWrite)
if err != nil {
return nil, fmt.Errorf("failed to get MinIO client for bucket %s: %w", bucketName, err)
}
opts := minio.PutObjectOptions{ContentType: "application/x-directory"}
var info minio.UploadInfo
retryConfig := utils.DefaultRetryConfig()
err = utils.RetryWithBackoff(ctx, retryConfig, func() error {
var uploadErr error
info, uploadErr = client.PutObject(ctx, bucketName, key, bytes.NewReader(nil), 0, opts)
return uploadErr
})
if err != nil {
return nil, fmt.Errorf("failed to create directory %s in bucket %s: %w", key, bucketName, err)
}
return &models.ObjectUploadResponse{
Bucket: bucketName,
Key: key,
ETag: info.ETag,
Size: info.Size,
ContentType: opts.ContentType,
}, nil
}
// GetObject retrieves an object from a bucket
func (s *S3Service) GetObject(ctx context.Context, bucketName, key string) (io.ReadCloser, *models.ObjectInfo, error) {
// Get bucket-specific MinIO client
client, err := s.getMinioClient(ctx, bucketName, OpRead)
if err != nil {
return nil, nil, fmt.Errorf("failed to get MinIO client for bucket %s: %w", bucketName, err)
}
var object *minio.Object
// Call MinIO GetObject API with retry logic
retryConfig := utils.DefaultRetryConfig()
err := utils.RetryWithBackoff(ctx, retryConfig, func() error {
err = utils.RetryWithBackoff(ctx, retryConfig, func() error {
var getErr error
object, getErr = s.client.GetObject(ctx, bucketName, key, minio.GetObjectOptions{})
object, getErr = client.GetObject(ctx, bucketName, key, minio.GetObjectOptions{})
return getErr
})
if err != nil {
@@ -351,10 +441,16 @@ func (s *S3Service) GetObject(ctx context.Context, bucketName, key string) (io.R
// DeleteObject deletes an object from a bucket
func (s *S3Service) DeleteObject(ctx context.Context, bucketName, key string) error {
// Get bucket-specific MinIO client
client, err := s.getMinioClient(ctx, bucketName, OpWrite)
if err != nil {
return fmt.Errorf("failed to get MinIO client for bucket %s: %w", bucketName, err)
}
// Call MinIO RemoveObject API with retry logic
retryConfig := utils.DefaultRetryConfig()
err := utils.RetryWithBackoff(ctx, retryConfig, func() error {
return s.client.RemoveObject(ctx, bucketName, key, minio.RemoveObjectOptions{})
err = utils.RetryWithBackoff(ctx, retryConfig, func() error {
return client.RemoveObject(ctx, bucketName, key, minio.RemoveObjectOptions{})
})
if err != nil {
return fmt.Errorf("failed to delete object %s from bucket %s: %w", key, bucketName, err)
@@ -366,7 +462,7 @@ func (s *S3Service) DeleteObject(ctx context.Context, bucketName, key string) er
// ObjectExists checks if an object exists in a bucket
func (s *S3Service) ObjectExists(ctx context.Context, bucketName, key string) (bool, error) {
// Get bucket-specific MinIO client
client, err := s.getMinioClient(ctx, bucketName)
client, err := s.getMinioClient(ctx, bucketName, OpRead)
if err != nil {
return false, fmt.Errorf("failed to get MinIO client for bucket %s: %w", bucketName, err)
}
@@ -394,7 +490,7 @@ func (s *S3Service) ObjectExists(ctx context.Context, bucketName, key string) (b
// GetObjectMetadata retrieves metadata for an object without downloading it
func (s *S3Service) GetObjectMetadata(ctx context.Context, bucketName, key string) (*models.ObjectInfo, error) {
// Get bucket-specific MinIO client
client, err := s.getMinioClient(ctx, bucketName)
client, err := s.getMinioClient(ctx, bucketName, OpRead)
if err != nil {
return nil, fmt.Errorf("failed to get MinIO client for bucket %s: %w", bucketName, err)
}
@@ -430,7 +526,7 @@ func (s *S3Service) DeleteMultipleObjects(ctx context.Context, bucketName string
}
// Get bucket-specific MinIO client
client, err := s.getMinioClient(ctx, bucketName)
client, err := s.getMinioClient(ctx, bucketName, OpWrite)
if err != nil {
return fmt.Errorf("failed to get MinIO client for bucket %s: %w", bucketName, err)
}
@@ -465,7 +561,7 @@ func (s *S3Service) DeleteMultipleObjects(ctx context.Context, bucketName string
// This is useful for sharing files without exposing credentials
func (s *S3Service) GetPresignedURL(ctx context.Context, bucketName, key string, expiresIn time.Duration) (string, error) {
// Get bucket-specific MinIO client
client, err := s.getMinioClient(ctx, bucketName)
client, err := s.getMinioClient(ctx, bucketName, OpRead)
if err != nil {
return "", fmt.Errorf("failed to get MinIO client for bucket %s: %w", bucketName, err)
}
@@ -504,7 +600,7 @@ func (s *S3Service) UploadMultipleObjects(ctx context.Context, bucketName string
results := make([]UploadResult, len(files))
// Get bucket-specific MinIO client once for all uploads
client, err := s.getMinioClient(ctx, bucketName)
client, err := s.getMinioClient(ctx, bucketName, OpWrite)
if err != nil {
// If we can't get the client, all uploads fail
for i := range files {
+569
View File
@@ -0,0 +1,569 @@
package services
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"Noooste/garage-ui/internal/config"
"Noooste/garage-ui/internal/models"
"Noooste/garage-ui/pkg/utils"
)
// s3ErrorXML writes an S3-style error response that the MinIO SDK parses.
func s3ErrorXML(w http.ResponseWriter, status int, code, msg string) {
w.Header().Set("Content-Type", "application/xml")
w.WriteHeader(status)
_, _ = fmt.Fprintf(w, `<?xml version="1.0" encoding="UTF-8"?>
<Error><Code>%s</Code><Message>%s</Message><Resource>/</Resource><RequestId>x</RequestId></Error>`, code, msg)
}
// newS3TestService builds an S3Service whose Endpoint points at a single
// httptest.Server handling BOTH Garage admin calls (for credential lookup)
// and S3 data-plane requests. Admin requests are routed by path prefix
// `/v2/` and `/health`; everything else is treated as an S3 call and
// dispatched to s3Handler.
func newS3TestService(t *testing.T, s3Handler http.Handler) *S3Service {
t.Helper()
secret := "s3-test-secret"
adminMux := http.NewServeMux()
adminMux.HandleFunc("/v2/GetBucketInfo", func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(&models.GarageBucketInfo{
ID: "bid",
Keys: []models.BucketKeyInfo{
{AccessKeyID: "TESTAK", Permissions: models.BucketKeyPermission{Read: true, Write: true}},
},
})
})
adminMux.HandleFunc("/v2/GetKeyInfo", func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(&models.GarageKeyInfo{
AccessKeyID: "TESTAK",
SecretAccessKey: &secret,
})
})
combined := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/v2/") || r.URL.Path == "/health" {
adminMux.ServeHTTP(w, r)
return
}
s3Handler.ServeHTTP(w, r)
})
srv := httptest.NewServer(combined)
t.Cleanup(srv.Close)
admin := NewGarageV2AdminService(&config.GarageConfig{
AdminEndpoint: srv.URL,
AdminToken: "test",
}, "")
// strip scheme for S3 endpoint (NewS3Service does this itself if http:// prefix)
s3 := NewS3Service(&config.GarageConfig{
Endpoint: srv.URL, // http://127.0.0.1:NNNN
Region: "garage",
}, admin)
return s3
}
// uniqueBucket2 returns a per-test bucket name so GlobalCache doesn't leak
// credentials between tests.
func uniqueBucket2(t *testing.T) string {
t.Helper()
name := "b-" + strings.ReplaceAll(t.Name(), "/", "-")
t.Cleanup(func() { utils.GlobalCache.Delete("key:" + name) })
return name
}
// fixedRequestCounter returns an http.Handler that always replies with the
// given S3 error, and counts requests.
func errS3Handler(status int, code string) (http.Handler, *int) {
var count int
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
count++
s3ErrorXML(w, status, code, code)
}), &count
}
func TestS3_ListBuckets_ServerError(t *testing.T) {
h, _ := errS3Handler(http.StatusInternalServerError, "InternalError")
s3 := newS3TestService(t, h)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
_, err := s3.ListBuckets(ctx)
if err == nil {
t.Fatal("expected error from ListBuckets, got nil")
}
if !strings.Contains(err.Error(), "failed to list buckets") {
t.Errorf("error %v should wrap 'failed to list buckets'", err)
}
}
func TestS3_CreateBucket_ServerError(t *testing.T) {
h, _ := errS3Handler(http.StatusConflict, "BucketAlreadyExists")
s3 := newS3TestService(t, h)
_ = uniqueBucket2(t)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
err := s3.CreateBucket(ctx, "b-TestS3_CreateBucket_ServerError")
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "failed to create bucket") {
t.Errorf("error = %v, want wrap 'failed to create bucket'", err)
}
}
func TestS3_DeleteBucket_ServerError(t *testing.T) {
h, _ := errS3Handler(http.StatusNotFound, "NoSuchBucket")
s3 := newS3TestService(t, h)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
err := s3.DeleteBucket(ctx, "b-TestS3_DeleteBucket_ServerError")
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "failed to delete bucket") {
t.Errorf("error = %v", err)
}
}
func TestS3_ListObjects_ServerError(t *testing.T) {
h, _ := errS3Handler(http.StatusForbidden, "AccessDenied")
s3 := newS3TestService(t, h)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
_, err := s3.ListObjects(ctx, "b-TestS3_ListObjects_ServerError", "", 0, "")
if err == nil {
t.Fatal("expected error from ListObjects, got nil")
}
if !strings.Contains(err.Error(), "failed to list objects") {
t.Errorf("error = %v", err)
}
}
func TestS3_UploadObject_ServerError(t *testing.T) {
h, _ := errS3Handler(http.StatusForbidden, "AccessDenied")
s3 := newS3TestService(t, h)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
_, err := s3.UploadObject(ctx, "b-TestS3_UploadObject_ServerError", "k", bytes.NewReader([]byte("hi")), "text/plain")
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "failed to upload object") {
t.Errorf("error = %v", err)
}
}
func TestS3_CreateDirectoryMarker_ServerError(t *testing.T) {
h, _ := errS3Handler(http.StatusForbidden, "AccessDenied")
s3 := newS3TestService(t, h)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
_, err := s3.CreateDirectoryMarker(ctx, "b-TestS3_CreateDirectoryMarker_ServerError", "folder/")
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "failed to create directory") {
t.Errorf("error = %v", err)
}
}
func TestS3_GetObject_ServerError(t *testing.T) {
h, _ := errS3Handler(http.StatusNotFound, "NoSuchKey")
s3 := newS3TestService(t, h)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
_, _, err := s3.GetObject(ctx, "b-TestS3_GetObject_ServerError", "missing")
if err == nil {
t.Fatal("expected error, got nil")
}
}
func TestS3_DeleteObject_ServerError(t *testing.T) {
h, _ := errS3Handler(http.StatusForbidden, "AccessDenied")
s3 := newS3TestService(t, h)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
err := s3.DeleteObject(ctx, "b-TestS3_DeleteObject_ServerError", "k")
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "failed to delete object") {
t.Errorf("error = %v", err)
}
}
func TestS3_ObjectExists_NoSuchKeyReturnsFalseNil(t *testing.T) {
h, _ := errS3Handler(http.StatusNotFound, "NoSuchKey")
s3 := newS3TestService(t, h)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
exists, err := s3.ObjectExists(ctx, "b-TestS3_ObjectExists_NoSuchKeyReturnsFalseNil", "k")
if err != nil {
t.Fatalf("ObjectExists returned error for NoSuchKey: %v", err)
}
if exists {
t.Error("exists should be false for NoSuchKey")
}
}
func TestS3_ObjectExists_OtherErrorPropagates(t *testing.T) {
h, _ := errS3Handler(http.StatusForbidden, "AccessDenied")
s3 := newS3TestService(t, h)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
_, err := s3.ObjectExists(ctx, "b-TestS3_ObjectExists_OtherErrorPropagates", "k")
if err == nil {
t.Fatal("expected error for AccessDenied, got nil")
}
}
func TestS3_GetObjectMetadata_ServerError(t *testing.T) {
h, _ := errS3Handler(http.StatusNotFound, "NoSuchKey")
s3 := newS3TestService(t, h)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
_, err := s3.GetObjectMetadata(ctx, "b-TestS3_GetObjectMetadata_ServerError", "k")
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "failed to get metadata") {
t.Errorf("error = %v", err)
}
}
func TestS3_DeleteMultipleObjects_EmptyKeysIsNoop(t *testing.T) {
// No S3 handler should be called; use a handler that fails if invoked.
called := false
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
s3ErrorXML(w, http.StatusInternalServerError, "ShouldNotHappen", "")
})
s3 := newS3TestService(t, h)
if err := s3.DeleteMultipleObjects(context.Background(), "whatever", nil); err != nil {
t.Fatalf("empty keys should return nil, got %v", err)
}
if called {
t.Error("S3 handler was invoked for empty-keys call")
}
}
func TestS3_DeleteMultipleObjects_ServerErrorPropagates(t *testing.T) {
h, _ := errS3Handler(http.StatusForbidden, "AccessDenied")
s3 := newS3TestService(t, h)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
err := s3.DeleteMultipleObjects(ctx, "b-TestS3_DeleteMultipleObjects_ServerErrorPropagates", []string{"a", "b"})
if err == nil {
t.Fatal("expected error, got nil")
}
}
func TestS3_GetPresignedURL_ReturnsURLWithoutServerCall(t *testing.T) {
// Presign is purely local (no network round-trip). Any handler suffices.
called := false
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
})
s3 := newS3TestService(t, h)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
got, err := s3.GetPresignedURL(ctx, "b-TestS3_GetPresignedURL_ReturnsURLWithoutServerCall", "k", 10*time.Minute)
if err != nil {
t.Fatalf("GetPresignedURL: %v", err)
}
u, perr := url.Parse(got)
if perr != nil {
t.Fatalf("returned URL is not parseable: %v", perr)
}
if u.Scheme == "" || u.Host == "" {
t.Errorf("presigned URL missing scheme/host: %q", got)
}
if !strings.Contains(u.RawQuery, "X-Amz-Signature") {
t.Errorf("presigned URL should contain X-Amz-Signature, got %q", got)
}
if called {
t.Error("presign should not make a network call")
}
}
// listBucketResultXML produces a ListBucketResult XML body that MinIO
// parses. ListObjectsV2 is keyed on the `list-type=2` query parameter.
func listBucketResultXML(bucket string, isTruncated bool, nextToken string, contents []struct {
Key string
Size int64
LastModified string
ETag string
}, commonPrefixes []string) string {
var b strings.Builder
b.WriteString(`<?xml version="1.0" encoding="UTF-8"?>`)
b.WriteString(`<ListBucketResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">`)
fmt.Fprintf(&b, `<Name>%s</Name>`, bucket)
b.WriteString(`<Prefix></Prefix>`)
fmt.Fprintf(&b, `<KeyCount>%d</KeyCount>`, len(contents))
b.WriteString(`<MaxKeys>1000</MaxKeys>`)
fmt.Fprintf(&b, `<IsTruncated>%t</IsTruncated>`, isTruncated)
if nextToken != "" {
fmt.Fprintf(&b, `<NextContinuationToken>%s</NextContinuationToken>`, nextToken)
}
for _, c := range contents {
lm := c.LastModified
if lm == "" {
lm = "2024-01-01T00:00:00.000Z"
}
etag := c.ETag
if etag == "" {
etag = "d41d8cd98f00b204e9800998ecf8427e"
}
fmt.Fprintf(&b, `<Contents><Key>%s</Key><LastModified>%s</LastModified><ETag>"%s"</ETag><Size>%d</Size><StorageClass>STANDARD</StorageClass></Contents>`,
c.Key, lm, etag, c.Size)
}
for _, p := range commonPrefixes {
fmt.Fprintf(&b, `<CommonPrefixes><Prefix>%s</Prefix></CommonPrefixes>`, p)
}
b.WriteString(`</ListBucketResult>`)
return b.String()
}
// s3ListHandler routes ListObjectsV2 (GET with list-type=2) to listBody
// and StatObject (HEAD) to a 200 response whose headers reflect statHeaders.
func s3ListHandler(listBody string, statHeaders map[string]string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodHead {
for k, v := range statHeaders {
w.Header().Set(k, v)
}
if _, ok := statHeaders["Content-Length"]; !ok {
w.Header().Set("Content-Length", "0")
}
w.WriteHeader(http.StatusOK)
return
}
// Treat any GET as a ListObjectsV2 request.
w.Header().Set("Content-Type", "application/xml")
w.WriteHeader(http.StatusOK)
_, _ = io.WriteString(w, listBody)
})
}
func TestS3_ListObjects_EmptyResult(t *testing.T) {
xml := listBucketResultXML("b", false, "", nil, nil)
s3 := newS3TestService(t, s3ListHandler(xml, nil))
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
got, err := s3.ListObjects(ctx, "b-TestS3_ListObjects_EmptyResult", "", 0, "")
if err != nil {
t.Fatalf("ListObjects: %v", err)
}
if got.Count != 0 || len(got.Objects) != 0 || len(got.Prefixes) != 0 {
t.Errorf("expected empty listing, got %+v", got)
}
}
func TestS3_ListObjects_ObjectsAndPrefixes(t *testing.T) {
contents := []struct {
Key string
Size int64
LastModified string
ETag string
}{
{Key: "file.txt", Size: 10},
}
xml := listBucketResultXML("b", false, "", contents, []string{"folder/"})
s3 := newS3TestService(t, s3ListHandler(xml, map[string]string{
"Content-Type": "text/plain",
"Content-Length": "10",
}))
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
got, err := s3.ListObjects(ctx, "b-TestS3_ListObjects_ObjectsAndPrefixes", "", 0, "")
if err != nil {
t.Fatalf("ListObjects: %v", err)
}
if got.Count != 1 || got.Objects[0].Key != "file.txt" {
t.Errorf("objects = %+v", got.Objects)
}
if len(got.Prefixes) != 1 || got.Prefixes[0] != "folder/" {
t.Errorf("prefixes = %+v", got.Prefixes)
}
// ContentType from StatObject may or may not round-trip depending on
// signature validation in the MinIO client; don't assert on it here.
}
func TestS3_ListObjects_DirectoryMarkerPromotedToPrefix(t *testing.T) {
// A zero-byte key ending in "/" in Contents must be dropped from
// Objects and promoted to Prefixes (unless already covered).
contents := []struct {
Key string
Size int64
LastModified string
ETag string
}{
{Key: "empty-folder/", Size: 0},
{Key: "already/", Size: 0}, // duplicate of CommonPrefix — must not duplicate
{Key: "real.txt", Size: 5},
}
xml := listBucketResultXML("b", true, "tokenXYZ", contents, []string{"already/"})
s3 := newS3TestService(t, s3ListHandler(xml, map[string]string{"Content-Length": "5"}))
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
got, err := s3.ListObjects(ctx, "b-TestS3_ListObjects_DirectoryMarkerPromotedToPrefix", "", 0, "")
if err != nil {
t.Fatalf("ListObjects: %v", err)
}
if got.Count != 1 || got.Objects[0].Key != "real.txt" {
t.Errorf("Objects should contain only real.txt, got %+v", got.Objects)
}
// Prefixes should contain "already/" (from CommonPrefix) and "empty-folder/"
// (promoted from Contents). "already/" must not appear twice.
count := map[string]int{}
for _, p := range got.Prefixes {
count[p]++
}
if count["already/"] != 1 {
t.Errorf("Prefixes contains 'already/' %d times, want 1: %v", count["already/"], got.Prefixes)
}
if count["empty-folder/"] != 1 {
t.Errorf("Prefixes missing 'empty-folder/': %v", got.Prefixes)
}
if !got.IsTruncated || got.NextContinuationToken != "tokenXYZ" {
t.Errorf("pagination fields not propagated: IsTruncated=%v Token=%q", got.IsTruncated, got.NextContinuationToken)
}
}
func TestS3_ListObjects_MarkerMatchingPrefixIsDropped(t *testing.T) {
// When listing a specific prefix, a marker whose key == the listing
// prefix is the folder itself — it must not render as a child of itself.
contents := []struct {
Key string
Size int64
LastModified string
ETag string
}{
{Key: "mydir/", Size: 0}, // matches prefix — must be dropped entirely
}
xml := listBucketResultXML("b", false, "", contents, nil)
s3 := newS3TestService(t, s3ListHandler(xml, nil))
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
got, err := s3.ListObjects(ctx, "b-TestS3_ListObjects_MarkerMatchingPrefixIsDropped", "mydir/", 0, "")
if err != nil {
t.Fatalf("ListObjects: %v", err)
}
if len(got.Objects) != 0 || len(got.Prefixes) != 0 {
t.Errorf("marker equal to prefix should be dropped entirely, got %+v", got)
}
}
func TestS3_ListObjects_StatObjectFailureLeavesContentTypeEmpty(t *testing.T) {
contents := []struct {
Key string
Size int64
LastModified string
ETag string
}{
{Key: "f.bin", Size: 100},
}
xml := listBucketResultXML("b", false, "", contents, nil)
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodHead {
// StatObject fails — return 403.
w.WriteHeader(http.StatusForbidden)
return
}
w.Header().Set("Content-Type", "application/xml")
w.WriteHeader(http.StatusOK)
_, _ = io.WriteString(w, xml)
})
s3 := newS3TestService(t, h)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
got, err := s3.ListObjects(ctx, "b-TestS3_ListObjects_StatObjectFailureLeavesContentTypeEmpty", "", 0, "")
if err != nil {
t.Fatalf("ListObjects: %v", err)
}
if got.Count != 1 || got.Objects[0].Key != "f.bin" {
t.Fatalf("unexpected object list %+v", got.Objects)
}
if got.Objects[0].ContentType != "" {
t.Errorf("ContentType = %q, want empty on StatObject failure", got.Objects[0].ContentType)
}
}
func TestS3_UploadMultipleObjects_PerFileFailuresRecorded(t *testing.T) {
h, _ := errS3Handler(http.StatusForbidden, "AccessDenied")
s3 := newS3TestService(t, h)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
results := s3.UploadMultipleObjects(ctx, "b-TestS3_UploadMultipleObjects_PerFileFailuresRecorded", []struct {
Key string
Body io.Reader
ContentType string
}{
{Key: "a", Body: bytes.NewReader([]byte("hello")), ContentType: "text/plain"},
{Key: "b", Body: bytes.NewReader([]byte("world")), ContentType: "text/plain"},
})
if len(results) != 2 {
t.Fatalf("len(results) = %d, want 2", len(results))
}
for i, r := range results {
if r.Success {
t.Errorf("result[%d] should not be successful: %+v", i, r)
}
if r.Error == nil {
t.Errorf("result[%d] should have Error set", i)
}
}
}
+515
View File
@@ -0,0 +1,515 @@
package services
import (
"context"
"encoding/json"
"fmt"
"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 GarageV2AdminService 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 := NewGarageV2AdminService(&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() {
for _, op := range []Operation{OpRead, OpWrite, OpRead | OpWrite} {
utils.GlobalCache.Delete(fmt.Sprintf("key:%s:%d", name, op))
}
})
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, OpRead|OpWrite)
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, OpRead|OpWrite); 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_RWKeyWarmsAllTiers(t *testing.T) {
bucket := uniqueBucket(t)
secret := "rw-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: "RW", 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: "RW",
SecretAccessKey: &secret,
})
})
s3, _ := adminBackedS3(t, mux)
// Prime via OpRead — should populate OpRead, OpWrite, and OpRead|OpWrite.
if _, err := s3.getBucketCredentials(context.Background(), bucket, OpRead); err != nil {
t.Fatalf("prime OpRead: %v", err)
}
for _, op := range []Operation{OpWrite, OpRead | OpWrite, OpRead} {
if _, err := s3.getBucketCredentials(context.Background(), bucket, op); err != nil {
t.Fatalf("op %d: %v", op, err)
}
}
if bucketCalls != 1 {
t.Errorf("GetBucketInfo called %d times, want 1 (RW key should warm every tier)", bucketCalls)
}
if keyCalls != 1 {
t.Errorf("GetKeyInfo called %d times, want 1", keyCalls)
}
}
// A read-only key must NOT populate the write or RW cache slots, otherwise an
// OpWrite call would receive credentials the cluster will reject.
func TestGetBucketCredentials_ReadOnlyKeyDoesNotPoisonWriteCache(t *testing.T) {
bucket := uniqueBucket(t)
secret := "ro-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}},
},
})
})
mux.HandleFunc("/v2/GetKeyInfo", func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(&models.GarageKeyInfo{
AccessKeyID: "READ-ONLY",
SecretAccessKey: &secret,
})
})
s3, _ := adminBackedS3(t, mux)
// Warm OpRead cache with the read-only key.
if _, err := s3.getBucketCredentials(context.Background(), bucket, OpRead); err != nil {
t.Fatalf("prime OpRead: %v", err)
}
// OpWrite must still fail — the read-only key must not have leaked into
// the write cache slot.
if _, err := s3.getBucketCredentials(context.Background(), bucket, OpWrite); err == nil {
t.Fatal("OpWrite served credentials from a read-only key; cache was poisoned")
}
}
func TestGetBucketCredentials_OpReadWriteSkipsKeysMissingAnyBit(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, OpRead|OpWrite)
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)
}
}
// Regression test for issue #44: read-only buckets must remain browsable when
// no read+write key is assigned. Before the fix, this case returned
// "no valid credentials found for bucket music" and the UI broke entirely.
func TestGetBucketCredentials_ReadOnlyFallsBackToReadKey(t *testing.T) {
bucket := uniqueBucket(t)
secret := "ro-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}},
},
})
})
mux.HandleFunc("/v2/GetKeyInfo", func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(&models.GarageKeyInfo{
AccessKeyID: "READ-ONLY",
SecretAccessKey: &secret,
})
})
s3, _ := adminBackedS3(t, mux)
creds, err := s3.getBucketCredentials(context.Background(), bucket, OpRead)
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 != "READ-ONLY" {
t.Errorf("AccessKeyID = %q, want READ-ONLY", v.AccessKeyID)
}
}
// Even with only a read-only key available, asking for write credentials must
// still fail loudly so uploads/deletes return a meaningful error instead of
// silently using a key that the cluster will reject.
func TestGetBucketCredentials_ReadOnlyBucketRejectsWriteRequest(t *testing.T) {
bucket := uniqueBucket(t)
secret := "ro-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}},
},
})
})
mux.HandleFunc("/v2/GetKeyInfo", func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(&models.GarageKeyInfo{
AccessKeyID: "READ-ONLY",
SecretAccessKey: &secret,
})
})
s3, _ := adminBackedS3(t, mux)
_, err := s3.getBucketCredentials(context.Background(), bucket, OpRead|OpWrite)
if err == nil {
t.Fatal("expected error when only a read-only key exists, got nil")
}
if !strings.Contains(err.Error(), "no valid credentials") {
t.Errorf("expected 'no valid credentials' in error, got %v", err)
}
}
// Mirror of issue #44 for write-only buckets: uploads must still succeed with a
// write-only key, even though no key grants read access.
func TestGetBucketCredentials_WriteOnlyFallsBackToWriteKey(t *testing.T) {
bucket := uniqueBucket(t)
secret := "wo-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: "WRITE-ONLY", Permissions: models.BucketKeyPermission{Read: false, Write: true}},
},
})
})
mux.HandleFunc("/v2/GetKeyInfo", func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(&models.GarageKeyInfo{
AccessKeyID: "WRITE-ONLY",
SecretAccessKey: &secret,
})
})
s3, _ := adminBackedS3(t, mux)
creds, err := s3.getBucketCredentials(context.Background(), bucket, OpWrite)
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 != "WRITE-ONLY" {
t.Errorf("AccessKeyID = %q, want WRITE-ONLY", 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, OpRead)
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, OpRead|OpWrite)
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, OpRead|OpWrite)
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)
}
}
+108 -40
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
@@ -55,27 +59,65 @@ 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")
garageTomlPath := flag.String("garage-toml", "", "Path to garage.toml file (extracts Garage connection values)")
flag.Parse()
// Load configuration
log.Printf("Loading configuration from: %s", *configPath)
cfg, err := config.Load(*configPath)
if err != nil {
log.Fatalf("Failed to load configuration: %v", err)
// Env var fallback for --garage-toml
if *garageTomlPath == "" {
if envPath := os.Getenv("GARAGE_UI_GARAGE_TOML"); envPath != "" {
*garageTomlPath = envPath
}
}
log.Printf("Starting Garage UI Backend v%s in %s mode", version, cfg.Server.Environment)
// Build load options
var loadOpts []config.LoadOption
if *garageTomlPath != "" {
loadOpts = append(loadOpts, config.WithGarageToml(*garageTomlPath))
}
// Load configuration first (before initializing logger)
cfg, err := config.Load(*configPath, loadOpts...)
if err != nil {
// 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")
}
// 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")
if *garageTomlPath != "" {
logger.Warn().
Str("s3_endpoint", cfg.Garage.Endpoint).
Str("admin_endpoint", cfg.Garage.AdminEndpoint).
Msg("Endpoints inferred from garage.toml bind addresses — override with GARAGE_UI_GARAGE_ENDPOINT / GARAGE_UI_GARAGE_ADMIN_ENDPOINT for remote/container setups")
}
// Initialize services
log.Println("Initializing Garage Admin service...")
adminService := services.NewGarageAdminService(&cfg.Garage)
logger.Info().Msg("Detecting Garage API version")
adminResult, err := services.NewAdminService(&cfg.Garage, cfg.Logging.Level)
if err != nil {
logger.Fatal().Err(err).Msg("Failed to connect to Garage admin API")
}
adminService := adminResult.Service
capabilitiesHandler := handlers.NewCapabilitiesHandler(adminResult.APIVersion, adminResult.Capabilities)
log.Println("Initializing S3 service...")
logger.Info().Msg("Initializing S3 service")
s3Service := services.NewS3Service(&cfg.Garage, adminService)
// Determine enabled auth methods for logging
@@ -86,13 +128,16 @@ func main() {
if cfg.Auth.OIDC.Enabled {
authMethods = append(authMethods, "oidc")
}
if cfg.Auth.Token.Enabled {
authMethods = append(authMethods, "token")
}
if len(authMethods) == 0 {
authMethods = append(authMethods, "none")
}
log.Printf("Initializing authentication service (enabled: %v)...", authMethods)
logger.Info().Strs("enabled_methods", authMethods).Msg("Initializing authentication service")
authService, err := auth.NewAuthService(&cfg.Auth, &cfg.Server)
if err != nil {
log.Fatalf("Failed to initialize auth service: %v", err)
logger.Fatal().Err(err).Msg("Failed to initialize auth service")
}
// Initialize handlers
@@ -121,27 +166,41 @@ func main() {
writeBufferSize = 4096 // 4KB default
}
log.Printf("Server limits - Max body: %d bytes (%.2fMB), Max header: %d bytes (%.2fKB)",
maxBodySize, float64(maxBodySize)/(1024*1024),
maxHeaderSize, float64(maxHeaderSize)/1024)
logger.Info().
Int64("max_body_bytes", maxBodySize).
Float64("max_body_mb", float64(maxBodySize)/(1024*1024)).
Int("max_header_bytes", maxHeaderSize).
Float64("max_header_kb", float64(maxHeaderSize)/1024).
Msg("Server request limits configured")
// Create Fiber app with configuration
app := fiber.New(fiber.Config{
AppName: "Garage UI Backend v" + version,
AppName: "Garage UI Backend | Version: " + version,
BodyLimit: int(maxBodySize),
ReadBufferSize: readBufferSize,
WriteBufferSize: writeBufferSize,
ErrorHandler: customErrorHandler,
})
// Apply global middleware
app.Use(recover.New()) // Panic recovery
app.Use(logger.New(logger.Config{
Format: "[${time}] ${status} - ${method} ${path} (${latency})\n",
// Apply global middleware (order matters):
// 1. recover — must be outermost so panics become 500s.
// 2. RequestID — mints/reads X-Request-ID before any logger needs it.
// 3. Logging — builds per-request zerolog logger + emits access log.
// Auth middleware is installed per-route inside routes.SetupRoutes.
app.Use(recover.New(recover.Config{
EnableStackTrace: true,
StackTraceHandler: func(c fiber.Ctx, e interface{}) {
logger.FromCtx(c.Context()).Error().
Interface("panic", e).
Bytes("stack", debug.Stack()).
Msg("panic_recovered")
},
}))
app.Use(appmw.RequestID())
app.Use(appmw.Logging(log.Logger))
// Setup routes
log.Println("Setting up routes...")
logger.Info().Msg("Setting up routes")
routes.SetupRoutes(
app,
cfg,
@@ -152,47 +211,56 @@ func main() {
userHandler,
clusterHandler,
monitoringHandler,
capabilitiesHandler,
)
// 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("network", fiber.NetworkTCP).
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)
if err := app.Listen(addr, fiber.ListenConfig{ListenerNetwork: fiber.NetworkTCP}); err != nil {
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)
}
}
+10
View File
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"net"
"strings"
"syscall"
"time"
)
@@ -50,6 +51,15 @@ func IsConnectionRefused(err error) bool {
return true
}
// Some HTTP clients (e.g. azuretls) collapse the syscall error into a
// plain string before returning. Fall back to substring matching so the
// retry path still triggers for those wrappers. "connectex" is the
// Windows variant emitted by the Go runtime.
msg := err.Error()
if strings.Contains(msg, "connection refused") || strings.Contains(msg, "actively refused") {
return true
}
return false
}
+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,39 @@
---
services:
garage:
image: dxflrs/garage:v2.1.0
volumes:
- ./garage.toml:/etc/garage.toml:ro
tmpfs:
- /var/lib/garage/meta
- /var/lib/garage/data
healthcheck:
test: ["CMD", "/garage", "status"]
interval: 2s
timeout: 2s
retries: 30
start_period: 2s
backend:
build:
context: ../../..
dockerfile: Dockerfile
environment:
GARAGE_UI_SERVER_HOST: "0.0.0.0"
GARAGE_UI_SERVER_PORT: "8080"
GARAGE_UI_SERVER_ENVIRONMENT: "development"
GARAGE_UI_GARAGE_ENDPOINT: "garage:3900"
GARAGE_UI_GARAGE_REGION: "garage"
GARAGE_UI_GARAGE_ADMIN_ENDPOINT: "http://garage:3903"
GARAGE_UI_GARAGE_ADMIN_TOKEN: "smoke-admin-token-do-not-use-in-prod"
GARAGE_UI_AUTH_ADMIN_ENABLED: "true"
GARAGE_UI_AUTH_ADMIN_USERNAME: "smokeadmin"
GARAGE_UI_AUTH_ADMIN_PASSWORD: "smokepass"
GARAGE_UI_CORS_ENABLED: "false"
GARAGE_UI_LOGGING_LEVEL: "info"
GARAGE_UI_LOGGING_FORMAT: "json"
ports:
- "127.0.0.1:18080:8080"
depends_on:
garage:
condition: service_healthy
+23
View File
@@ -0,0 +1,23 @@
metadata_dir = "/var/lib/garage/meta"
data_dir = "/var/lib/garage/data"
db_engine = "lmdb"
replication_factor = 1
rpc_bind_addr = "[::]:3901"
rpc_public_addr = "127.0.0.1:3901"
rpc_secret = "0000000000000000000000000000000000000000000000000000000000000000"
[s3_api]
s3_region = "garage"
api_bind_addr = "[::]:3900"
root_domain = ".s3.garage"
[s3_web]
bind_addr = "[::]:3902"
root_domain = ".web.garage"
index = "index.html"
[admin]
api_bind_addr = "[::]:3903"
admin_token = "smoke-admin-token-do-not-use-in-prod"
+388
View File
@@ -0,0 +1,388 @@
//go:build smoke
package smoke_test
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
)
const (
composeProject = "garage-ui-smoke"
backendBaseURL = "http://127.0.0.1:18080"
adminToken = "smoke-admin-token-do-not-use-in-prod"
adminUsername = "smokeadmin"
adminPassword = "smokepass"
readyTimeout = 90 * time.Second
readyPollEvery = 1 * time.Second
)
func composeFile(t testing.TB) string {
t.Helper()
_, thisFile, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("runtime.Caller failed")
}
return filepath.Join(filepath.Dir(thisFile), "docker-compose.test.yml")
}
func runCompose(t testing.TB, args ...string) string {
t.Helper()
full := append([]string{"compose", "-p", composeProject, "-f", composeFile(t)}, args...)
cmd := exec.Command("docker", full...)
var out bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &out
if err := cmd.Run(); err != nil {
t.Fatalf("docker %s failed: %v\n%s", strings.Join(full, " "), err, out.String())
}
return out.String()
}
func composeExec(service string, argv ...string) (string, error) {
full := append([]string{"compose", "-p", composeProject, "-f", mustComposeFile(), "exec", "-T", service}, argv...)
cmd := exec.Command("docker", full...)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return "", fmt.Errorf("docker %s: %w\nstderr:\n%s", strings.Join(full, " "), err, stderr.String())
}
return stdout.String(), nil
}
func mustComposeFile() string {
_, thisFile, _, ok := runtime.Caller(0)
if !ok {
panic("runtime.Caller failed")
}
return filepath.Join(filepath.Dir(thisFile), "docker-compose.test.yml")
}
func waitForHTTP(ctx context.Context, url string, timeout time.Duration) error {
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return err
}
resp, err := http.DefaultClient.Do(req)
if err == nil {
_ = resp.Body.Close()
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return nil
}
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(readyPollEvery):
}
}
return fmt.Errorf("timeout waiting for %s", url)
}
func initGarageLayout() error {
nodeOut, err := composeExec("garage", "/garage", "node", "id", "-q")
if err != nil {
return fmt.Errorf("garage node id: %w", err)
}
nodeID := strings.TrimSpace(nodeOut)
if i := strings.Index(nodeID, "@"); i > 0 {
nodeID = nodeID[:i]
}
if nodeID == "" {
return fmt.Errorf("empty node id from garage node id")
}
if _, err := composeExec("garage", "/garage", "layout", "assign", "-z", "dc1", "-c", "1G", nodeID); err != nil {
return fmt.Errorf("garage layout assign: %w", err)
}
if _, err := composeExec("garage", "/garage", "layout", "apply", "--version", "1"); err != nil {
return fmt.Errorf("garage layout apply: %w", err)
}
return nil
}
func TestMain(m *testing.M) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
fmt.Fprintln(os.Stderr, "[smoke] bringing up garage (waiting for healthcheck)...")
runComposeNoT("up", "-d", "--wait", "garage")
cleanup := func() {
fmt.Fprintln(os.Stderr, "[smoke] tearing down compose...")
_ = exec.Command("docker", "compose", "-p", composeProject, "-f", mustComposeFile(), "logs").Run()
_ = exec.Command("docker", "compose", "-p", composeProject, "-f", mustComposeFile(), "down", "-v").Run()
}
fmt.Fprintln(os.Stderr, "[smoke] initializing garage cluster layout...")
if err := initGarageLayout(); err != nil {
fmt.Fprintf(os.Stderr, "[smoke] layout init failed: %v\n", err)
cleanup()
os.Exit(1)
}
fmt.Fprintln(os.Stderr, "[smoke] starting backend...")
runComposeNoT("up", "-d", "backend")
if err := waitForHTTP(ctx, backendBaseURL+"/health", readyTimeout); err != nil {
fmt.Fprintf(os.Stderr, "[smoke] backend not ready: %v\n", err)
cleanup()
os.Exit(1)
}
code := m.Run()
if code != 0 {
fmt.Fprintln(os.Stderr, "[smoke] tests failed — dumping compose logs")
logCmd := exec.Command("docker", "compose", "-p", composeProject, "-f", mustComposeFile(), "logs", "--no-color")
logCmd.Stdout = os.Stderr
logCmd.Stderr = os.Stderr
_ = logCmd.Run()
}
_ = exec.Command("docker", "compose", "-p", composeProject, "-f", mustComposeFile(), "down", "-v").Run()
os.Exit(code)
}
func runComposeNoT(args ...string) {
full := append([]string{"compose", "-p", composeProject, "-f", mustComposeFile()}, args...)
cmd := exec.Command("docker", full...)
cmd.Stdout = os.Stderr
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
fmt.Fprintf(os.Stderr, "[smoke] docker %s failed: %v\n", strings.Join(full, " "), err)
os.Exit(1)
}
}
type testState struct {
client *http.Client
token string
bucketName string
objectKey string
sourceBody []byte
accessKeyID string
}
func (s *testState) do(t *testing.T, method, path string, body io.Reader, contentType string) *http.Response {
t.Helper()
req, err := http.NewRequest(method, backendBaseURL+path, body)
if err != nil {
t.Fatalf("build request: %v", err)
}
if s.token != "" {
req.Header.Set("Authorization", "Bearer "+s.token)
}
if contentType != "" {
req.Header.Set("Content-Type", contentType)
}
resp, err := s.client.Do(req)
if err != nil {
t.Fatalf("%s %s: %v", method, path, err)
}
return resp
}
func readBody(t *testing.T, resp *http.Response) []byte {
t.Helper()
defer resp.Body.Close()
b, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("read body: %v", err)
}
return b
}
func TestSmokeGoldenPath(t *testing.T) {
sourcePath := filepath.Join(filepath.Dir(mustComposeFile()), "testdata", "small.bin")
source, err := os.ReadFile(sourcePath)
if err != nil {
t.Fatalf("read fixture: %v", err)
}
state := &testState{
client: &http.Client{Timeout: 30 * time.Second},
bucketName: fmt.Sprintf("smoke-%d", time.Now().UnixNano()),
objectKey: "small.bin",
sourceBody: source,
}
t.Run("AdminLogin", func(t *testing.T) {
payload := fmt.Sprintf(`{"username":%q,"password":%q}`, adminUsername, adminPassword)
resp := state.do(t, "POST", "/auth/login", strings.NewReader(payload), "application/json")
body := readBody(t, resp)
if resp.StatusCode != 200 {
t.Fatalf("login status = %d, body = %s", resp.StatusCode, body)
}
var parsed struct {
Success bool `json:"success"`
Token string `json:"token"`
}
if err := json.Unmarshal(body, &parsed); err != nil {
t.Fatalf("parse login body: %v, body=%s", err, body)
}
if !parsed.Success || parsed.Token == "" {
t.Fatalf("login did not return token: %s", body)
}
state.token = parsed.Token
})
t.Run("CreateBucket", func(t *testing.T) {
if state.token == "" {
t.Skip("login did not succeed")
}
payload := fmt.Sprintf(`{"name":%q}`, state.bucketName)
resp := state.do(t, "POST", "/api/v1/buckets", strings.NewReader(payload), "application/json")
body := readBody(t, resp)
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
t.Fatalf("create bucket status = %d, body = %s", resp.StatusCode, body)
}
})
t.Run("CreateKey", func(t *testing.T) {
if state.token == "" {
t.Skip("login did not succeed")
}
payload := `{"name":"smoke-key"}`
resp := state.do(t, "POST", "/api/v1/users", strings.NewReader(payload), "application/json")
body := readBody(t, resp)
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
t.Fatalf("create key status = %d, body = %s", resp.StatusCode, body)
}
var parsed struct {
Data struct {
AccessKeyID string `json:"accessKeyId"`
} `json:"data"`
}
if err := json.Unmarshal(body, &parsed); err != nil {
t.Fatalf("parse key body: %v, body=%s", err, body)
}
if parsed.Data.AccessKeyID == "" {
t.Fatalf("empty accessKeyId: %s", body)
}
state.accessKeyID = parsed.Data.AccessKeyID
})
t.Run("GrantPermission", func(t *testing.T) {
if state.accessKeyID == "" {
t.Skip("key creation did not succeed")
}
payload := fmt.Sprintf(
`{"accessKeyId":%q,"permissions":{"read":true,"write":true,"owner":false}}`,
state.accessKeyID,
)
path := fmt.Sprintf("/api/v1/buckets/%s/permissions", state.bucketName)
resp := state.do(t, "POST", path, strings.NewReader(payload), "application/json")
body := readBody(t, resp)
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
t.Fatalf("grant permission status = %d, body = %s", resp.StatusCode, body)
}
})
t.Run("UploadObject", func(t *testing.T) {
if state.token == "" {
t.Skip("login did not succeed")
}
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
fw, err := mw.CreateFormFile("file", filepath.Base(state.objectKey))
if err != nil {
t.Fatalf("create form file: %v", err)
}
if _, err := fw.Write(state.sourceBody); err != nil {
t.Fatalf("write form file: %v", err)
}
if err := mw.WriteField("key", state.objectKey); err != nil {
t.Fatalf("write key field: %v", err)
}
if err := mw.Close(); err != nil {
t.Fatalf("close multipart: %v", err)
}
path := fmt.Sprintf("/api/v1/buckets/%s/objects/", state.bucketName)
resp := state.do(t, "POST", path, &buf, mw.FormDataContentType())
body := readBody(t, resp)
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
t.Fatalf("upload status = %d, body = %s", resp.StatusCode, body)
}
})
t.Run("ListObjects", func(t *testing.T) {
if state.token == "" {
t.Skip("login did not succeed")
}
path := fmt.Sprintf("/api/v1/buckets/%s/objects/", state.bucketName)
resp := state.do(t, "GET", path, nil, "")
body := readBody(t, resp)
if resp.StatusCode != 200 {
t.Fatalf("list status = %d, body = %s", resp.StatusCode, body)
}
if !bytes.Contains(body, []byte(state.objectKey)) {
t.Fatalf("uploaded key %q not found in list: %s", state.objectKey, body)
}
})
t.Run("DownloadObject", func(t *testing.T) {
if state.token == "" {
t.Skip("login did not succeed")
}
path := fmt.Sprintf("/api/v1/buckets/%s/objects/%s", state.bucketName, state.objectKey)
resp := state.do(t, "GET", path, nil, "")
body := readBody(t, resp)
if resp.StatusCode != 200 {
t.Fatalf("download status = %d, body = %s", resp.StatusCode, body)
}
if !bytes.Equal(body, state.sourceBody) {
t.Fatalf("downloaded bytes differ: got %d bytes, want %d bytes", len(body), len(state.sourceBody))
}
})
t.Run("DeleteObject", func(t *testing.T) {
if state.token == "" {
t.Skip("login did not succeed")
}
path := fmt.Sprintf("/api/v1/buckets/%s/objects/%s", state.bucketName, state.objectKey)
resp := state.do(t, "DELETE", path, nil, "")
body := readBody(t, resp)
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
t.Fatalf("delete object status = %d, body = %s", resp.StatusCode, body)
}
})
t.Run("DeleteBucket", func(t *testing.T) {
if state.token == "" {
t.Skip("login did not succeed")
}
path := fmt.Sprintf("/api/v1/buckets/%s", state.bucketName)
resp := state.do(t, "DELETE", path, nil, "")
body := readBody(t, resp)
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
t.Fatalf("delete bucket status = %d, body = %s", resp.StatusCode, body)
}
})
t.Run("ListKeys", func(t *testing.T) {
if state.token == "" {
t.Skip("login did not succeed")
}
resp := state.do(t, "GET", "/api/v1/users", nil, "")
body := readBody(t, resp)
if resp.StatusCode != 200 {
t.Fatalf("list users status = %d, body = %s", resp.StatusCode, body)
}
})
}
Binary file not shown.
+17
View File
@@ -0,0 +1,17 @@
coverage:
status:
project:
default:
target: 85%
threshold: 1%
patch:
default:
target: 80%
ignore:
- "backend/main.go"
- "backend/docs/"
- "backend/internal/services/mocks/"
- "backend/**/*_mock.go"
- "backend/internal/services/s3.go"
- "frontend/"
+18 -7
View File
@@ -2,7 +2,7 @@
# Server configuration
server:
host: "0.0.0.0"
host: "::" # IPv6 wildcard; dual-stack behavior depends on OS/runtime socket settings
port: 8080
environment: "development" # development, production
domain: "localhost" # Domain name for the application
@@ -40,6 +40,12 @@ auth:
username: "admin"
password: "changeme"
# Admin Token Authentication
# When enabled, users can log in using the Garage admin token
# Auto-enabled when no other auth method is configured (zero-config fallback)
token:
enabled: false # Set to true to explicitly enable, or leave all auth disabled for auto-enable
# 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
@@ -57,9 +63,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
@@ -72,7 +75,14 @@ auth:
# Role-based access (optional)
role_attribute_path: "resource_access.garage-ui.roles"
# Single admin role (backward-compatible).
admin_role: "admin"
# Multiple admin roles: a user is granted admin if ANY of their roles
# matches ANY entry below. Values from admin_role and admin_roles are
# merged, so you can set either, both, or only admin_roles.
# admin_roles:
# - "garage-admins"
# - "platform-team"
# TLS configuration
tls_skip_verify: false # Only set to true for testing, not recommended for production
@@ -103,7 +113,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"
-4
View File
@@ -41,7 +41,3 @@ services:
-----BEGIN PRIVATE KEY-----
MC4CAQAwBQYDK2VwBCIEIH0ZHqIV7MEyVsxNYc5TA/a0qaBxgq3ntlFS3w1F03MS
-----END PRIVATE KEY-----
# Logging
GARAGE_UI_LOGGING_LEVEL: "info"
GARAGE_UI_LOGGING_FORMAT: "json"
+59
View File
@@ -0,0 +1,59 @@
# Setting Up a Garage Cluster
This guide walks you through setting up a local Garage cluster using Docker Compose for use with Garage UI.
If you already have a running Garage cluster, skip this and go straight to the [Quick Start](../README.md#quick-start).
## Prerequisites
- Docker & Docker Compose
## 1. Start Garage
From the garage-ui repository root:
```bash
docker compose up -d garage
sleep 10
```
## 2. Initialize the Cluster Layout
```bash
# Assign the node to a zone with 1GB capacity
docker compose exec garage garage layout assign -z dc1 -c 1G $(docker compose exec garage garage node id -q)
# Apply the layout
docker compose exec garage garage layout apply --version 1
```
## 3. Create an Admin Key
```bash
docker compose exec garage garage key create admin-key
```
Save the **access key** and **secret key** from the output — you'll need them for configuration.
## 4. Configure Garage UI
Copy the example config and fill in your Garage endpoints and admin token:
```bash
cp config.example.yaml config.yaml
```
The `admin_token` can be found in your `garage.toml` file. See [Garage Configuration](../README.md#garage-configuration) for the required `garage.toml` settings.
## 5. Start Garage UI
```bash
docker compose up -d garage-ui
```
Access Garage UI at http://localhost:8080
## Next Steps
- [Configuration reference](../config.example.yaml) for all available options
- [Garage official documentation](https://garagehq.deuxfleurs.fr/documentation/) for advanced Garage setup
+243 -153
View File
@@ -12,7 +12,7 @@
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-query": "^5.90.10",
"@tanstack/react-table": "^8.21.3",
"axios": "^1.13.2",
"axios": "^1.16.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
@@ -21,7 +21,7 @@
"react-dom": "^19.2.0",
"react-dropzone": "^14.3.8",
"react-hook-form": "^7.66.1",
"react-router-dom": "^7.9.6",
"react-router-dom": "^7.14.1",
"recharts": "^3.5.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
@@ -40,11 +40,11 @@
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"postcss": "^8.5.6",
"postcss": "^8.5.12",
"tailwindcss": "^4.1.17",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^7.2.4"
"vite": "^7.3.2"
}
},
"node_modules/@alloc/quick-lru": {
@@ -91,7 +91,6 @@
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5",
@@ -1547,9 +1546,9 @@
"license": "MIT"
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.54.0.tgz",
"integrity": "sha512-OywsdRHrFvCdvsewAInDKCNyR3laPA2mc9bRYJ6LBp5IyvF3fvXbbNR0bSzHlZVFtn6E0xw2oZlyjg4rKCVcng==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz",
"integrity": "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==",
"cpu": [
"arm"
],
@@ -1561,9 +1560,9 @@
]
},
"node_modules/@rollup/rollup-android-arm64": {
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.54.0.tgz",
"integrity": "sha512-Skx39Uv+u7H224Af+bDgNinitlmHyQX1K/atIA32JP3JQw6hVODX5tkbi2zof/E69M1qH2UoN3Xdxgs90mmNYw==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.2.tgz",
"integrity": "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==",
"cpu": [
"arm64"
],
@@ -1575,9 +1574,9 @@
]
},
"node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.54.0.tgz",
"integrity": "sha512-k43D4qta/+6Fq+nCDhhv9yP2HdeKeP56QrUUTW7E6PhZP1US6NDqpJj4MY0jBHlJivVJD5P8NxrjuobZBJTCRw==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.2.tgz",
"integrity": "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==",
"cpu": [
"arm64"
],
@@ -1589,9 +1588,9 @@
]
},
"node_modules/@rollup/rollup-darwin-x64": {
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.54.0.tgz",
"integrity": "sha512-cOo7biqwkpawslEfox5Vs8/qj83M/aZCSSNIWpVzfU2CYHa2G3P1UN5WF01RdTHSgCkri7XOlTdtk17BezlV3A==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.2.tgz",
"integrity": "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==",
"cpu": [
"x64"
],
@@ -1603,9 +1602,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.54.0.tgz",
"integrity": "sha512-miSvuFkmvFbgJ1BevMa4CPCFt5MPGw094knM64W9I0giUIMMmRYcGW/JWZDriaw/k1kOBtsWh1z6nIFV1vPNtA==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.2.tgz",
"integrity": "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==",
"cpu": [
"arm64"
],
@@ -1617,9 +1616,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.54.0.tgz",
"integrity": "sha512-KGXIs55+b/ZfZsq9aR026tmr/+7tq6VG6MsnrvF4H8VhwflTIuYh+LFUlIsRdQSgrgmtM3fVATzEAj4hBQlaqQ==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.2.tgz",
"integrity": "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==",
"cpu": [
"x64"
],
@@ -1631,9 +1630,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.54.0.tgz",
"integrity": "sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.2.tgz",
"integrity": "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==",
"cpu": [
"arm"
],
@@ -1645,9 +1644,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.54.0.tgz",
"integrity": "sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.2.tgz",
"integrity": "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==",
"cpu": [
"arm"
],
@@ -1659,9 +1658,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.54.0.tgz",
"integrity": "sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.2.tgz",
"integrity": "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==",
"cpu": [
"arm64"
],
@@ -1673,9 +1672,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.54.0.tgz",
"integrity": "sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.2.tgz",
"integrity": "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==",
"cpu": [
"arm64"
],
@@ -1687,9 +1686,23 @@
]
},
"node_modules/@rollup/rollup-linux-loong64-gnu": {
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.54.0.tgz",
"integrity": "sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.2.tgz",
"integrity": "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-loong64-musl": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.2.tgz",
"integrity": "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==",
"cpu": [
"loong64"
],
@@ -1701,9 +1714,23 @@
]
},
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.54.0.tgz",
"integrity": "sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.2.tgz",
"integrity": "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-ppc64-musl": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.2.tgz",
"integrity": "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==",
"cpu": [
"ppc64"
],
@@ -1715,9 +1742,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.54.0.tgz",
"integrity": "sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.2.tgz",
"integrity": "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==",
"cpu": [
"riscv64"
],
@@ -1729,9 +1756,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.54.0.tgz",
"integrity": "sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.2.tgz",
"integrity": "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==",
"cpu": [
"riscv64"
],
@@ -1743,9 +1770,9 @@
]
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.54.0.tgz",
"integrity": "sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.2.tgz",
"integrity": "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==",
"cpu": [
"s390x"
],
@@ -1757,9 +1784,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.54.0.tgz",
"integrity": "sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.2.tgz",
"integrity": "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==",
"cpu": [
"x64"
],
@@ -1771,9 +1798,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.54.0.tgz",
"integrity": "sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.2.tgz",
"integrity": "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==",
"cpu": [
"x64"
],
@@ -1784,10 +1811,24 @@
"linux"
]
},
"node_modules/@rollup/rollup-openbsd-x64": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.2.tgz",
"integrity": "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
]
},
"node_modules/@rollup/rollup-openharmony-arm64": {
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.54.0.tgz",
"integrity": "sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.2.tgz",
"integrity": "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==",
"cpu": [
"arm64"
],
@@ -1799,9 +1840,9 @@
]
},
"node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.54.0.tgz",
"integrity": "sha512-c2V0W1bsKIKfbLMBu/WGBz6Yci8nJ/ZJdheE0EwB73N3MvHYKiKGs3mVilX4Gs70eGeDaMqEob25Tw2Gb9Nqyw==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.2.tgz",
"integrity": "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==",
"cpu": [
"arm64"
],
@@ -1813,9 +1854,9 @@
]
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.54.0.tgz",
"integrity": "sha512-woEHgqQqDCkAzrDhvDipnSirm5vxUXtSKDYTVpZG3nUdW/VVB5VdCYA2iReSj/u3yCZzXID4kuKG7OynPnB3WQ==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.2.tgz",
"integrity": "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==",
"cpu": [
"ia32"
],
@@ -1827,9 +1868,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-gnu": {
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.54.0.tgz",
"integrity": "sha512-dzAc53LOuFvHwbCEOS0rPbXp6SIhAf2txMP5p6mGyOXXw5mWY8NGGbPMPrs4P1WItkfApDathBj/NzMLUZ9rtQ==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.2.tgz",
"integrity": "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==",
"cpu": [
"x64"
],
@@ -1841,9 +1882,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.54.0.tgz",
"integrity": "sha512-hYT5d3YNdSh3mbCU1gwQyPgQd3T2ne0A3KG8KSBdav5TiBg6eInVmV+TeR5uHufiIgSFg0XsOWGW5/RhNcSvPg==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.2.tgz",
"integrity": "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==",
"cpu": [
"x64"
],
@@ -2089,6 +2130,66 @@
"node": ">=14.0.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
"version": "1.7.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.1.0",
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.7.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
"version": "1.1.0",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.0",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1",
"@tybys/wasm-util": "^0.10.1"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
"version": "2.8.1",
"dev": true,
"inBundle": true,
"license": "0BSD",
"optional": true
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz",
@@ -2324,7 +2425,6 @@
"integrity": "sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~7.16.0"
}
@@ -2335,7 +2435,6 @@
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -2346,7 +2445,6 @@
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"devOptional": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@@ -2402,7 +2500,6 @@
"integrity": "sha512-hM5faZwg7aVNa819m/5r7D0h0c9yC4DUlWAOvHAtISdFTc8xB86VmX5Xqabrama3wIPJ/q9RbGS1worb6JfnMg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.50.1",
"@typescript-eslint/types": "8.50.1",
@@ -2557,13 +2654,13 @@
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
"version": "9.0.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
"brace-expansion": "^2.0.2"
},
"engines": {
"node": ">=16 || 14 >=14.17"
@@ -2654,7 +2751,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -2765,14 +2861,14 @@
}
},
"node_modules/axios": {
"version": "1.13.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz",
"integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==",
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz",
"integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.4",
"proxy-from-env": "^1.1.0"
"follow-redirects": "^1.16.0",
"form-data": "^4.0.5",
"proxy-from-env": "^2.1.0"
}
},
"node_modules/balanced-match": {
@@ -2823,7 +2919,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -3343,7 +3438,6 @@
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -3624,16 +3718,16 @@
}
},
"node_modules/flatted": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz",
"integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==",
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
"integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
"dev": true,
"license": "ISC"
},
"node_modules/follow-redirects": {
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
"funding": [
{
"type": "individual",
@@ -4404,9 +4498,9 @@
}
},
"node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true,
"license": "ISC",
"dependencies": {
@@ -4556,12 +4650,11 @@
"license": "ISC"
},
"node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -4570,9 +4663,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
"version": "8.5.12",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz",
"integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==",
"dev": true,
"funding": [
{
@@ -4589,7 +4682,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -4634,10 +4726,13 @@
"license": "MIT"
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/punycode": {
"version": "2.3.1",
@@ -4654,7 +4749,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -4664,7 +4758,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -4694,7 +4787,6 @@
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.69.0.tgz",
"integrity": "sha512-yt6ZGME9f4F6WHwevrvpAjh42HMvocuSnSIHUGycBqXIJdhqGSPQzTpGF+1NLREk/58IdPxEMfPcFCjlMhclGw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18.0.0"
},
@@ -4718,7 +4810,6 @@
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
@@ -4748,9 +4839,9 @@
}
},
"node_modules/react-router": {
"version": "7.11.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.11.0.tgz",
"integrity": "sha512-uI4JkMmjbWCZc01WVP2cH7ZfSzH91JAZUDd7/nIprDgWxBV1TkkmLToFh7EbMTcMak8URFRa2YoBL/W8GWnCTQ==",
"version": "7.14.1",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.14.1.tgz",
"integrity": "sha512-5BCvFskyAAVumqhEKh/iPhLOIkfxcEUz8WqFIARCkMg8hZZzDYX9CtwxXA0e+qT8zAxmMC0x3Ckb9iMONwc5jg==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
@@ -4770,12 +4861,12 @@
}
},
"node_modules/react-router-dom": {
"version": "7.11.0",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.11.0.tgz",
"integrity": "sha512-e49Ir/kMGRzFOOrYQBdoitq3ULigw4lKbAyKusnvtDu2t4dBX4AGYPrzNvorXmVuOyeakai6FUPW5MmibvVG8g==",
"version": "7.14.1",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.14.1.tgz",
"integrity": "sha512-ZkrQuwwhGibjQLqH1eCdyiZyLWglPxzxdl5tgwgKEyCSGC76vmAjleGocRe3J/MLfzMUIKwaFJWpFVJhK3d2xA==",
"license": "MIT",
"dependencies": {
"react-router": "7.11.0"
"react-router": "7.14.1"
},
"engines": {
"node": ">=20.0.0"
@@ -4819,8 +4910,7 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/redux-thunk": {
"version": "3.1.0",
@@ -4848,9 +4938,9 @@
}
},
"node_modules/rollup": {
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.54.0.tgz",
"integrity": "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz",
"integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -4864,28 +4954,31 @@
"npm": ">=8.0.0"
},
"optionalDependencies": {
"@rollup/rollup-android-arm-eabi": "4.54.0",
"@rollup/rollup-android-arm64": "4.54.0",
"@rollup/rollup-darwin-arm64": "4.54.0",
"@rollup/rollup-darwin-x64": "4.54.0",
"@rollup/rollup-freebsd-arm64": "4.54.0",
"@rollup/rollup-freebsd-x64": "4.54.0",
"@rollup/rollup-linux-arm-gnueabihf": "4.54.0",
"@rollup/rollup-linux-arm-musleabihf": "4.54.0",
"@rollup/rollup-linux-arm64-gnu": "4.54.0",
"@rollup/rollup-linux-arm64-musl": "4.54.0",
"@rollup/rollup-linux-loong64-gnu": "4.54.0",
"@rollup/rollup-linux-ppc64-gnu": "4.54.0",
"@rollup/rollup-linux-riscv64-gnu": "4.54.0",
"@rollup/rollup-linux-riscv64-musl": "4.54.0",
"@rollup/rollup-linux-s390x-gnu": "4.54.0",
"@rollup/rollup-linux-x64-gnu": "4.54.0",
"@rollup/rollup-linux-x64-musl": "4.54.0",
"@rollup/rollup-openharmony-arm64": "4.54.0",
"@rollup/rollup-win32-arm64-msvc": "4.54.0",
"@rollup/rollup-win32-ia32-msvc": "4.54.0",
"@rollup/rollup-win32-x64-gnu": "4.54.0",
"@rollup/rollup-win32-x64-msvc": "4.54.0",
"@rollup/rollup-android-arm-eabi": "4.60.2",
"@rollup/rollup-android-arm64": "4.60.2",
"@rollup/rollup-darwin-arm64": "4.60.2",
"@rollup/rollup-darwin-x64": "4.60.2",
"@rollup/rollup-freebsd-arm64": "4.60.2",
"@rollup/rollup-freebsd-x64": "4.60.2",
"@rollup/rollup-linux-arm-gnueabihf": "4.60.2",
"@rollup/rollup-linux-arm-musleabihf": "4.60.2",
"@rollup/rollup-linux-arm64-gnu": "4.60.2",
"@rollup/rollup-linux-arm64-musl": "4.60.2",
"@rollup/rollup-linux-loong64-gnu": "4.60.2",
"@rollup/rollup-linux-loong64-musl": "4.60.2",
"@rollup/rollup-linux-ppc64-gnu": "4.60.2",
"@rollup/rollup-linux-ppc64-musl": "4.60.2",
"@rollup/rollup-linux-riscv64-gnu": "4.60.2",
"@rollup/rollup-linux-riscv64-musl": "4.60.2",
"@rollup/rollup-linux-s390x-gnu": "4.60.2",
"@rollup/rollup-linux-x64-gnu": "4.60.2",
"@rollup/rollup-linux-x64-musl": "4.60.2",
"@rollup/rollup-openbsd-x64": "4.60.2",
"@rollup/rollup-openharmony-arm64": "4.60.2",
"@rollup/rollup-win32-arm64-msvc": "4.60.2",
"@rollup/rollup-win32-ia32-msvc": "4.60.2",
"@rollup/rollup-win32-x64-gnu": "4.60.2",
"@rollup/rollup-win32-x64-msvc": "4.60.2",
"fsevents": "~2.3.2"
}
},
@@ -5072,7 +5165,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -5185,12 +5277,11 @@
}
},
"node_modules/vite": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz",
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
"version": "7.3.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz",
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@@ -5311,7 +5402,6 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-4.2.1.tgz",
"integrity": "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
+4 -4
View File
@@ -14,7 +14,7 @@
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-query": "^5.90.10",
"@tanstack/react-table": "^8.21.3",
"axios": "^1.13.2",
"axios": "^1.16.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
@@ -23,7 +23,7 @@
"react-dom": "^19.2.0",
"react-dropzone": "^14.3.8",
"react-hook-form": "^7.66.1",
"react-router-dom": "^7.9.6",
"react-router-dom": "^7.14.1",
"recharts": "^3.5.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
@@ -42,10 +42,10 @@
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"postcss": "^8.5.6",
"postcss": "^8.5.12",
"tailwindcss": "^4.1.17",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^7.2.4"
"vite": "^7.3.2"
}
}
+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.

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