Compare commits

...

143 Commits

Author SHA1 Message Date
garage-ui-release-bot[bot] 0e6b7b9215 chore(main): release 0.10.0 (#93)
Co-authored-by: garage-ui-release-bot[bot] <285030457+garage-ui-release-bot[bot]@users.noreply.github.com>
2026-07-15 00:02:03 +02:00
Noste c0976a1e1d feat(preview): Implement object preview functionality (#94)
* feat(preview): implement object preview functionality with token-based access

* docs: update README to include inline object preview feature

* test: add range read error handling and attachment content disposition tests
2026-07-15 00:01:04 +02:00
Camilo Hollanda 5d33382e30 feat: bulk actions — recursively delete folders (key prefixes) (#68)
* feat(backend,frontend): recursive delete of key prefixes in bulk actions

Bulk object selection previously only supported deleting individually
listed object keys — folders ("prefixes") could not be selected or
deleted, leaving no way to remove a directory and all of its contents.

Backend:
- Add S3Service.DeleteObjectsByPrefix, which recursively lists every
  object under a prefix and batch-deletes them, returning the count.
- Extend the delete-multiple endpoint to accept a "prefixes" array
  alongside "keys"; keys are batch-deleted and each prefix is deleted
  recursively. Response now reports the total objects removed.

Frontend:
- Enable folder checkboxes and add a per-folder "Delete folder" action.
- Select-all now covers both files and folders.
- Route all bulk/folder deletes through a confirmation dialog that
  spells out the file/folder counts and warns that folders are removed
  recursively (previously bulk delete fired with no confirmation).
- api/hook send "prefixes"; optimistic update drops objects under any
  deleted prefix.

* fix(backend): validate delete prefixes and count actual removals

Addresses maintainer review on the recursive prefix-delete endpoint:

- Reject blank/whitespace-only prefixes with a 400 instead of a 500, and
  normalize each prefix to have a trailing "/" so "photos/2024" can no
  longer also delete siblings like "photos/2024-old/..." on this
  irreversible public endpoint.
- DeleteMultipleObjects now returns the number of objects actually
  removed (requested keys minus failures) rather than assuming every
  requested key was deleted; the handler sums real counts across the
  keys and prefix paths. Draining the full RemoveObjects error channel
  also fixes a potential sender-goroutine leak on early return.
- Add tests: blank-prefix -> 400, prefix trailing-slash normalization,
  and S3Service.DeleteObjectsByPrefix (list-then-delete, empty prefix,
  no-match, and list-error propagation).

* fix(frontend): align select-all with the active search filter

The header "select all" checkbox derived its checked state from the
filtered (searched) rows, but handleSelectAll operated on the full,
unfiltered object list — so with a search active it selected hidden
items and the checkbox state disagreed with the selection.

ObjectsTable now passes the keys of the currently visible (filtered)
rows to onSelectAll, and its checked state reflects whether every
visible row is selected. handleSelectAll toggles only those visible
rows, leaving any off-screen selection intact.

* fix(frontend): scope select-all to the visible page

After merging upstream's client-side deep-search pagination, the rendered
rows are pageObjects (one page slice) while select-all still operated on
filteredObjects (every match across hidden pages). Scope the header
checkbox's state and its select-all action to pageObjects so one click
never selects off-screen rows for a destructive bulk delete. In
normal/prefix browsing pageObjects === filteredObjects, so behavior there
is unchanged.

---------

Authored-by: Camilo Hollanda <775409+prem-prakash@users.noreply.github.com>
2026-07-14 23:11:23 +02:00
Noooste 477b544be7 docs: improve clarity and structure 2026-07-11 22:53:40 +02:00
Noooste 90dffe594a update README.md 2026-07-11 22:22:49 +02:00
Noste 9f73d032e6 feat(metrics): add public metrics endpoint configuration and update documentation (#92) 2026-07-11 21:00:04 +02:00
garage-ui-release-bot[bot] 16dc2eb996 chore(main): release 0.9.0 (#90)
Co-authored-by: garage-ui-release-bot[bot] <285030457+garage-ui-release-bot[bot]@users.noreply.github.com>
2026-07-11 17:59:34 +02:00
Noste 0d804fbd39 feat(oidc): add fine grained access control (#91)
* feat(access-control): fine grain tokens

* fix(docs): clean wording

* test(access-control): add tests for team extraction from access tokens and bucket info permissions

* test(vocabulary): add test for ExpandGlob function to reject non-glob patterns

* feat(helm): add multi-user access control documentation and schema support
2026-07-11 17:57:28 +02:00
Noste 3098e474f2 feat(backend,frontend): implement prefix and recursive substring search for bucket objects (#89)
* feat(search): implement recursive substring search for bucket objects

* fix: update key formatting to remove trailing slashes in ObjectsTable

* feat(search): implement debounced search functionality and improve UI text clarity

* test(objects): add test for handling search error response
2026-07-11 17:09:27 +02:00
garage-ui-release-bot[bot] b28e975a56 chore(main): release 0.8.5 (#86)
Co-authored-by: garage-ui-release-bot[bot] <285030457+garage-ui-release-bot[bot]@users.noreply.github.com>
2026-07-03 11:13:32 +02:00
Noste 430d1a5d68 fix(frontend): add downloadObject function for downloading files from a bucket 2026-07-03 11:10:33 +02:00
dependabot[bot] 460793632e chore(deps): bump esbuild, @vitejs/plugin-react and vite in /frontend (#79)
Removes [esbuild](https://github.com/evanw/esbuild). It's no longer used after updating ancestor dependencies [esbuild](https://github.com/evanw/esbuild), [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react) and [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite). These dependencies need to be updated together.


Removes `esbuild`

Updates `@vitejs/plugin-react` from 5.1.2 to 5.2.0
- [Release notes](https://github.com/vitejs/vite-plugin-react/releases)
- [Changelog](https://github.com/vitejs/vite-plugin-react/blob/plugin-react@5.2.0/packages/plugin-react/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite-plugin-react/commits/plugin-react@5.2.0/packages/plugin-react)

Updates `vite` from 7.3.2 to 8.0.16
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.0.16/packages/vite)

---
updated-dependencies:
- dependency-name: esbuild
  dependency-version:
  dependency-type: indirect
- dependency-name: "@vitejs/plugin-react"
  dependency-version: 5.2.0
  dependency-type: direct:development
- dependency-name: vite
  dependency-version: 8.0.16
  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-06-22 16:33:57 +02:00
dependabot[bot] f888b7c77c chore(deps): bump js-yaml from 4.1.1 to 4.2.0 in /frontend (#83)
Bumps [js-yaml](https://github.com/nodeca/js-yaml) from 4.1.1 to 4.2.0.
- [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodeca/js-yaml/compare/4.1.1...4.2.0)

---
updated-dependencies:
- dependency-name: js-yaml
  dependency-version: 4.2.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-22 16:33:41 +02:00
dependabot[bot] ef118dc3a7 chore(deps): bump form-data from 4.0.5 to 4.0.6 in /frontend (#82)
Bumps [form-data](https://github.com/form-data/form-data) from 4.0.5 to 4.0.6.
- [Changelog](https://github.com/form-data/form-data/blob/master/CHANGELOG.md)
- [Commits](https://github.com/form-data/form-data/compare/v4.0.5...v4.0.6)

---
updated-dependencies:
- dependency-name: form-data
  dependency-version: 4.0.6
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-22 16:33:20 +02:00
garage-ui-release-bot[bot] 34ba8ef851 chore(main): release 0.8.4 (#84)
Co-authored-by: garage-ui-release-bot[bot] <285030457+garage-ui-release-bot[bot]@users.noreply.github.com>
2026-06-22 15:32:55 +02:00
Noste 1ffdfe83c8 fix(helm): track appVersion in release-please and fix badges 2026-06-22 15:28:41 +02:00
garage-ui-release-bot[bot] e0cd59dcdf chore(main): release 0.8.3 (#81)
Co-authored-by: garage-ui-release-bot[bot] <285030457+garage-ui-release-bot[bot]@users.noreply.github.com>
2026-06-22 12:23:53 +02:00
Noooste 1c1e4d4b2b chore: update Helm chart version to 0.8.2 and adjust README badges
Signed-off-by: Noooste <83548733+Noooste@users.noreply.github.com>
2026-06-21 23:20:44 +02:00
Noooste ae245c8a31 Merge remote-tracking branch 'origin/main' 2026-06-21 22:58:38 +02:00
Noooste 28c186f3eb fix(helm): update version badges in README for Garage UI
Signed-off-by: Noooste <83548733+Noooste@users.noreply.github.com>
2026-06-21 22:58:32 +02:00
Noste 46aa3752c8 fix(backend): improve API version detection with retry logic for health probes
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2026-06-17 19:14:06 +02:00
garage-ui-release-bot[bot] d502dac457 chore: release main (#77)
* chore: release main

* chore: sync Chart.yaml appVersion to v0.8.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-06-08 00:35:53 +02:00
dependabot[bot] 67d8f633b0 chore(deps): bump react-router and react-router-dom in /frontend (#74)
Bumps [react-router](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router) to 7.16.0 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.14.1 to 7.16.0
- [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.16.0/packages/react-router)

Updates `react-router-dom` from 7.14.1 to 7.16.0
- [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.16.0/packages/react-router-dom)

---
updated-dependencies:
- dependency-name: react-router
  dependency-version: 7.16.0
  dependency-type: indirect
- dependency-name: react-router-dom
  dependency-version: 7.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-06-08 00:13:10 +02:00
Noste 22be89b2ff fix(backend): prevent OIDC login loop from empty cookie name (#76)
Signed-off-by: Noooste <83548733+Noooste@users.noreply.github.com>
2026-06-08 00:11:45 +02:00
garage-ui-release-bot[bot] ae97dd8f01 chore: release main (#73)
* chore: release main

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

---------

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-06-01 00:54:09 +02:00
Florian Gareis 45f8770799 fix(frontend): align three-dot menu item icon spacing and text alignment (#72) 2026-06-01 00:53:22 +02:00
Noste e3191c2686 fix(ci): add workflow_dispatch
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2026-05-31 12:57:58 +02:00
Noste 24997db960 fix(ci): add docker login for cosign
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2026-05-31 12:55:40 +02:00
Noste 3c69cc5f26 Merge remote-tracking branch 'origin/main' 2026-05-31 12:52:34 +02:00
Noste 4b0e98008b chore(release): remove pin version
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2026-05-31 12:52:04 +02:00
garage-ui-release-bot[bot] bc67e50606 chore: release main (#71)
Co-authored-by: garage-ui-release-bot[bot] <285030457+garage-ui-release-bot[bot]@users.noreply.github.com>
2026-05-31 12:51:23 +02:00
Noste fe1765597c chore(release): exclude .github from app, pin chart to 0.6.1 patch
Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2026-05-31 12:50:15 +02:00
Noste 1c9043c697 feat: Publish Helm chart to GHCR (#70)
* feat(ci): add GitHub Actions workflow to publish Helm chart to GHCR

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

* docs(helm): update README to include installation instructions from OCI registry and signature verification

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

---------

Signed-off-by: Noste <83548733+Noooste@users.noreply.github.com>
2026-05-31 12:31:53 +02:00
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
205 changed files with 27161 additions and 3953 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"
+4 -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:
+81
View File
@@ -0,0 +1,81 @@
name: chart-release
on:
push:
tags:
- 'v[0-9]+.[0-9]+.[0-9]+'
- 'v[0-9]+.[0-9]+.[0-9]+-*'
workflow_dispatch:
permissions:
contents: write
pages: write
packages: write
id-token: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Configure Git
run: |
git config user.name "$GITHUB_ACTOR"
git config user.email "$GITHUB_ACTOR@users.noreply.github.com"
- name: Set up Helm
uses: azure/setup-helm@v4.3.1
with:
version: v4.1.3
- name: Run chart-releaser
uses: helm/chart-releaser-action@v1.7.0
with:
charts_dir: helm
skip_existing: true
env:
CR_TOKEN: ${{ secrets.HELM_RELEASE_TOKEN }}
- name: Install cosign
uses: sigstore/cosign-installer@v4.1.2
- name: Log in to ghcr.io for cosign
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Package and push chart to ghcr.io (OCI)
id: oci_push
env:
GHCR_USER: ${{ github.actor }}
GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
chart_version=$(grep -E '^version:' helm/garage-ui/Chart.yaml | awk '{print $2}')
echo "Packaging chart version ${chart_version}"
helm package helm/garage-ui --destination /tmp/chart
echo "${GHCR_TOKEN}" | helm registry login ghcr.io \
--username "${GHCR_USER}" --password-stdin
push_output=$(helm push "/tmp/chart/garage-ui-${chart_version}.tgz" \
oci://ghcr.io/noooste/charts 2>&1 | tee /dev/stderr)
digest=$(echo "$push_output" | grep -oE 'sha256:[a-f0-9]{64}' | head -n1)
if [ -z "$digest" ]; then
echo "Failed to parse pushed digest from helm push output" >&2
exit 1
fi
echo "digest=${digest}" >> "$GITHUB_OUTPUT"
echo "Pushed oci://ghcr.io/noooste/charts/garage-ui:${chart_version} (${digest})"
- name: Sign chart with cosign (keyless)
run: |
cosign sign --yes \
"ghcr.io/noooste/charts/garage-ui@${{ steps.oci_push.outputs.digest }}"
+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"
+27
View File
@@ -0,0 +1,27 @@
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 }}
-37
View File
@@ -1,37 +0,0 @@
name: Release Charts
on:
push:
branches:
- main
paths:
- 'helm/garage-ui/**'
- '!helm/garage-ui/README.md'
jobs:
release:
permissions:
contents: write
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Configure Git
run: |
git config user.name "$GITHUB_ACTOR"
git config user.email "$GITHUB_ACTOR@users.noreply.github.com"
- name: Set up Helm
uses: azure/setup-helm@v4.3.1
with:
version: v3.19.3
- name: Run chart-releaser
uses: helm/chart-releaser-action@v1.7.0
with:
charts_dir: helm
env:
CR_TOKEN: "${{ secrets.HELM_RELEASE_TOKEN }}"
+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
+11 -2
View File
@@ -52,12 +52,21 @@ 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
!docs/access-control.md
**/state**
**/content**
**/worktrees
+3
View File
@@ -0,0 +1,3 @@
{
".": "0.10.0"
}
+107
View File
@@ -0,0 +1,107 @@
# Changelog
## [0.10.0](https://github.com/Noooste/garage-ui/compare/v0.9.0...v0.10.0) (2026-07-14)
### Features
* bulk actions — recursively delete folders (key prefixes) ([#68](https://github.com/Noooste/garage-ui/issues/68)) ([5d33382](https://github.com/Noooste/garage-ui/commit/5d33382e3056afd5b75b1775255e0f3033833f6c))
* **metrics:** add public metrics endpoint configuration and update documentation ([#92](https://github.com/Noooste/garage-ui/issues/92)) ([9f73d03](https://github.com/Noooste/garage-ui/commit/9f73d032e6c1c10b7d0dafa986e32985aa3b6f66))
* **preview:** Implement object preview functionality ([#94](https://github.com/Noooste/garage-ui/issues/94)) ([c0976a1](https://github.com/Noooste/garage-ui/commit/c0976a1e1d53839d1fe9d7158e8cadb979789722))
## [0.9.0](https://github.com/Noooste/garage-ui/compare/v0.8.5...v0.9.0) (2026-07-11)
### Features
* **backend,frontend:** implement prefix and recursive substring search for bucket objects ([#89](https://github.com/Noooste/garage-ui/issues/89)) ([3098e47](https://github.com/Noooste/garage-ui/commit/3098e474f202f36b527c8636cd1d4e8c08e287d2))
* **oidc:** add fine grained access control ([#91](https://github.com/Noooste/garage-ui/issues/91)) ([0d804fb](https://github.com/Noooste/garage-ui/commit/0d804fbd39ed656511b671d237ad14fc8c21786d))
## [0.8.5](https://github.com/Noooste/garage-ui/compare/v0.8.4...v0.8.5) (2026-07-03)
### Bug Fixes
* **frontend:** add downloadObject function for downloading files from a bucket ([430d1a5](https://github.com/Noooste/garage-ui/commit/430d1a5d68e4f9915a2951d5b31c1b5ea56cb24c))
## [0.8.4](https://github.com/Noooste/garage-ui/compare/v0.8.3...v0.8.4) (2026-06-22)
### Bug Fixes
* **helm:** track appVersion in release-please and fix badges ([1ffdfe8](https://github.com/Noooste/garage-ui/commit/1ffdfe83c884dc5e39d071343d5269164746c536))
## [0.8.3](https://github.com/Noooste/garage-ui/compare/v0.8.2...v0.8.3) (2026-06-21)
### Bug Fixes
* **backend:** improve API version detection with retry logic for health probes ([46aa375](https://github.com/Noooste/garage-ui/commit/46aa3752c81788787388d1c67e29cef786bdabff))
* **helm:** update version badges in README for Garage UI ([28c186f](https://github.com/Noooste/garage-ui/commit/28c186f3eba1a1c111100712f1eaec22a5d18eb2))
## [0.8.2](https://github.com/Noooste/garage-ui/compare/v0.8.1...v0.8.2) (2026-06-07)
### Bug Fixes
* **backend:** prevent OIDC login loop from empty cookie name ([#76](https://github.com/Noooste/garage-ui/issues/76)) ([22be89b](https://github.com/Noooste/garage-ui/commit/22be89b2ff86465abb90dab0344ef9366ab181b3))
## [0.8.1](https://github.com/Noooste/garage-ui/compare/v0.8.0...v0.8.1) (2026-05-31)
### Bug Fixes
* **frontend:** align three-dot menu item icon spacing and text alignment ([#72](https://github.com/Noooste/garage-ui/issues/72)) ([45f8770](https://github.com/Noooste/garage-ui/commit/45f87707996e92d0f8f75e79c8f60a13556eaf6e))
## [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.
+1 -2
View File
@@ -48,7 +48,7 @@ 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
@@ -58,4 +58,3 @@ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
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
+160 -87
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,39 @@ 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.)
- **Multi-user access control** - optional OIDC-team-based permissions, see [docs/access-control.md](docs/access-control.md)
- **Easy deployment** - single Docker image or Helm chart, configure with one YAML file
- **Preview common file types** - images, video, PDF, and text without downloading
## 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 +79,114 @@ helm install garage-ui garage-ui/garage-ui \
--set garage.adminToken=your-token
```
## Development
The chart creates a ClusterIP service on port 80. To try it out before setting up an ingress:
Backend (Go 1.25+):
```bash
cd backend
go run main.go --config ../config.yaml
kubectl port-forward svc/garage-ui 8080:80
```
Frontend (Node.js 25+):
Then open http://localhost:8080
### Reusing your garage.toml
If you already have a running Garage instance, you can point Garage UI straight at your `garage.toml` and skip `config.yaml` entirely:
```bash
cd frontend
npm install
npm run dev
./garage-ui --garage-toml /etc/garage.toml
```
API docs: http://localhost:8080/api/v1/
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 a container this won't work, so override the endpoints explicitly with environment variables or a config file.
**Docker:**
```bash
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
```
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 and Kubernetes secrets, sensitive env vars can be read from files instead of plain values. Set `{VAR}_FILE=/path/to/file` and garage-ui uses 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 stops startup.
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 don't need it; the chart already injects secrets via `existingSecret` references.
## Garage Configuration
@@ -172,6 +208,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:**
@@ -186,6 +239,22 @@ logging:
format: "text" # or "json"
```
## Roadmap
Roughly ordered by value. Open an [issue](https://github.com/Noooste/garage-ui/issues) to push something up the list.
- [x] **Fine-grained access control**: OIDC teams with per-bucket-prefix permissions, see [docs/access-control.md](docs/access-control.md)
- [x] **Object search**: recursive substring search across a bucket
- [x] **Bucket quotas**: size and object count limits from bucket settings
- [x] **Zero-config startup**: run straight from `garage.toml`, log in with the admin token
- [x] **Broad compatibility**: Garage v1 through latest, IPv6-only networks, secrets from files
- [X] **Inline object preview**: images, video, PDF, and text without downloading ([#60](https://github.com/Noooste/garage-ui/issues/60))
- [ ] **Presigned share links**: time-limited download links from the object browser
- [ ] **Resumable uploads**: multipart uploads that survive a dropped connection
- [ ] **Visual layout editor**: staged vs. applied diff before committing layout changes
- [ ] **Admin audit log**: who changed what, building on access control
- [ ] **Table and detail polish**: sortable columns, clearer node details ([#36](https://github.com/Noooste/garage-ui/issues/36), [#37](https://github.com/Noooste/garage-ui/issues/37))
## License
MIT - see [LICENSE](LICENSE)
@@ -195,3 +264,7 @@ MIT - see [LICENSE](LICENSE)
- [Issues](https://github.com/Noooste/garage-ui/issues)
- [Contributing](CONTRIBUTING.md)
- [Garage Docs](https://garagehq.deuxfleurs.fr/documentation/)
---
<p align="center">Made with ❤️ in France 🇫🇷</p>
+209
View File
@@ -0,0 +1,209 @@
// Command seed bulk-loads a Garage/S3 bucket with millions of small objects
// for testing the object browser and search features.
//
// Keys are a deterministic function of a global index, so a run is resumable:
// re-run with -start=<last index> and any re-uploaded key simply overwrites.
// Layout: pets/<species>/<breed>/<name>-<NNNNNN>.dat
//
// cd backend && go run ./cmd/seed # full 3,000,000 objects
// go run ./cmd/seed -count 10000 # quick smoke test
// go run ./cmd/seed -start 1500000 # resume from index 1.5M
package main
import (
"bytes"
"context"
"flag"
"fmt"
"log"
"math/rand"
"os"
"os/signal"
"sync"
"sync/atomic"
"syscall"
"time"
"github.com/minio/minio-go/v7"
"github.com/minio/minio-go/v7/pkg/credentials"
)
// species -> breeds. Real words so keys are searchable by substring
// (e.g. "golden", "retriever", "siamese"). Flattened into speciesBreed at init.
var taxonomy = []struct {
species string
breeds []string
}{
{"dogs", []string{"labrador", "golden-retriever", "german-shepherd", "bulldog", "poodle", "beagle", "rottweiler", "dachshund", "husky", "chihuahua"}},
{"cats", []string{"siamese", "persian", "maine-coon", "bengal", "ragdoll", "sphynx", "british-shorthair", "abyssinian"}},
{"birds", []string{"parrot", "canary", "cockatiel", "budgie", "finch", "macaw", "lovebird"}},
{"fish", []string{"goldfish", "guppy", "betta", "angelfish", "tetra", "molly"}},
{"rabbits", []string{"holland-lop", "netherland-dwarf", "rex", "lionhead", "flemish-giant"}},
{"hamsters", []string{"syrian", "dwarf-campbell", "roborovski", "chinese"}},
{"reptiles", []string{"leopard-gecko", "iguana", "bearded-dragon", "corn-snake", "box-turtle"}},
{"horses", []string{"arabian", "thoroughbred", "mustang", "clydesdale", "appaloosa"}},
{"guinea-pigs", []string{"american", "abyssinian", "peruvian", "silkie"}},
{"ferrets", []string{"sable", "albino", "cinnamon", "chocolate"}},
}
// petNames are the leaf file names. ~50 common pet names.
var petNames = []string{
"buddy", "luna", "max", "bella", "charlie", "lucy", "cooper", "daisy", "rocky", "molly",
"bailey", "sadie", "duke", "maggie", "bear", "sophie", "tucker", "chloe", "oliver", "lola",
"jack", "zoe", "toby", "ruby", "teddy", "rosie", "milo", "gracie", "oscar", "coco",
"leo", "penny", "rex", "willow", "sam", "honey", "gus", "ginger", "murphy", "olive",
"jasper", "hazel", "finn", "ivy", "louie", "pepper", "ziggy", "nala", "apollo", "cleo",
}
type speciesBreedPair struct{ species, breed string }
var speciesBreed []speciesBreedPair
func init() {
for _, t := range taxonomy {
for _, b := range t.breeds {
speciesBreed = append(speciesBreed, speciesBreedPair{t.species, b})
}
}
}
// keyFor maps a global index to a unique object key with even folder fill.
// folderIdx selects the species/breed folder; within selects (name, suffix)
// inside that folder. The mapping is a bijection, so keys never collide.
func keyFor(i int64) string {
c := int64(len(speciesBreed))
n := int64(len(petNames))
folderIdx := i % c
within := i / c
name := petNames[within%n]
suffix := within / n
sb := speciesBreed[folderIdx]
return fmt.Sprintf("pets/%s/%s/%s-%06d.dat", sb.species, sb.breed, name, suffix)
}
func main() {
var (
endpoint = flag.String("endpoint", "localhost:3900", "S3 endpoint host:port")
bucket = flag.String("bucket", "test", "target bucket")
region = flag.String("region", "garage", "S3 region")
accessKey = flag.String("access-key", "GK4b706791e6efb7bc00a99c69", "S3 access key")
secretKey = flag.String("secret-key", "cdb665539872887e4fca34841ad2ebd79cda7af2302b500097262ec030123b14", "S3 secret key")
count = flag.Int64("count", 3_000_000, "total dataset size (upper index, exclusive)")
start = flag.Int64("start", 0, "start index (resume point)")
size = flag.Int("size", 4096, "bytes per object")
concurrency = flag.Int("concurrency", 64, "concurrent upload workers")
secure = flag.Bool("secure", false, "use HTTPS")
)
flag.Parse()
client, err := minio.New(*endpoint, &minio.Options{
Creds: credentials.NewStaticV4(*accessKey, *secretKey, ""),
Secure: *secure,
Region: *region,
BucketLookup: minio.BucketLookupPath, // Garage needs path-style
})
if err != nil {
log.Fatalf("client init: %v", err)
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
total := *count - *start
if total <= 0 {
log.Fatalf("nothing to do: start=%d >= count=%d", *start, *count)
}
log.Printf("seeding bucket %q: indices [%d,%d) = %d objects of %d bytes, concurrency=%d, folders=%d",
*bucket, *start, *count, total, *size, *concurrency, len(speciesBreed))
var (
cursor = *start
done int64
errCount int64
wg sync.WaitGroup
startTime = time.Now()
)
// Progress reporter.
reportDone := make(chan struct{})
go func() {
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
var last int64
lastT := startTime
for {
select {
case <-reportDone:
return
case now := <-ticker.C:
d := atomic.LoadInt64(&done)
cur := atomic.LoadInt64(&cursor)
instRate := float64(d-last) / now.Sub(lastT).Seconds()
last, lastT = d, now
var eta time.Duration
if instRate > 0 {
eta = time.Duration(float64(total-d)/instRate) * time.Second
}
log.Printf("progress: %d/%d (%.1f%%) | %.0f obj/s | errors=%d | next-index=%d | eta=%s",
d, total, 100*float64(d)/float64(total), instRate,
atomic.LoadInt64(&errCount), cur, eta.Round(time.Second))
}
}
}()
opts := minio.PutObjectOptions{ContentType: "application/octet-stream", DisableMultipart: true}
for w := 0; w < *concurrency; w++ {
wg.Add(1)
go func(worker int) {
defer wg.Done()
// One random, incompressible buffer per worker (Garage compresses
// per-object, so reuse is fine and avoids per-object allocation).
buf := make([]byte, *size)
r := rand.New(rand.NewSource(int64(1000 + worker)))
for j := range buf {
buf[j] = byte(r.Intn(256))
}
for {
idx := atomic.AddInt64(&cursor, 1) - 1
if idx >= *count {
return
}
if ctx.Err() != nil {
return
}
key := keyFor(idx)
var putErr error
for attempt := 0; attempt < 3; attempt++ {
_, putErr = client.PutObject(ctx, *bucket, key, bytes.NewReader(buf), int64(*size), opts)
if putErr == nil || ctx.Err() != nil {
break
}
time.Sleep(time.Duration(attempt+1) * 100 * time.Millisecond)
}
if putErr != nil {
if n := atomic.AddInt64(&errCount, 1); n <= 10 {
log.Printf("put %q failed: %v", key, putErr)
}
continue
}
atomic.AddInt64(&done, 1)
}
}(w)
}
wg.Wait()
close(reportDone)
elapsed := time.Since(startTime)
d := atomic.LoadInt64(&done)
log.Printf("DONE: uploaded %d/%d objects in %s (%.0f obj/s), errors=%d",
d, total, elapsed.Round(time.Second), float64(d)/elapsed.Seconds(), atomic.LoadInt64(&errCount))
if ctx.Err() != nil {
log.Printf("interrupted; resume with -start=%d", atomic.LoadInt64(&cursor))
}
if atomic.LoadInt64(&errCount) > 0 {
os.Exit(1)
}
}
+46 -41
View File
@@ -3,75 +3,80 @@ module Noooste/garage-ui
go 1.25.3
require (
github.com/Noooste/azuretls-client v1.12.11
github.com/Noooste/azuretls-client v1.13.2
github.com/Noooste/swagger v1.2.0
github.com/coreos/go-oidc/v3 v3.17.0
github.com/gofiber/fiber/v3 v3.1.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/minio/minio-go/v7 v7.0.98
github.com/rs/zerolog v1.34.0
github.com/google/uuid v1.6.0
github.com/minio/minio-go/v7 v7.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
golang.org/x/oauth2 v0.35.0
github.com/swaggo/swag v1.16.6
golang.org/x/oauth2 v0.36.0
)
require (
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/Noooste/fhttp v1.0.15 // indirect
github.com/Noooste/go-socks4 v0.0.2 // indirect
github.com/Noooste/uquic-go v1.0.3 // indirect
github.com/Noooste/utls v1.3.20 // indirect
github.com/Noooste/uquic-go v1.0.5 // indirect
github.com/Noooste/utls v1.3.21 // indirect
github.com/Noooste/websocket v1.0.3 // indirect
github.com/andybalholm/brotli v1.2.0 // indirect
github.com/andybalholm/brotli v1.2.1 // indirect
github.com/bdandy/go-errors v1.2.2 // indirect
github.com/cloudflare/circl v1.6.1 // indirect
github.com/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.21.0 // indirect
github.com/go-openapi/jsonreference v0.21.0 // indirect
github.com/go-openapi/spec v0.21.0 // indirect
github.com/go-openapi/swag v0.23.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/gofiber/schema v1.7.0 // indirect
github.com/gofiber/utils/v2 v2.0.2 // indirect
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
github.com/go-openapi/jsonpointer v0.23.1 // indirect
github.com/go-openapi/jsonreference v0.21.5 // indirect
github.com/go-openapi/spec v0.22.4 // indirect
github.com/go-openapi/swag/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/josharian/intern v1.0.0 // indirect
github.com/klauspost/compress v1.18.4 // indirect
github.com/klauspost/cpuid/v2 v2.2.11 // indirect
github.com/klauspost/compress v1.18.6 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/klauspost/crc32 v1.3.0 // indirect
github.com/mailru/easyjson v0.9.0 // indirect
github.com/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.11.0 // indirect
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
github.com/sagikazarmark/locafero v0.12.0 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/swaggo/files/v2 v2.0.2 // indirect
github.com/swaggo/swag v1.16.6 // indirect
github.com/tinylib/msgp v1.6.3 // indirect
github.com/tinylib/msgp v1.6.4 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.69.0 // indirect
github.com/valyala/fasthttp v1.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.48.0 // indirect
golang.org/x/mod v0.32.0 // indirect
golang.org/x/net v0.50.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect
golang.org/x/tools v0.41.0 // indirect
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
)
+133 -96
View File
@@ -2,136 +2,162 @@ github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/Noooste/azuretls-client v1.12.11 h1:8IvtfPf+K6wOqiRROL/APGkxQCO/+jyjH0S39rnItfQ=
github.com/Noooste/azuretls-client v1.12.11/go.mod h1:lvXW8wpaOwrwtDrSt8nv/Dd8NAbCMVNRoU4sFrAaxYs=
github.com/Noooste/azuretls-client v1.13.2 h1:8Dli5aKP5O6qN/FNSGFVkpQ1V1F1gGawAkLIE2Nrk+U=
github.com/Noooste/azuretls-client v1.13.2/go.mod h1:ON+SmiBm4Zy5vAhJmBNZk61Y7nqf4iM/b1MC1lN47Bk=
github.com/Noooste/fhttp v1.0.15 h1:sYRWOKgr1x4L+wA6REMJCs4Z/lFOSJmuQHSIXMXCcPs=
github.com/Noooste/fhttp v1.0.15/go.mod h1:YZtq+i2M11Y22UiOR6gjNSLMNLiPhURh6M44oFVQ1TE=
github.com/Noooste/go-socks4 v0.0.2 h1:DwHCYiCEAdjfNrQOFIid7qgKCll7ubhGS1ji5O8FYng=
github.com/Noooste/go-socks4 v0.0.2/go.mod h1:+oOgtOFRsU8FoK7NBOhHSjiH5pveY8LgYNF5XcqVgjE=
github.com/Noooste/swagger v1.2.0 h1:zGHin8k2V9mXDB1gxXOdKe4V8zhw79ycsw+/L2hH/pk=
github.com/Noooste/swagger v1.2.0/go.mod h1:5N+iUZlFA43k2Paf42EZ+SFndBG1niSA1FAnwiNP1PM=
github.com/Noooste/uquic-go v1.0.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/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-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ=
github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4=
github.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9ZY=
github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk=
github.com/go-openapi/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 v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
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/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
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/schema v1.7.0 h1:yNM+FNRZjyYEli9Ey0AXRBrAY9jTnb+kmGs3lJGPvKg=
github.com/gofiber/schema v1.7.0/go.mod h1:A/X5Ffyru4p9eBdp99qu+nzviHzQiZ7odLT+TwxWhbk=
github.com/gofiber/utils/v2 v2.0.2 h1:ShRRssz0F3AhTlAQcuEj54OEDtWF7+HJDwEi/aa6QLI=
github.com/gofiber/utils/v2 v2.0.2/go.mod h1:+9Ub4NqQ+IaJoTliq5LfdmOJAA/Hzwf4pXOxOa3RrJ0=
github.com/gofiber/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/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/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.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4OxRzU=
github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM=
github.com/klauspost/crc32 v1.3.0/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4=
github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/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.98 h1:MeAVKjLVz+XJ28zFcuYyImNSAh8Mq725uNW4beRisi0=
github.com/minio/minio-go/v7 v7.0.98/go.mod h1:cY0Y+W7yozf0mdIclrttzo1Iiu7mEf9y7nk2uXqMOvM=
github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns=
github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A=
github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/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.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
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/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
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/v3 v3.1.0 h1:jsk0vEAqVvvS9+fTZ5/EcQ9tz860c9pWxJ4Iwecz8gU=
github.com/shamaton/msgpack/v3 v3.1.0/go.mod h1:DcQG8jrdrQCIxr3HlMYkiXdMhK+KfN2CitkyzsQV4uc=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
@@ -148,57 +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.6.3 h1:bCSxiTz386UTgyT1i0MSCvdbWjVW+8sG3PjkGsZQt4s=
github.com/tinylib/msgp v1.6.3/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZyVI=
github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw=
github.com/valyala/fasthttp v1.70.0 h1:LAhMGcWk13QZWm85+eg8ZBNbrq5mnkWFGbHMUJHIdXA=
github.com/valyala/fasthttp v1.70.0/go.mod h1:oDZEHHkJ/Buyklg6uURmYs19442zFSnCIfX3j1FY3pE=
github.com/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/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.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/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.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/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.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
golang.org/x/oauth2 v0.35.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.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/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.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/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.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
golang.org/x/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-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
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/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+136 -47
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"
@@ -20,15 +24,18 @@ type Service struct {
oidcProvider *oidc.Provider
oidcVerifier *oidc.IDTokenVerifier
oauth2Config *oauth2.Config
oidcClient *http.Client
jwtService *JWTService
}
// UserInfo represents authenticated user information
type UserInfo struct {
Username string
Email string
Name string
Roles []string
Username string
Email string
Name string
Roles []string
Teams []string // raw team claim values (team_attribute_path), OIDC only
AuthMethod string // "oidc" | "admin" | "token"; "" on legacy sessions
}
// NewAuthService creates a new authentication service
@@ -56,7 +63,15 @@ func NewAuthService(authCfg *config.AuthConfig, serverCfg *config.ServerConfig)
// initOIDC initializes the OIDC provider and configuration
func (a *Service) initOIDC() error {
ctx := context.Background()
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,6 +105,13 @@ func (a *Service) 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 *Service) ValidateBasicAuth(username, password string) bool {
// Use constant-time comparison to prevent timing attacks
@@ -106,35 +128,6 @@ func (a *Service) ValidateBasicAuth(username, password string) bool {
return usernameMatch && passwordMatch
}
// ParseBasicAuth parses the Authorization header for basic auth
func ParseBasicAuth(authHeader string) (username, password string, ok bool) {
if authHeader == "" {
return "", "", false
}
// Check if it's a Basic auth header
const prefix = "Basic "
if !strings.HasPrefix(authHeader, prefix) {
return "", "", false
}
// Decode base64 credentials
encoded := authHeader[len(prefix):]
decoded, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
return "", "", false
}
// Split username:password
credentials := string(decoded)
parts := strings.SplitN(credentials, ":", 2)
if len(parts) != 2 {
return "", "", false
}
return parts[0], parts[1], true
}
// GetAuthorizationURL returns the OIDC authorization URL for login
func (a *Service) GetAuthorizationURL(state string) (string, error) {
if a.oauth2Config == nil {
@@ -150,7 +143,7 @@ func (a *Service) ExchangeCode(ctx context.Context, code string) (*oauth2.Token,
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)
}
@@ -165,7 +158,7 @@ func (a *Service) VerifyIDToken(ctx context.Context, rawIDToken string) (*UserIn
}
// 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)
}
@@ -188,6 +181,10 @@ func (a *Service) VerifyIDToken(ctx context.Context, rawIDToken string) (*UserIn
userInfo.Roles = extractRoles(claims, a.authConfig.OIDC.RoleAttributePath)
}
if a.authConfig.OIDC.TeamAttributePath != "" {
userInfo.Teams = extractRoles(claims, a.authConfig.OIDC.TeamAttributePath)
}
return userInfo, nil
}
@@ -197,6 +194,8 @@ func (a *Service) GetUserInfo(ctx context.Context, token *oauth2.Token) (*UserIn
return nil, fmt.Errorf("OIDC not initialized")
}
ctx = a.oidcContext(ctx)
// Create OAuth2 token source
tokenSource := a.oauth2Config.TokenSource(ctx, token)
@@ -212,6 +211,8 @@ func (a *Service) GetUserInfo(ctx context.Context, token *oauth2.Token) (*UserIn
return nil, fmt.Errorf("failed to parse user info claims: %w", err)
}
logger.Debug().Interface("claims", claims).Msg("Extracted user info claims")
// Build user info
userInfo := &UserInfo{
Username: extractClaim(claims, a.authConfig.OIDC.UsernameAttribute),
@@ -224,18 +225,82 @@ func (a *Service) GetUserInfo(ctx context.Context, token *oauth2.Token) (*UserIn
userInfo.Roles = extractRoles(claims, a.authConfig.OIDC.RoleAttributePath)
}
if a.authConfig.OIDC.TeamAttributePath != "" {
userInfo.Teams = extractRoles(claims, a.authConfig.OIDC.TeamAttributePath)
}
return userInfo, nil
}
// IsAdmin checks if the user has admin role
// 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)
}
// ExtractTeamsFromAccessToken parses the access token JWT payload and extracts
// team claim values using the configured team_attribute_path. Same rationale
// as ExtractRolesFromAccessToken: Keycloak-style IdPs often emit group claims
// only in the access token, which came from a verified code exchange.
func (a *Service) ExtractTeamsFromAccessToken(accessToken string) []string {
if accessToken == "" || a.authConfig.OIDC.TeamAttributePath == "" {
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.TeamAttributePath)
}
// IsAdmin checks if the user has any of the configured admin roles.
func (a *Service) IsAdmin(userInfo *UserInfo) bool {
if a.authConfig.OIDC.AdminRole == "" {
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
}
}
}
@@ -294,8 +359,21 @@ func extractRoles(claims map[string]interface{}, path string) []string {
return nil
}
// extractStringArray converts an interface{} to []string if possible
// extractStringArray converts an interface{} to []string if possible.
//
// A scalar string is treated as a single-element list: IdPs commonly emit a
// single role as a bare string (e.g. "garage_role": "garage-ui-admin") rather
// than a one-element array, and discarding it would make admin_role checks
// fail with a spurious 403, see https://github.com/Noooste/garage-ui/issues/75
func extractStringArray(value interface{}) []string {
// Try a scalar string (single role emitted as a bare value)
if str, ok := value.(string); ok {
if str == "" {
return nil
}
return []string{str}
}
// Try direct string array
if strArray, ok := value.([]string); ok {
return strArray
@@ -325,9 +403,18 @@ func (a *Service) ValidateAndConsumeState(token string) bool {
return a.jwtService.ValidateAndConsumeState(token)
}
// GenerateSessionToken generates a JWT session token for the user
// 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) {
return a.jwtService.GenerateToken(userInfo, a.authConfig.OIDC.SessionMaxAge)
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
@@ -338,9 +425,11 @@ func (a *Service) ValidateSessionToken(tokenString string) (*UserInfo, error) {
}
return &UserInfo{
Username: claims.Username,
Email: claims.Email,
Name: claims.Name,
Roles: claims.Roles,
Username: claims.Username,
Email: claims.Email,
Name: claims.Name,
Roles: claims.Roles,
Teams: claims.Teams,
AuthMethod: claims.AuthMethod,
}, nil
}
+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")
}
}
+75
View File
@@ -0,0 +1,75 @@
package auth
import (
"testing"
"Noooste/garage-ui/internal/config"
)
func TestSessionTokenRoundTripsTeamsAndMethod(t *testing.T) {
svc, err := NewAuthService(&config.AuthConfig{}, &config.ServerConfig{})
if err != nil {
t.Fatal(err)
}
in := &UserInfo{
Username: "alice",
Email: "alice@example.com",
Teams: []string{"garage-team-backend", "garage-team-data"},
AuthMethod: "oidc",
}
token, err := svc.GenerateSessionToken(in)
if err != nil {
t.Fatal(err)
}
out, err := svc.ValidateSessionToken(token)
if err != nil {
t.Fatal(err)
}
if len(out.Teams) != 2 || out.Teams[0] != "garage-team-backend" {
t.Errorf("Teams = %v, want round-trip", out.Teams)
}
if out.AuthMethod != "oidc" {
t.Errorf("AuthMethod = %q, want oidc", out.AuthMethod)
}
}
func TestExtractTeamsFromAccessToken(t *testing.T) {
svc, err := NewAuthService(&config.AuthConfig{
OIDC: config.OIDCConfig{TeamAttributePath: "groups"},
}, &config.ServerConfig{})
if err != nil {
t.Fatal(err)
}
// Unsigned JWT with {"groups":["team-a","team-b"]} payload. Extraction
// parses claims without verifying (token came from a verified exchange).
// header {"alg":"none"} / payload base64url of {"groups":["team-a","team-b"]}
tok := "eyJhbGciOiJub25lIn0.eyJncm91cHMiOlsidGVhbS1hIiwidGVhbS1iIl19.x"
got := svc.ExtractTeamsFromAccessToken(tok)
if len(got) != 2 || got[0] != "team-a" || got[1] != "team-b" {
t.Errorf("ExtractTeamsFromAccessToken = %v, want [team-a team-b]", got)
}
if got := svc.ExtractTeamsFromAccessToken(""); got != nil {
t.Errorf("empty token should return nil, got %v", got)
}
}
func TestExtractTeamsFromAccessToken_Malformed(t *testing.T) {
svc, err := NewAuthService(&config.AuthConfig{
OIDC: config.OIDCConfig{TeamAttributePath: "groups"},
}, &config.ServerConfig{})
if err != nil {
t.Fatal(err)
}
// Fewer than two dot-separated segments.
if got := svc.ExtractTeamsFromAccessToken("single-segment"); got != nil {
t.Errorf("one-segment token = %v, want nil", got)
}
// Correct shape but the payload segment is not valid base64url.
if got := svc.ExtractTeamsFromAccessToken("hdr.!!!not-base64!!!.sig"); got != nil {
t.Errorf("bad base64 payload = %v, want nil", got)
}
// Valid base64url ("bm90anNvbg" -> "notjson") but not JSON.
if got := svc.ExtractTeamsFromAccessToken("hdr.bm90anNvbg.sig"); got != nil {
t.Errorf("non-JSON payload = %v, want nil", got)
}
}
+668
View File
@@ -0,0 +1,668 @@
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_ScalarStringRoleReturnsSingleElement(t *testing.T) {
// A role_attribute_path that resolves to a scalar string (common when an IdP
// emits a single role, e.g. "garage_role": "garage-ui-admin") must be treated
// as a one-element role list, not silently discarded. Discarding it caused
// admin_role (singular) + scalar claim to yield roles=[] and a spurious 403.
tok := makeAccessToken(t, map[string]any{
"roles": "admin",
})
svc := &Service{
authConfig: &config.AuthConfig{
OIDC: config.OIDCConfig{RoleAttributePath: "roles"},
},
}
got := svc.ExtractRolesFromAccessToken(tok)
if len(got) != 1 || got[0] != "admin" {
t.Errorf("got %v, want [admin]", got)
}
}
func TestExtractRolesFromAccessToken_EmptyScalarStringReturnsNil(t *testing.T) {
// An empty scalar must not produce a [""] role, which would never match a
// configured admin role and only muddies logs.
tok := makeAccessToken(t, map[string]any{
"roles": "",
})
svc := &Service{
authConfig: &config.AuthConfig{
OIDC: config.OIDCConfig{RoleAttributePath: "roles"},
},
}
if got := svc.ExtractRolesFromAccessToken(tok); got != nil {
t.Errorf("expected nil for empty scalar role, 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)
}
})
}
}
+12 -8
View File
@@ -31,10 +31,12 @@ type StateData struct {
}
type SessionClaims struct {
Username string `json:"username"`
Email string `json:"email"`
Name string `json:"name"`
Roles []string `json:"roles"`
Username string `json:"username"`
Email string `json:"email"`
Name string `json:"name"`
Roles []string `json:"roles"`
Teams []string `json:"teams,omitempty"`
AuthMethod string `json:"auth_method,omitempty"`
jwt.RegisteredClaims
}
@@ -160,10 +162,12 @@ func (j *JWTService) GenerateToken(userInfo *UserInfo, sessionMaxAge int) (strin
expiresAt := now.Add(time.Duration(sessionMaxAge) * time.Second)
claims := SessionClaims{
Username: userInfo.Username,
Email: userInfo.Email,
Name: userInfo.Name,
Roles: userInfo.Roles,
Username: userInfo.Username,
Email: userInfo.Email,
Name: userInfo.Name,
Roles: userInfo.Roles,
Teams: userInfo.Teams,
AuthMethod: userInfo.AuthMethod,
RegisteredClaims: jwt.RegisteredClaims{
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(expiresAt),
+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)
}
+89
View File
@@ -0,0 +1,89 @@
package auth
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"strings"
"time"
)
// PreviewTokenLocalsKey is the fiber Locals key the auth middleware sets
// after validating a preview token. The authz middleware reads it to
// authorize the request without a subject.
const PreviewTokenLocalsKey = "previewTokenClaims"
// PreviewClaims identify the single object a preview token can read.
type PreviewClaims struct {
Bucket string `json:"b"`
Key string `json:"k"`
ExpiresAt int64 `json:"exp"`
}
// previewSecret derives the HMAC key from the JWT signing key. A configured
// session key therefore keeps preview URLs valid across restarts, and a
// generated key invalidates them on restart, which the frontend recovers
// from by minting a fresh URL.
func (j *JWTService) previewSecret() []byte {
j.mu.RLock()
defer j.mu.RUnlock()
sum := sha256.Sum256(append([]byte("garage-ui-preview-token:"), j.privateKey...))
return sum[:]
}
// MintPreviewToken signs a token granting read access to one exact object
// until the TTL elapses.
func (a *Service) MintPreviewToken(bucket, key string, ttl time.Duration) (string, time.Time, error) {
expiresAt := time.Now().Add(ttl)
token, err := mintPreviewToken(a.jwtService.previewSecret(), bucket, key, expiresAt)
return token, expiresAt, err
}
// ValidatePreviewToken checks the signature, expiry, and exact object match.
func (a *Service) ValidatePreviewToken(token, bucket, key string) error {
return verifyPreviewToken(a.jwtService.previewSecret(), token, bucket, key, time.Now())
}
func mintPreviewToken(secret []byte, bucket, key string, expiresAt time.Time) (string, error) {
payload, err := json.Marshal(PreviewClaims{Bucket: bucket, Key: key, ExpiresAt: expiresAt.Unix()})
// Defensive and unreachable: marshaling a struct of two strings and an int64
// cannot fail, so this branch stays uncovered by design.
if err != nil {
return "", fmt.Errorf("failed to encode preview claims: %w", err)
}
encoded := base64.RawURLEncoding.EncodeToString(payload)
return encoded + "." + signPreview(secret, encoded), nil
}
func verifyPreviewToken(secret []byte, token, bucket, key string, now time.Time) error {
encoded, sig, ok := strings.Cut(token, ".")
if !ok {
return fmt.Errorf("malformed preview token")
}
if !hmac.Equal([]byte(sig), []byte(signPreview(secret, encoded))) {
return fmt.Errorf("invalid preview token signature")
}
payload, err := base64.RawURLEncoding.DecodeString(encoded)
if err != nil {
return fmt.Errorf("malformed preview token payload")
}
var claims PreviewClaims
if err := json.Unmarshal(payload, &claims); err != nil {
return fmt.Errorf("malformed preview token claims")
}
if now.Unix() > claims.ExpiresAt {
return fmt.Errorf("preview token expired")
}
if claims.Bucket != bucket || claims.Key != key {
return fmt.Errorf("preview token does not match the requested object")
}
return nil
}
func signPreview(secret []byte, encoded string) string {
mac := hmac.New(sha256.New, secret)
mac.Write([]byte(encoded))
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
}
+122
View File
@@ -0,0 +1,122 @@
package auth
import (
"encoding/base64"
"strings"
"testing"
"time"
)
func newPreviewTestService(t *testing.T) *Service {
t.Helper()
jwtSvc, err := NewJWTService()
if err != nil {
t.Fatalf("NewJWTService: %v", err)
}
return &Service{jwtService: jwtSvc}
}
func TestPreviewToken_RoundTrip(t *testing.T) {
svc := newPreviewTestService(t)
token, expiresAt, err := svc.MintPreviewToken("b1", "dir/clip.mp4", time.Hour)
if err != nil {
t.Fatalf("MintPreviewToken: %v", err)
}
if remaining := time.Until(expiresAt); remaining < 59*time.Minute || remaining > time.Hour {
t.Errorf("expiresAt %v is not about an hour away", expiresAt)
}
if err := svc.ValidatePreviewToken(token, "b1", "dir/clip.mp4"); err != nil {
t.Errorf("ValidatePreviewToken: %v", err)
}
}
func TestPreviewToken_Expired(t *testing.T) {
svc := newPreviewTestService(t)
token, _, err := svc.MintPreviewToken("b1", "k", -time.Minute)
if err != nil {
t.Fatalf("MintPreviewToken: %v", err)
}
if err := svc.ValidatePreviewToken(token, "b1", "k"); err == nil {
t.Error("expected expired token to be rejected")
}
}
func TestPreviewToken_WrongObject(t *testing.T) {
svc := newPreviewTestService(t)
token, _, err := svc.MintPreviewToken("b1", "k1", time.Hour)
if err != nil {
t.Fatalf("MintPreviewToken: %v", err)
}
if err := svc.ValidatePreviewToken(token, "b2", "k1"); err == nil {
t.Error("expected wrong bucket to be rejected")
}
if err := svc.ValidatePreviewToken(token, "b1", "k2"); err == nil {
t.Error("expected wrong key to be rejected")
}
}
func TestPreviewToken_Tampered(t *testing.T) {
svc := newPreviewTestService(t)
token, _, err := svc.MintPreviewToken("b1", "k", time.Hour)
if err != nil {
t.Fatalf("MintPreviewToken: %v", err)
}
payload, sig, _ := strings.Cut(token, ".")
flipped := "A" + payload[1:]
if flipped == payload {
flipped = "B" + payload[1:]
}
if err := svc.ValidatePreviewToken(flipped+"."+sig, "b1", "k"); err == nil {
t.Error("expected tampered payload to be rejected")
}
if err := svc.ValidatePreviewToken(payload+".AAAA", "b1", "k"); err == nil {
t.Error("expected tampered signature to be rejected")
}
}
func TestPreviewToken_Malformed(t *testing.T) {
svc := newPreviewTestService(t)
for _, tok := range []string{"", "nodot", "a.b.c", "!!!.???", "bm90anNvbg.sig"} {
if err := svc.ValidatePreviewToken(tok, "b1", "k"); err == nil {
t.Errorf("expected malformed token %q to be rejected", tok)
}
}
}
func TestPreviewToken_DifferentServicesRejectEachOther(t *testing.T) {
a := newPreviewTestService(t)
b := newPreviewTestService(t)
token, _, err := a.MintPreviewToken("b1", "k", time.Hour)
if err != nil {
t.Fatalf("MintPreviewToken: %v", err)
}
if err := b.ValidatePreviewToken(token, "b1", "k"); err == nil {
t.Error("expected a token from another key to be rejected")
}
}
// TestPreviewToken_ValidSignatureMalformedBase64Payload signs a payload that
// is not valid RawURLEncoding, so the signature check passes but the base64
// decode fails. This exercises the decode error branch in verifyPreviewToken.
func TestPreviewToken_ValidSignatureMalformedBase64Payload(t *testing.T) {
svc := newPreviewTestService(t)
secret := svc.jwtService.previewSecret()
enc := "!!not-base64!!"
token := enc + "." + signPreview(secret, enc)
if err := svc.ValidatePreviewToken(token, "b1", "k"); err == nil {
t.Error("expected a validly signed but non-base64 payload to be rejected")
}
}
// TestPreviewToken_ValidSignatureNonJSONPayload signs a valid base64 payload
// whose bytes are not JSON, so the signature and decode both pass but the
// unmarshal fails. This exercises the JSON error branch in verifyPreviewToken.
func TestPreviewToken_ValidSignatureNonJSONPayload(t *testing.T) {
svc := newPreviewTestService(t)
secret := svc.jwtService.previewSecret()
enc := base64.RawURLEncoding.EncodeToString([]byte("not json"))
token := enc + "." + signPreview(secret, enc)
if err := svc.ValidatePreviewToken(token, "b1", "k"); err == nil {
t.Error("expected a validly signed but non-JSON payload to be rejected")
}
}
+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)
}
})
}
}
+129
View File
@@ -0,0 +1,129 @@
package authz
import (
"sort"
"strings"
)
// Subject is who is asking: resolved once per request by the TeamResolver.
type Subject struct {
ID string
IsAdmin bool
Bindings []Binding
ClusterPerms PermSet
}
// Resource is what is being acted on. Empty Bucket means the action is
// global or unscoped (e.g. the ListBuckets endpoint itself).
type Resource struct {
Bucket string
}
// Decision is the outcome of an authorization check.
type Decision struct {
Allow bool
Reason string
}
// Authorizer decides whether a subject may perform an action on a resource.
// It is an interface so enforcement can later move to an external PDP or
// Garage-side scoped tokens without touching handlers.
type Authorizer interface {
Decide(subj Subject, action string, res Resource) Decision
}
type policyAuthorizer struct{}
// NewAuthorizer returns the built-in policy evaluator.
func NewAuthorizer() Authorizer { return policyAuthorizer{} }
func (policyAuthorizer) Decide(subj Subject, action string, res Resource) Decision {
return Decide(subj, action, res)
}
// Decide is the pure decision function. The synthetic admin subject flows
// through the same logic as any team, with no IsAdmin shortcut.
func Decide(subj Subject, action string, res Resource) Decision {
spec, ok := Vocabulary[action]
if !ok {
return Decision{Allow: false, Reason: "unknown_permission"}
}
if spec.Scope == ScopeGlobal {
if _, ok := subj.ClusterPerms[action]; ok {
return Decision{Allow: true, Reason: "cluster_permission"}
}
return Decision{Allow: false, Reason: "no_cluster_permission"}
}
// Prefix-scoped.
for _, b := range subj.Bindings {
if _, ok := b.Permissions[action]; !ok {
continue
}
// Unscoped call (list endpoint): any binding holding the permission
// suffices; per-bucket filtering happens on the response.
if res.Bucket == "" {
return Decision{Allow: true, Reason: "any_binding"}
}
if prefixesMatch(b.BucketPrefixes, res.Bucket) {
return Decision{Allow: true, Reason: "binding_match"}
}
}
return Decision{Allow: false, Reason: "no_matching_binding"}
}
func prefixesMatch(prefixes []string, bucket string) bool {
for _, p := range prefixes {
if p == "*" || strings.HasPrefix(bucket, p) {
return true
}
}
return false
}
// AdminSubject builds the synthetic admin team: one wildcard binding holding
// every prefix-scoped permission (admin-only included) plus every global
// permission. Same code path as any team.
func AdminSubject(id string) Subject {
prefixPerms := PermSet{}
clusterPerms := PermSet{}
for name, spec := range Vocabulary {
if spec.Scope == ScopePrefix {
prefixPerms[name] = struct{}{}
} else {
clusterPerms[name] = struct{}{}
}
}
return Subject{
ID: id,
IsAdmin: true,
Bindings: []Binding{{BucketPrefixes: []string{"*"}, Permissions: prefixPerms}},
ClusterPerms: clusterPerms,
}
}
// EffectivePermissions returns the sorted union of prefix-scoped permissions
// the subject holds on the named bucket. This is the value served in API responses
// so the frontend never does prefix matching. Returns nil when nothing
// matches.
func EffectivePermissions(subj Subject, bucket string) []string {
set := PermSet{}
for _, b := range subj.Bindings {
if !prefixesMatch(b.BucketPrefixes, bucket) {
continue
}
for perm := range b.Permissions {
set[perm] = struct{}{}
}
}
if len(set) == 0 {
return nil
}
out := make([]string, 0, len(set))
for perm := range set {
out = append(out, perm)
}
sort.Strings(out)
return out
}
+117
View File
@@ -0,0 +1,117 @@
package authz
import (
"sort"
"testing"
)
func testSubject() Subject {
return Subject{
ID: "alice@example.com",
Bindings: []Binding{
{BucketPrefixes: []string{"backend-"}, Permissions: PermSet{
"bucket.list": {}, "bucket.read": {}, "bucket.create": {}, "object.read": {}, "object.write": {},
}},
{BucketPrefixes: []string{"shared-"}, Permissions: PermSet{
"bucket.list": {}, "bucket.read": {}, "object.read": {},
}},
},
ClusterPerms: PermSet{"cluster.status": {}},
}
}
func TestDecidePrefixScoped(t *testing.T) {
s := testSubject()
cases := []struct {
action, bucket string
allow bool
}{
{"bucket.read", "backend-api", true},
{"bucket.read", "shared-docs", true},
{"bucket.read", "data-warehouse", false}, // no binding matches
{"object.write", "backend-api", true},
{"object.write", "shared-docs", false}, // readonly binding
{"bucket.create", "backend-new", true}, // prefix guard: new name matches
{"bucket.create", "frontend-new", false}, // prefix guard: no match
{"bucket.delete", "backend-api", false}, // permission not granted at all
}
for _, tc := range cases {
d := Decide(s, tc.action, Resource{Bucket: tc.bucket})
if d.Allow != tc.allow {
t.Errorf("Decide(%s, %s) = %v (%s), want %v", tc.action, tc.bucket, d.Allow, d.Reason, tc.allow)
}
}
}
func TestDecideUnscopedListEndpoint(t *testing.T) {
// GET /buckets carries no bucket name: allowed if ANY binding grants
// bucket.list (the response is filtered per bucket afterwards).
s := testSubject()
if d := Decide(s, "bucket.list", Resource{}); !d.Allow {
t.Errorf("bucket.list with empty resource should be allowed for a subject holding it in any binding: %s", d.Reason)
}
noList := Subject{ID: "bob", Bindings: []Binding{{BucketPrefixes: []string{"x-"}, Permissions: PermSet{"object.read": {}}}}}
if d := Decide(noList, "bucket.list", Resource{}); d.Allow {
t.Error("bucket.list should be denied when no binding grants it")
}
}
func TestDecideGlobal(t *testing.T) {
s := testSubject()
if d := Decide(s, "cluster.status", Resource{}); !d.Allow {
t.Errorf("cluster.status should be allowed: %s", d.Reason)
}
if d := Decide(s, "cluster.statistics", Resource{}); d.Allow {
t.Error("cluster.statistics should be denied")
}
if d := Decide(s, "key.create", Resource{}); d.Allow {
t.Error("admin-only key.create should be denied for a team subject")
}
}
func TestDecideUnknownPermission(t *testing.T) {
if d := Decide(testSubject(), "bucket.explode", Resource{}); d.Allow {
t.Error("unknown permission must be denied")
}
}
func TestAdminSubject(t *testing.T) {
a := AdminSubject("root")
if !a.IsAdmin {
t.Error("AdminSubject must set IsAdmin")
}
// Admin goes through the exact same Decide path, no shortcut.
for _, action := range []string{"bucket.delete", "object.write", "key.create", "key.read_secret", "cluster.layout.apply", "node.repair"} {
if d := Decide(a, action, Resource{Bucket: "any-bucket-at-all"}); !d.Allow {
t.Errorf("admin denied %s: %s", action, d.Reason)
}
}
}
func TestEffectivePermissions(t *testing.T) {
s := testSubject()
got := EffectivePermissions(s, "backend-api")
want := []string{"bucket.create", "bucket.list", "bucket.read", "object.read", "object.write"}
if !equalStrings(got, want) {
t.Errorf("EffectivePermissions(backend-api) = %v, want %v", got, want)
}
if got := EffectivePermissions(s, "data-x"); got != nil {
t.Errorf("EffectivePermissions(data-x) = %v, want nil", got)
}
admin := EffectivePermissions(AdminSubject("root"), "anything")
if !sort.StringsAreSorted(admin) || len(admin) == 0 {
t.Errorf("admin effective permissions should be all prefix-scoped perms, sorted; got %v", admin)
}
}
func equalStrings(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
+244
View File
@@ -0,0 +1,244 @@
package authz
import (
"fmt"
"reflect"
"runtime"
"strconv"
"strings"
"Noooste/garage-ui/internal/auth"
"Noooste/garage-ui/internal/models"
logpkg "Noooste/garage-ui/pkg/logger"
"github.com/gofiber/fiber/v3"
)
// SubjectLocalsKey is the fiber.Ctx.Locals key carrying the resolved Subject.
const SubjectLocalsKey = "authzSubject"
// Middleware wires the policy into Fiber. When the policy is disabled
// (access_control absent) every handler is a passthrough no-op.
type Middleware struct {
enabled bool
resolver TeamResolver
authorizer Authorizer
}
func NewMiddleware(policy *Policy, resolver TeamResolver, authorizer Authorizer) *Middleware {
return &Middleware{enabled: policy.Enabled, resolver: resolver, authorizer: authorizer}
}
// Enabled reports whether access control is active.
func (m *Middleware) Enabled() bool { return m.enabled }
// ResolveSubject computes the request's Subject once, right after
// authentication. Handlers and the capabilities endpoint read the same
// struct, so enforcement and UI can never disagree.
func (m *Middleware) ResolveSubject() fiber.Handler {
return func(c fiber.Ctx) error {
if !m.enabled {
return c.Next()
}
userInfo, ok := c.Locals("userInfo").(*auth.UserInfo)
if !ok || userInfo == nil {
return c.Next() // no identity (auth disabled); Require will deny
}
c.Locals(SubjectLocalsKey, m.resolver.Resolve(userInfo))
return c.Next()
}
}
// SubjectFrom returns the Subject resolved for this request, if any.
func SubjectFrom(c fiber.Ctx) (Subject, bool) {
subj, ok := c.Locals(SubjectLocalsKey).(Subject)
return subj, ok
}
// ScopeResolver extracts the target Resource from the request.
type ScopeResolver func(c fiber.Ctx) Resource
// ScopeNone is for global permissions and unscoped list endpoints.
func ScopeNone(fiber.Ctx) Resource { return Resource{} }
// BucketFromParam reads the bucket name from a URL parameter.
func BucketFromParam(param string) ScopeResolver {
return func(c fiber.Ctx) Resource {
return Resource{Bucket: c.Params(param)}
}
}
// BucketFromBody reads the bucket name from a JSON body {"name": "..."}.
// Fiber buffers the body, so the handler can bind it again afterwards.
func BucketFromBody() ScopeResolver {
return func(c fiber.Ctx) Resource {
var req struct {
Name string `json:"name"`
}
if err := c.Bind().JSON(&req); err != nil {
return Resource{}
}
return Resource{Bucket: req.Name}
}
}
// Require gates a route on the caller holding ALL of perms for the resolved
// resource. One structured decision log line is emitted per check: denies at
// warn, allows at debug.
//
// The closure's function name is the marker VerifyRouteCoverage looks for.
// Do not wrap it in another anonymous function.
func (m *Middleware) Require(scope ScopeResolver, perms ...string) fiber.Handler {
if len(perms) == 0 {
// A no-perms Require would silently no-op (allow everything) yet still
// satisfy VerifyRouteCoverage, defeating the fail-closed guarantee.
panic("authz.Require: at least one permission required")
}
for _, p := range perms {
if !IsValidPermission(p) {
panic(fmt.Sprintf("authz.Require: unknown permission %q", p)) // programmer error, fail at wiring time
}
}
return func(c fiber.Ctx) error {
if !m.enabled {
return c.Next()
}
// A validated preview token authorizes exactly one thing: object.read
// on the object it names. The auth middleware set these claims after
// verifying the signature and the bucket and key match this request.
if claims, ok := c.Locals(auth.PreviewTokenLocalsKey).(*auth.PreviewClaims); ok && claims != nil {
if len(perms) == 1 && perms[0] == PermObjectRead && scope(c).Bucket == claims.Bucket {
logDecision(c, "preview-token", PermObjectRead, claims.Bucket, true, "preview_token")
return c.Next()
}
}
subj, ok := SubjectFrom(c)
if !ok {
logDecision(c, "", strings.Join(perms, ","), "", false, "no_subject")
return forbidden(c, perms[0])
}
res := scope(c)
for _, perm := range perms {
d := m.authorizer.Decide(subj, perm, res)
logDecision(c, subj.ID, perm, res.Bucket, d.Allow, d.Reason)
if !d.Allow {
return forbidden(c, perm)
}
}
return c.Next()
}
}
func forbidden(c fiber.Ctx, perm string) error {
return c.Status(fiber.StatusForbidden).JSON(
models.ErrorResponse(models.ErrCodeForbidden, "Missing permission: "+perm),
)
}
func logDecision(c fiber.Ctx, subject, action, resource string, allow bool, reason string) {
l := logpkg.FromCtx(c.Context())
evt := l.Debug()
if !allow {
evt = l.Warn()
}
decision := "allow"
if !allow {
decision = "deny"
}
evt.Str("subject", subject).
Str("action", action).
Str("resource", resource).
Str("decision", decision).
Str("reason", reason).
Msg("authz_decision")
}
// coverageExemptPaths are /api/v1 routes that intentionally carry no Require:
// health is unauthenticated, capabilities is the frontend's fail-closed
// source and returns only the caller's own permissions.
var coverageExemptPaths = map[string]struct{}{
"/api/v1/health": {},
"/api/v1/capabilities": {},
}
// VerifyRouteCoverage walks the app's route table and errors if any /api/v1
// route lacks a Require handler. Called at startup (and from tests): a new
// endpoint registered without declaring its permission prevents boot instead
// of silently failing open.
//
// .Use()-registered routes need special handling. Group-level middleware
// (api.Use(handler) on the "/api/v1" group) produces synthetic per-method
// bookkeeping entries at exactly the bare prefix. Those aren't endpoints and
// are exempt. But a use-route at any deeper path (api.Use("/sneaky",
// terminalHandler)) IS a reachable endpoint and gets the same fail-closed
// treatment as normal routes.
func VerifyRouteCoverage(app *fiber.App) error {
// fiber.Route doesn't export whether a route was .Use()-registered, so
// classify by diffing GetRoutes() (everything) against GetRoutes(true)
// (use-routes filtered out): entries unmatched in the filtered multiset
// are use-registered.
nonUse := map[string]int{}
for _, r := range app.GetRoutes(true) {
nonUse[routeKey(r)]++
}
var naked []string
for _, route := range app.GetRoutes() {
// Consume the multiset for every route, before any skip, so
// classification stays consistent across the whole table.
isUse := true
if k := routeKey(route); nonUse[k] > 0 {
nonUse[k]--
isUse = false
}
if !strings.HasPrefix(route.Path, "/api/v1") {
continue
}
if isUse && route.Path == "/api/v1" {
continue // group-middleware bookkeeping at the bare prefix
}
if _, exempt := coverageExemptPaths[route.Path]; exempt {
continue
}
if route.Method == fiber.MethodHead && hasRequireForPath(app, fiber.MethodGet, route.Path) {
continue // Fiber auto-registers HEAD mirroring GET
}
if !routeHasRequire(route.Handlers) {
naked = append(naked, route.Method+" "+route.Path)
}
}
if len(naked) > 0 {
return fmt.Errorf("authz: routes without Require permission declaration: %s", strings.Join(naked, ", "))
}
return nil
}
// routeKey identifies a route for the use-route diff. Method+Path+handler
// count is robust enough: two routes sharing all three are interchangeable
// for coverage purposes, and the multiset keeps counts honest.
func routeKey(r fiber.Route) string {
return r.Method + " " + r.Path + " " + strconv.Itoa(len(r.Handlers))
}
func hasRequireForPath(app *fiber.App, method, path string) bool {
for _, route := range app.GetRoutes(true) {
if route.Method == method && route.Path == path {
return routeHasRequire(route.Handlers)
}
}
return false
}
func routeHasRequire(handlers []fiber.Handler) bool {
for _, h := range handlers {
fn := runtime.FuncForPC(fiberHandlerPC(h))
if fn != nil && strings.Contains(fn.Name(), "authz.(*Middleware).Require") {
return true
}
}
return false
}
func fiberHandlerPC(h fiber.Handler) uintptr {
return reflect.ValueOf(h).Pointer()
}
+285
View File
@@ -0,0 +1,285 @@
package authz
import (
"io"
"net/http/httptest"
"strings"
"testing"
"Noooste/garage-ui/internal/auth"
"Noooste/garage-ui/internal/config"
"github.com/gofiber/fiber/v3"
)
func middlewareFixture(t *testing.T) *Middleware {
t.Helper()
policy, err := CompilePolicy(&config.AccessControlConfig{
Teams: []config.TeamConfig{{
Name: "backend",
ClaimValues: []string{"g-backend"},
Bindings: []config.BindingConfig{{
BucketPrefixes: []string{"backend-"},
Permissions: []string{"bucket.read", "bucket.create", "object.read"},
}},
ClusterPermissions: []string{"cluster.status"},
}},
})
if err != nil {
t.Fatal(err)
}
return NewMiddleware(policy, NewTeamResolver(policy, []string{"garage-admin"}), NewAuthorizer())
}
func newTestApp(m *Middleware, userInfo *auth.UserInfo) *fiber.App {
app := fiber.New()
app.Use(func(c fiber.Ctx) error { // stand-in for AuthMiddleware
if userInfo != nil {
c.Locals("userInfo", userInfo)
}
return c.Next()
})
app.Use(m.ResolveSubject())
app.Get("/api/v1/buckets/:name", m.Require(BucketFromParam("name"), PermBucketRead), func(c fiber.Ctx) error {
return c.SendString("ok")
})
app.Post("/api/v1/buckets", m.Require(BucketFromBody(), PermBucketCreate), func(c fiber.Ctx) error {
return c.SendString("created")
})
app.Get("/api/v1/cluster/status", m.Require(ScopeNone, PermClusterStatus), func(c fiber.Ctx) error {
return c.SendString("ok")
})
return app
}
func doReq(t *testing.T, app *fiber.App, method, path, body string) int {
t.Helper()
var reader io.Reader
if body != "" {
reader = strings.NewReader(body)
}
req := httptest.NewRequest(method, path, reader)
if body != "" {
req.Header.Set("Content-Type", "application/json")
}
resp, err := app.Test(req)
if err != nil {
t.Fatal(err)
}
return resp.StatusCode
}
func TestRequireAllowsMatchingTeam(t *testing.T) {
m := middlewareFixture(t)
app := newTestApp(m, &auth.UserInfo{Email: "a@x", AuthMethod: "oidc", Teams: []string{"g-backend"}})
if code := doReq(t, app, "GET", "/api/v1/buckets/backend-api", ""); code != 200 {
t.Errorf("matching bucket: status %d, want 200", code)
}
if code := doReq(t, app, "GET", "/api/v1/cluster/status", ""); code != 200 {
t.Errorf("cluster.status: status %d, want 200", code)
}
}
func TestRequireDeniesOutOfScope(t *testing.T) {
m := middlewareFixture(t)
app := newTestApp(m, &auth.UserInfo{Email: "a@x", AuthMethod: "oidc", Teams: []string{"g-backend"}})
if code := doReq(t, app, "GET", "/api/v1/buckets/data-warehouse", ""); code != 403 {
t.Errorf("non-matching bucket: status %d, want 403", code)
}
}
func TestRequireBucketFromBody(t *testing.T) {
m := middlewareFixture(t)
app := newTestApp(m, &auth.UserInfo{Email: "a@x", AuthMethod: "oidc", Teams: []string{"g-backend"}})
if code := doReq(t, app, "POST", "/api/v1/buckets", `{"name":"backend-new"}`); code != 200 {
t.Errorf("create with matching prefix: status %d, want 200", code)
}
if code := doReq(t, app, "POST", "/api/v1/buckets", `{"name":"other-new"}`); code != 403 {
t.Errorf("create with foreign prefix: status %d, want 403", code)
}
}
func TestRequireDefaultDenyZeroTeamUser(t *testing.T) {
m := middlewareFixture(t)
app := newTestApp(m, &auth.UserInfo{Email: "z@x", AuthMethod: "oidc"})
if code := doReq(t, app, "GET", "/api/v1/buckets/backend-api", ""); code != 403 {
t.Errorf("zero-team user: status %d, want 403", code)
}
}
func TestRequirePassthroughWhenDisabled(t *testing.T) {
policy, _ := CompilePolicy(nil)
m := NewMiddleware(policy, NewTeamResolver(policy, nil), NewAuthorizer())
// No userInfo at all. Disabled access control must not require a subject.
app := newTestApp(m, nil)
if code := doReq(t, app, "GET", "/api/v1/buckets/anything", ""); code != 200 {
t.Errorf("disabled: status %d, want 200", code)
}
}
func TestMiddlewareEnabledReflectsPolicy(t *testing.T) {
if !middlewareFixture(t).Enabled() {
t.Error("Enabled() = false for a configured policy, want true")
}
disabled, _ := CompilePolicy(nil)
m := NewMiddleware(disabled, NewTeamResolver(disabled, nil), NewAuthorizer())
if m.Enabled() {
t.Error("Enabled() = true for a nil (disabled) policy, want false")
}
}
func TestResolveSubjectWithoutUserInfoDenies(t *testing.T) {
// Enabled middleware, but auth set no userInfo local: ResolveSubject leaves
// no subject, and Require then denies for want of one.
m := middlewareFixture(t)
app := newTestApp(m, nil)
if code := doReq(t, app, "GET", "/api/v1/buckets/backend-api", ""); code != 403 {
t.Errorf("enabled + no userInfo: status %d, want 403", code)
}
}
func TestRequireBucketFromBodyMalformedBody(t *testing.T) {
m := middlewareFixture(t)
app := newTestApp(m, &auth.UserInfo{Email: "a@x", AuthMethod: "oidc", Teams: []string{"g-backend"}})
// Malformed JSON: BucketFromBody's bind fails and returns an empty resource.
// An empty bucket is an unscoped check, so a team holding bucket.create in
// any binding is allowed (this is what proves the resource came back empty:
// a non-empty foreign bucket name would be denied instead).
if code := doReq(t, app, "POST", "/api/v1/buckets", "{not-json"); code != 200 {
t.Errorf("malformed body: status %d, want 200 (empty resource, any_binding)", code)
}
}
func TestRequireUnknownPermissionPanics(t *testing.T) {
m := middlewareFixture(t)
defer func() {
r := recover()
if r == nil {
t.Fatal("Require with an unknown permission: want panic, got none")
}
if msg, ok := r.(string); !ok || !strings.Contains(msg, "unknown permission") {
t.Errorf("panic value = %v, want message containing %q", r, "unknown permission")
}
}()
m.Require(ScopeNone, "bogus.permission")
}
func TestVerifyRouteCoverage(t *testing.T) {
m := middlewareFixture(t)
covered := fiber.New()
covered.Get("/api/v1/capabilities", func(c fiber.Ctx) error { return nil }) // exempt
covered.Get("/api/v1/health", func(c fiber.Ctx) error { return nil }) // exempt
covered.Get("/api/v1/x", m.Require(ScopeNone, PermClusterStatus), func(c fiber.Ctx) error { return nil })
covered.Get("/other", func(c fiber.Ctx) error { return nil }) // outside /api/v1
if err := VerifyRouteCoverage(covered); err != nil {
t.Errorf("covered app: %v, want nil", err)
}
uncovered := fiber.New()
uncovered.Get("/api/v1/naked", func(c fiber.Ctx) error { return nil })
err := VerifyRouteCoverage(uncovered)
if err == nil {
t.Fatal("uncovered app: want error, got nil")
}
if !strings.Contains(err.Error(), "/api/v1/naked") {
t.Errorf("error should name the naked route: %v", err)
}
}
func TestVerifyRouteCoverage_GroupUseBookkeepingExempt(t *testing.T) {
// Group-level .Use() middleware produces synthetic per-method entries at
// exactly the bare group prefix; those aren't endpoints and must not trip
// the coverage check as long as the real routes carry Require.
m := middlewareFixture(t)
app := fiber.New()
api := app.Group("/api/v1")
api.Use(func(c fiber.Ctx) error { return c.Next() }) // stand-in for AuthMiddleware
api.Use(m.ResolveSubject())
api.Get("/x", m.Require(ScopeNone, PermClusterStatus), func(c fiber.Ctx) error { return nil })
if err := VerifyRouteCoverage(app); err != nil {
t.Errorf("group-level Use at bare prefix should be exempt: %v", err)
}
}
func TestRequireZeroPermissionsPanics(t *testing.T) {
m := middlewareFixture(t)
defer func() {
r := recover()
if r == nil {
t.Fatal("Require with zero perms: want panic, got none")
}
msg, ok := r.(string)
if !ok || !strings.Contains(msg, "at least one permission required") {
t.Errorf("panic value = %v, want message containing %q", r, "at least one permission required")
}
}()
m.Require(ScopeNone)
}
func newPreviewClaimsApp(m *Middleware, claims *auth.PreviewClaims) *fiber.App {
app := fiber.New()
app.Use(func(c fiber.Ctx) error { // stand-in for AuthMiddleware validating a preview token
if claims != nil {
c.Locals(auth.PreviewTokenLocalsKey, claims)
}
return c.Next()
})
app.Use(m.ResolveSubject())
app.Get("/api/v1/buckets/:bucket/objects/*", m.Require(BucketFromParam("bucket"), PermObjectRead), func(c fiber.Ctx) error {
return c.SendString("bytes")
})
app.Delete("/api/v1/buckets/:bucket/objects/*", m.Require(BucketFromParam("bucket"), PermObjectDelete), func(c fiber.Ctx) error {
return c.SendString("deleted")
})
return app
}
func TestRequirePreviewTokenBypass(t *testing.T) {
m := middlewareFixture(t)
t.Run("matching bucket allows object read without a subject", func(t *testing.T) {
app := newPreviewClaimsApp(m, &auth.PreviewClaims{Bucket: "any-bucket", Key: "k"})
if code := doReq(t, app, "GET", "/api/v1/buckets/any-bucket/objects/k", ""); code != 200 {
t.Errorf("status = %d, want 200", code)
}
})
t.Run("bucket mismatch denies", func(t *testing.T) {
app := newPreviewClaimsApp(m, &auth.PreviewClaims{Bucket: "bucket-a", Key: "k"})
if code := doReq(t, app, "GET", "/api/v1/buckets/bucket-b/objects/k", ""); code != 403 {
t.Errorf("status = %d, want 403", code)
}
})
t.Run("other permissions stay denied", func(t *testing.T) {
app := newPreviewClaimsApp(m, &auth.PreviewClaims{Bucket: "any-bucket", Key: "k"})
if code := doReq(t, app, "DELETE", "/api/v1/buckets/any-bucket/objects/k", ""); code != 403 {
t.Errorf("status = %d, want 403", code)
}
})
t.Run("no claims still requires a subject", func(t *testing.T) {
app := newPreviewClaimsApp(m, nil)
if code := doReq(t, app, "GET", "/api/v1/buckets/any-bucket/objects/k", ""); code != 403 {
t.Errorf("status = %d, want 403", code)
}
})
}
func TestVerifyRouteCoverage_UseRegisteredEndpointFlagged(t *testing.T) {
// A .Use()-registered route at a DEEPER path under /api/v1 is a reachable
// endpoint (Fiber runs it for every method with that prefix); it must get
// the same fail-closed treatment as a normal route.
app := fiber.New()
api := app.Group("/api/v1")
api.Use(func(c fiber.Ctx) error { return c.Next() }) // bare-prefix middleware stays exempt
api.Use("/sneaky", func(c fiber.Ctx) error { return c.SendString("terminal") })
err := VerifyRouteCoverage(app)
if err == nil {
t.Fatal("use-registered endpoint under /api/v1: want error, got nil")
}
if !strings.Contains(err.Error(), "/api/v1/sneaky") {
t.Errorf("error should name /api/v1/sneaky: %v", err)
}
}
+212
View File
@@ -0,0 +1,212 @@
package authz
import (
"fmt"
"strings"
"Noooste/garage-ui/internal/config"
)
// PermSet is a set of concrete permission names.
type PermSet map[string]struct{}
// Binding pairs bucket-name prefixes with the prefix-scoped permissions that
// apply to buckets matching them.
type Binding struct {
BucketPrefixes []string
Permissions PermSet
}
// TeamPolicy is a compiled team: presets resolved, globs expanded, validated.
type TeamPolicy struct {
Name string
ClaimValues []string
Bindings []Binding
ClusterPerms PermSet
}
// Policy is the compiled access-control policy. Enabled=false (access_control
// absent) means "behave exactly as before this feature existed".
type Policy struct {
Enabled bool
Teams []TeamPolicy
byClaim map[string][]int // claim value -> indexes into Teams
}
const presetPrefix = "preset:"
// CompilePolicy validates and compiles the access_control config section.
// Any error here must abort startup; a half-valid policy is worse than none.
func CompilePolicy(cfg *config.AccessControlConfig) (*Policy, error) {
if cfg == nil {
return &Policy{Enabled: false}, nil
}
resolvedPresets := make(map[string]PermSet, len(cfg.Presets))
for name := range cfg.Presets {
perms, err := resolvePreset(cfg.Presets, name, nil)
if err != nil {
return nil, err
}
resolvedPresets[name] = perms
}
p := &Policy{Enabled: true, byClaim: map[string][]int{}}
seenNames := map[string]struct{}{}
for ti, team := range cfg.Teams {
if team.Name == "" {
return nil, fmt.Errorf("access_control: team %d has no name", ti)
}
if _, dup := seenNames[team.Name]; dup {
return nil, fmt.Errorf("access_control: duplicate team name %q", team.Name)
}
seenNames[team.Name] = struct{}{}
if len(team.ClaimValues) == 0 {
return nil, fmt.Errorf("access_control: team %q has empty claim_values", team.Name)
}
if len(team.Bindings) == 0 && len(team.ClusterPermissions) == 0 {
return nil, fmt.Errorf("access_control: team %q must have at least one binding or cluster permission", team.Name)
}
tp := TeamPolicy{Name: team.Name, ClaimValues: team.ClaimValues, ClusterPerms: PermSet{}}
for bi, b := range team.Bindings {
if len(b.BucketPrefixes) == 0 {
return nil, fmt.Errorf("access_control: team %q binding %d has no bucket_prefixes", team.Name, bi)
}
perms, err := resolvePermList(b.Permissions, resolvedPresets, team.Name)
if err != nil {
return nil, err
}
if len(perms) == 0 {
return nil, fmt.Errorf("access_control: team %q binding %d has no permissions", team.Name, bi)
}
for perm := range perms {
if Vocabulary[perm].Scope != ScopePrefix {
return nil, fmt.Errorf("access_control: team %q binding %d: %q is a global permission, put it under cluster_permissions", team.Name, bi, perm)
}
}
tp.Bindings = append(tp.Bindings, Binding{BucketPrefixes: b.BucketPrefixes, Permissions: perms})
}
clusterPerms, err := resolvePermList(team.ClusterPermissions, resolvedPresets, team.Name)
if err != nil {
return nil, err
}
for perm := range clusterPerms {
if Vocabulary[perm].Scope != ScopeGlobal {
return nil, fmt.Errorf("access_control: team %q cluster_permissions: %q is prefix-scoped, put it in a binding", team.Name, perm)
}
}
tp.ClusterPerms = clusterPerms
p.Teams = append(p.Teams, tp)
idx := len(p.Teams) - 1
for _, cv := range team.ClaimValues {
p.byClaim[cv] = append(p.byClaim[cv], idx)
}
}
return p, nil
}
// resolvePermList turns a raw permission list (concrete names, preset refs,
// globs) into a validated PermSet.
func resolvePermList(raw []string, presets map[string]PermSet, teamName string) (PermSet, error) {
out := PermSet{}
for _, entry := range raw {
switch {
case strings.HasPrefix(entry, presetPrefix):
name := strings.TrimPrefix(entry, presetPrefix)
perms, ok := presets[name]
if !ok {
return nil, fmt.Errorf("access_control: team %q references unknown preset %q", teamName, name)
}
for perm := range perms {
out[perm] = struct{}{}
}
case strings.HasSuffix(entry, "*"):
expanded := ExpandGlob(entry)
if expanded == nil {
return nil, fmt.Errorf("access_control: team %q: glob %q matches no permission (unknown permission pattern)", teamName, entry)
}
for _, perm := range expanded {
out[perm] = struct{}{}
}
default:
spec, ok := Vocabulary[entry]
if !ok {
return nil, fmt.Errorf("access_control: team %q: unknown permission %q", teamName, entry)
}
if spec.AdminOnly {
return nil, fmt.Errorf("access_control: team %q: %q is admin-only in v1 and cannot be granted to a team", teamName, entry)
}
out[entry] = struct{}{}
}
}
return out, nil
}
// resolvePreset resolves one preset, following preset:… references with cycle
// detection. path carries the current resolution chain.
func resolvePreset(presets map[string][]string, name string, path []string) (PermSet, error) {
for _, seen := range path {
if seen == name {
return nil, fmt.Errorf("access_control: preset cycle detected: %s -> %s", strings.Join(path, " -> "), name)
}
}
entries, ok := presets[name]
if !ok {
return nil, fmt.Errorf("access_control: unknown preset %q", name)
}
out := PermSet{}
for _, entry := range entries {
switch {
case strings.HasPrefix(entry, presetPrefix):
sub, err := resolvePreset(presets, strings.TrimPrefix(entry, presetPrefix), append(path, name))
if err != nil {
return nil, err
}
for perm := range sub {
out[perm] = struct{}{}
}
case strings.HasSuffix(entry, "*"):
expanded := ExpandGlob(entry)
if expanded == nil {
return nil, fmt.Errorf("access_control: preset %q: glob %q matches no permission", name, entry)
}
for _, perm := range expanded {
out[perm] = struct{}{}
}
default:
spec, ok := Vocabulary[entry]
if !ok {
return nil, fmt.Errorf("access_control: preset %q: unknown permission %q", name, entry)
}
if spec.AdminOnly {
return nil, fmt.Errorf("access_control: preset %q: %q is admin-only in v1 and cannot be granted to a team", name, entry)
}
out[entry] = struct{}{}
}
}
return out, nil
}
// TeamsForClaims returns every team whose claim_values intersect claims.
// Multiple teams sharing a claim value are all returned (union semantics).
func (p *Policy) TeamsForClaims(claims []string) []*TeamPolicy {
if !p.Enabled {
return nil
}
seen := map[int]struct{}{}
var out []*TeamPolicy
for _, c := range claims {
for _, idx := range p.byClaim[c] {
if _, dup := seen[idx]; dup {
continue
}
seen[idx] = struct{}{}
out = append(out, &p.Teams[idx])
}
}
return out
}
+201
View File
@@ -0,0 +1,201 @@
package authz
import (
"strings"
"testing"
"Noooste/garage-ui/internal/config"
)
func validAC() *config.AccessControlConfig {
return &config.AccessControlConfig{
Presets: map[string][]string{
"bucket_readonly": {"bucket.list", "bucket.read", "object.list", "object.read"},
"bucket_owner": {"preset:bucket_readonly", "bucket.create", "bucket.update", "bucket.delete", "object.write", "object.delete"},
},
Teams: []config.TeamConfig{
{
Name: "backend",
ClaimValues: []string{"garage-team-backend"},
Bindings: []config.BindingConfig{
{BucketPrefixes: []string{"backend-"}, Permissions: []string{"preset:bucket_owner"}},
{BucketPrefixes: []string{"shared-"}, Permissions: []string{"preset:bucket_readonly"}},
},
ClusterPermissions: []string{"cluster.status", "cluster.health"},
},
},
}
}
func TestCompilePolicyNilConfig(t *testing.T) {
p, err := CompilePolicy(nil)
if err != nil {
t.Fatalf("CompilePolicy(nil): %v", err)
}
if p.Enabled {
t.Error("nil config must compile to disabled policy")
}
}
func TestCompilePolicyResolvesPresetsAndGlobs(t *testing.T) {
cfg := validAC()
cfg.Teams[0].Bindings[0].Permissions = append(cfg.Teams[0].Bindings[0].Permissions, "bucket_alias.*")
p, err := CompilePolicy(cfg)
if err != nil {
t.Fatalf("CompilePolicy: %v", err)
}
if !p.Enabled {
t.Fatal("policy should be enabled")
}
b0 := p.Teams[0].Bindings[0].Permissions
for _, want := range []string{"bucket.list", "bucket.read", "bucket.create", "object.delete", "bucket_alias.add", "bucket_alias.remove"} {
if _, ok := b0[want]; !ok {
t.Errorf("binding 0 missing %q after preset/glob resolution: %v", want, b0)
}
}
if _, ok := p.Teams[0].Bindings[1].Permissions["bucket.create"]; ok {
t.Error("readonly binding must not gain owner permissions")
}
if _, ok := p.Teams[0].ClusterPerms["cluster.status"]; !ok {
t.Error("cluster_permissions not compiled")
}
}
func TestCompilePolicyValidationErrors(t *testing.T) {
cases := []struct {
name string
mutate func(*config.AccessControlConfig)
errPart string
}{
{"unknown permission", func(c *config.AccessControlConfig) {
c.Teams[0].Bindings[0].Permissions = []string{"bucket.explode"}
}, "unknown permission"},
{"unknown preset", func(c *config.AccessControlConfig) {
c.Teams[0].Bindings[0].Permissions = []string{"preset:nope"}
}, "unknown preset"},
{"preset cycle", func(c *config.AccessControlConfig) {
c.Presets["a"] = []string{"preset:b"}
c.Presets["b"] = []string{"preset:a"}
c.Teams[0].Bindings[0].Permissions = []string{"preset:a"}
}, "cycle"},
{"admin-only to team", func(c *config.AccessControlConfig) {
c.Teams[0].ClusterPermissions = []string{"key.create"}
}, "admin-only"},
{"duplicate team name", func(c *config.AccessControlConfig) {
c.Teams = append(c.Teams, c.Teams[0])
}, "duplicate team"},
{"empty claim_values", func(c *config.AccessControlConfig) {
c.Teams[0].ClaimValues = nil
}, "claim_values"},
{"no bindings or cluster perms", func(c *config.AccessControlConfig) {
c.Teams[0].Bindings = nil
c.Teams[0].ClusterPermissions = nil
}, "at least one"},
{"prefix-scoped perm in cluster_permissions", func(c *config.AccessControlConfig) {
c.Teams[0].ClusterPermissions = []string{"bucket.read"}
}, "prefix-scoped"},
{"global perm in binding", func(c *config.AccessControlConfig) {
c.Teams[0].Bindings[0].Permissions = []string{"cluster.status"}
}, "global"},
{"binding without prefixes", func(c *config.AccessControlConfig) {
c.Teams[0].Bindings[0].BucketPrefixes = nil
}, "bucket_prefixes"},
{"binding with empty permissions", func(c *config.AccessControlConfig) {
c.Teams[0].Bindings[0].Permissions = nil
}, "has no permissions"},
{"empty team name", func(c *config.AccessControlConfig) {
c.Teams[0].Name = ""
}, "has no name"},
{"glob matches nothing in binding", func(c *config.AccessControlConfig) {
c.Teams[0].Bindings[0].Permissions = []string{"nonexistent.*"}
}, "matches no permission"},
{"preset references unknown preset", func(c *config.AccessControlConfig) {
c.Presets["broken"] = []string{"preset:ghost"}
}, "unknown preset"},
{"glob matches nothing in preset", func(c *config.AccessControlConfig) {
c.Presets["globby"] = []string{"nonexistent.*"}
}, "matches no permission"},
{"unknown permission in preset", func(c *config.AccessControlConfig) {
c.Presets["badperm"] = []string{"bucket.explode"}
}, "unknown permission"},
{"admin-only permission in preset", func(c *config.AccessControlConfig) {
c.Presets["adminy"] = []string{"key.create"}
}, "admin-only"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
cfg := validAC()
tc.mutate(cfg)
_, err := CompilePolicy(cfg)
if err == nil {
t.Fatalf("expected error containing %q, got nil", tc.errPart)
}
if !strings.Contains(err.Error(), tc.errPart) {
t.Fatalf("error %q does not contain %q", err.Error(), tc.errPart)
}
})
}
}
func TestCompilePolicyPresetWithGlob(t *testing.T) {
// A preset may itself contain a trailing-star glob; it must expand at
// compile time just like a glob written directly in a binding.
cfg := validAC()
cfg.Presets["aliasops"] = []string{"bucket_alias.*"}
cfg.Teams[0].Bindings[0].Permissions = []string{"preset:aliasops"}
p, err := CompilePolicy(cfg)
if err != nil {
t.Fatalf("CompilePolicy: %v", err)
}
b0 := p.Teams[0].Bindings[0].Permissions
for _, want := range []string{"bucket_alias.add", "bucket_alias.remove"} {
if _, ok := b0[want]; !ok {
t.Errorf("preset glob did not expand %q: %v", want, b0)
}
}
}
func TestTeamsForClaimsDisabledReturnsNil(t *testing.T) {
p, err := CompilePolicy(nil)
if err != nil {
t.Fatal(err)
}
if got := p.TeamsForClaims([]string{"anything"}); got != nil {
t.Errorf("disabled policy matched %v, want nil", got)
}
}
func TestTeamsForClaimsDeduplicatesSameTeam(t *testing.T) {
// One team reachable via two claim values: presenting both must not
// return the team twice.
cfg := validAC()
cfg.Teams[0].ClaimValues = []string{"g-a", "g-b"}
p, err := CompilePolicy(cfg)
if err != nil {
t.Fatal(err)
}
if got := p.TeamsForClaims([]string{"g-a", "g-b"}); len(got) != 1 {
t.Fatalf("TeamsForClaims returned %d teams, want 1 (deduped)", len(got))
}
}
func TestTeamsForClaims(t *testing.T) {
cfg := validAC()
// Second team sharing a claim value with the first: union case from the issue.
cfg.Teams = append(cfg.Teams, config.TeamConfig{
Name: "backend-observers",
ClaimValues: []string{"garage-team-backend"},
ClusterPermissions: []string{"cluster.statistics"},
})
p, err := CompilePolicy(cfg)
if err != nil {
t.Fatalf("CompilePolicy: %v", err)
}
got := p.TeamsForClaims([]string{"garage-team-backend"})
if len(got) != 2 {
t.Fatalf("TeamsForClaims matched %d teams, want 2 (shared claim value)", len(got))
}
if got := p.TeamsForClaims([]string{"unrelated"}); len(got) != 0 {
t.Fatalf("unrelated claim matched %d teams, want 0", len(got))
}
}
+55
View File
@@ -0,0 +1,55 @@
package authz
import (
"Noooste/garage-ui/internal/auth"
)
// TeamResolver maps an authenticated identity to an authorization Subject.
// It is an interface so non-OIDC identity sources (deferred in v1) can plug
// in later without touching middleware or handlers.
type TeamResolver interface {
Resolve(userInfo *auth.UserInfo) Subject
}
type configTeamResolver struct {
policy *Policy
adminRoles []string
}
// NewTeamResolver builds the v1 resolver: OIDC identities resolve through the
// compiled policy; admin/token logins resolve to the synthetic admin subject
// (non-OIDC team mapping is deferred).
func NewTeamResolver(policy *Policy, adminRoles []string) TeamResolver {
return &configTeamResolver{policy: policy, adminRoles: adminRoles}
}
func (r *configTeamResolver) Resolve(userInfo *auth.UserInfo) Subject {
id := userInfo.Email
if id == "" {
id = userInfo.Username
}
// Trust only signed claims, never the transport channel: the auth method
// is a JWT claim stamped at login. Legacy sessions ("") resolve like OIDC
// so a replayed cookie can never escalate.
if userInfo.AuthMethod == "admin" || userInfo.AuthMethod == "token" {
return AdminSubject(id)
}
for _, role := range userInfo.Roles {
for _, adminRole := range r.adminRoles {
if role == adminRole {
return AdminSubject(id)
}
}
}
subj := Subject{ID: id, ClusterPerms: PermSet{}}
for _, team := range r.policy.TeamsForClaims(userInfo.Teams) {
subj.Bindings = append(subj.Bindings, team.Bindings...)
for perm := range team.ClusterPerms {
subj.ClusterPerms[perm] = struct{}{}
}
}
return subj
}
+93
View File
@@ -0,0 +1,93 @@
package authz
import (
"testing"
"Noooste/garage-ui/internal/auth"
"Noooste/garage-ui/internal/config"
)
func resolverFixture(t *testing.T) TeamResolver {
t.Helper()
policy, err := CompilePolicy(&config.AccessControlConfig{
Teams: []config.TeamConfig{
{
Name: "backend",
ClaimValues: []string{"g-backend"},
Bindings: []config.BindingConfig{
{BucketPrefixes: []string{"backend-"}, Permissions: []string{"bucket.list", "bucket.read"}},
},
ClusterPermissions: []string{"cluster.status"},
},
{
Name: "observers",
ClaimValues: []string{"g-backend", "g-obs"},
ClusterPermissions: []string{"cluster.statistics"},
},
},
})
if err != nil {
t.Fatal(err)
}
return NewTeamResolver(policy, []string{"garage-admin"})
}
func TestResolveOIDCTeamUser(t *testing.T) {
r := resolverFixture(t)
s := r.Resolve(&auth.UserInfo{Email: "a@x.com", AuthMethod: "oidc", Teams: []string{"g-backend"}})
if s.IsAdmin {
t.Error("team user must not be admin")
}
if s.ID != "a@x.com" {
t.Errorf("ID = %q", s.ID)
}
if len(s.Bindings) != 1 {
t.Fatalf("bindings = %d, want 1", len(s.Bindings))
}
// Union across the two teams matched by g-backend.
if _, ok := s.ClusterPerms["cluster.status"]; !ok {
t.Error("missing cluster.status")
}
if _, ok := s.ClusterPerms["cluster.statistics"]; !ok {
t.Error("missing cluster.statistics from second team sharing the claim")
}
}
func TestResolveOIDCAdminRole(t *testing.T) {
r := resolverFixture(t)
s := r.Resolve(&auth.UserInfo{Username: "root", AuthMethod: "oidc", Roles: []string{"garage-admin"}})
if !s.IsAdmin {
t.Error("admin-role user must resolve to admin subject")
}
}
func TestResolveNonOIDCIsAdmin(t *testing.T) {
r := resolverFixture(t)
for _, method := range []string{"admin", "token"} {
s := r.Resolve(&auth.UserInfo{Username: "op", AuthMethod: method})
if !s.IsAdmin {
t.Errorf("method %q must resolve to admin (deferred: non-OIDC team mapping)", method)
}
}
}
func TestResolveLegacySessionFailsClosed(t *testing.T) {
// Pre-upgrade JWTs have no auth_method claim. Resolve them like OIDC:
// roles can still grant admin, but there is no channel-based trust.
r := resolverFixture(t)
s := r.Resolve(&auth.UserInfo{Username: "old", AuthMethod: ""})
if s.IsAdmin {
t.Error("legacy session without admin role must not be admin")
}
if len(s.Bindings) != 0 || len(s.ClusterPerms) != 0 {
t.Error("legacy session with no teams must have zero permissions")
}
}
func TestResolveZeroTeamUser(t *testing.T) {
r := resolverFixture(t)
s := r.Resolve(&auth.UserInfo{Email: "b@x.com", AuthMethod: "oidc", Teams: []string{"unmatched"}})
if s.IsAdmin || len(s.Bindings) != 0 || len(s.ClusterPerms) != 0 {
t.Errorf("zero-team subject must have nothing: %+v", s)
}
}
+136
View File
@@ -0,0 +1,136 @@
// Package authz implements the UI-layer access-control policy for garage-ui.
//
// IMPORTANT: this is UI-layer policy, not a security boundary. garage-ui talks
// to Garage with a single admin token and a single S3 credential set; anyone
// holding those bypasses everything here.
package authz
import "strings"
// ScopeKind says what a permission applies to.
type ScopeKind int
const (
// ScopeGlobal permissions are all-or-nothing per team (cluster_permissions).
ScopeGlobal ScopeKind = iota
// ScopePrefix permissions are gated by a binding's bucket_prefixes.
ScopePrefix
)
// PermSpec describes one abstract permission. Endpoints lists the Garage admin
// API endpoint names (or S3 data-plane operations) the permission maps to.
// This registry is the only place those names appear in authz.
type PermSpec struct {
Scope ScopeKind
AdminOnly bool // not grantable to teams in v1; only the synthetic admin subject holds it
Endpoints []string
}
// Permission constants for every permission referenced from route wiring.
const (
PermBucketList = "bucket.list"
PermBucketRead = "bucket.read"
PermBucketCreate = "bucket.create"
PermBucketUpdate = "bucket.update"
PermBucketDelete = "bucket.delete"
PermObjectList = "object.list"
PermObjectRead = "object.read"
PermObjectWrite = "object.write"
PermObjectDelete = "object.delete"
PermAllowBucketKey = "permission.allow_bucket_key"
PermDenyBucketKey = "permission.deny_bucket_key"
PermKeyList = "key.list"
PermKeyRead = "key.read"
PermKeyReadSecret = "key.read_secret"
PermKeyCreate = "key.create"
PermKeyUpdate = "key.update"
PermKeyDelete = "key.delete"
PermClusterStatus = "cluster.status"
PermClusterHealth = "cluster.health"
PermClusterStatistics = "cluster.statistics"
PermNodeInfo = "node.info"
PermNodeStatistics = "node.statistics"
)
// Vocabulary is the full v1 permission registry, ratified in issue #33.
// admin_token.* is deliberately absent (admin-only implicitly, not modeled).
var Vocabulary = map[string]PermSpec{
"bucket.list": {Scope: ScopePrefix, Endpoints: []string{"ListBuckets"}},
"bucket.read": {Scope: ScopePrefix, Endpoints: []string{"GetBucketInfo"}},
"bucket.create": {Scope: ScopePrefix, Endpoints: []string{"CreateBucket"}},
"bucket.update": {Scope: ScopePrefix, Endpoints: []string{"UpdateBucket"}},
"bucket.delete": {Scope: ScopePrefix, Endpoints: []string{"DeleteBucket"}},
"bucket.cleanup_uploads": {Scope: ScopePrefix, Endpoints: []string{"CleanupIncompleteUploads"}},
"bucket.inspect_object": {Scope: ScopePrefix, Endpoints: []string{"InspectObject"}},
"bucket_alias.add": {Scope: ScopePrefix, Endpoints: []string{"AddBucketAlias"}},
"bucket_alias.remove": {Scope: ScopePrefix, Endpoints: []string{"RemoveBucketAlias"}},
// S3 data plane (object browser), no Garage admin endpoint.
"object.list": {Scope: ScopePrefix, Endpoints: []string{"S3:ListObjectsV2"}},
"object.read": {Scope: ScopePrefix, Endpoints: []string{"S3:GetObject", "S3:HeadObject", "S3:PresignGet"}},
"object.write": {Scope: ScopePrefix, Endpoints: []string{"S3:PutObject"}},
"object.delete": {Scope: ScopePrefix, Endpoints: []string{"S3:DeleteObject", "S3:DeleteObjects"}},
"permission.allow_bucket_key": {Scope: ScopePrefix, Endpoints: []string{"AllowBucketKey"}},
"permission.deny_bucket_key": {Scope: ScopePrefix, Endpoints: []string{"DenyBucketKey"}},
"key.list": {Scope: ScopeGlobal, Endpoints: []string{"ListKeys"}},
"key.read": {Scope: ScopeGlobal, Endpoints: []string{"GetKeyInfo"}},
"key.read_secret": {Scope: ScopeGlobal, AdminOnly: true, Endpoints: []string{"GetKeyInfo(showSecretKey)"}},
"key.create": {Scope: ScopeGlobal, AdminOnly: true, Endpoints: []string{"CreateKey"}},
"key.import": {Scope: ScopeGlobal, AdminOnly: true, Endpoints: []string{"ImportKey"}},
"key.update": {Scope: ScopeGlobal, AdminOnly: true, Endpoints: []string{"UpdateKey"}},
"key.delete": {Scope: ScopeGlobal, AdminOnly: true, Endpoints: []string{"DeleteKey"}},
"cluster.status": {Scope: ScopeGlobal, Endpoints: []string{"GetClusterStatus"}},
"cluster.health": {Scope: ScopeGlobal, Endpoints: []string{"GetClusterHealth"}},
"cluster.statistics": {Scope: ScopeGlobal, Endpoints: []string{"GetClusterStatistics"}},
"cluster.connect_nodes": {Scope: ScopeGlobal, Endpoints: []string{"ConnectClusterNodes"}},
"cluster.layout.read": {Scope: ScopeGlobal, Endpoints: []string{"GetClusterLayout"}},
"cluster.layout.history": {Scope: ScopeGlobal, Endpoints: []string{"GetClusterLayoutHistory"}},
"cluster.layout.apply": {Scope: ScopeGlobal, Endpoints: []string{"ApplyClusterLayout"}},
"cluster.layout.skip_dead_nodes": {Scope: ScopeGlobal, Endpoints: []string{"ClusterLayoutSkipDeadNodes"}},
"node.info": {Scope: ScopeGlobal, Endpoints: []string{"GetNodeInfo"}},
"node.statistics": {Scope: ScopeGlobal, Endpoints: []string{"GetNodeStatistics"}},
"node.snapshot": {Scope: ScopeGlobal, Endpoints: []string{"CreateMetadataSnapshot"}},
"node.repair": {Scope: ScopeGlobal, Endpoints: []string{"LaunchRepairOperation"}},
"worker.list": {Scope: ScopeGlobal, Endpoints: []string{"ListWorkers"}},
"worker.info": {Scope: ScopeGlobal, Endpoints: []string{"GetWorkerInfo"}},
"worker.get_variable": {Scope: ScopeGlobal, Endpoints: []string{"GetWorkerVariable"}},
"worker.set_variable": {Scope: ScopeGlobal, Endpoints: []string{"SetWorkerVariable"}},
"block.list_errors": {Scope: ScopeGlobal, Endpoints: []string{"ListBlockErrors"}},
"block.info": {Scope: ScopeGlobal, Endpoints: []string{"GetBlockInfo"}},
}
// IsValidPermission reports whether p names a concrete vocabulary entry.
// Globs are patterns, not permissions, and return false.
func IsValidPermission(p string) bool {
_, ok := Vocabulary[p]
return ok
}
// ExpandGlob expands a trailing-star pattern ("bucket.*", "cluster.layout.*",
// bare "*") against the vocabulary. Admin-only permissions are never matched
// by globs; they must be held via the synthetic admin subject. Returns nil
// when the pattern matches nothing or has no trailing star.
func ExpandGlob(pattern string) []string {
if !strings.HasSuffix(pattern, "*") {
return nil
}
prefix := strings.TrimSuffix(pattern, "*")
var out []string
for name, spec := range Vocabulary {
if spec.AdminOnly {
continue
}
if strings.HasPrefix(name, prefix) {
out = append(out, name)
}
}
return out
}
+122
View File
@@ -0,0 +1,122 @@
package authz
import (
"sort"
"testing"
)
func TestExpandGlobRejectsNonGlob(t *testing.T) {
// A pattern without a trailing star is not a glob and expands to nothing.
if got := ExpandGlob("bucket.read"); got != nil {
t.Errorf("ExpandGlob(%q) = %v, want nil", "bucket.read", got)
}
}
func TestVocabularyContainsRatifiedPermissions(t *testing.T) {
// Spot-check one permission per family plus scope/admin flags.
cases := []struct {
perm string
scope ScopeKind
adminOnly bool
}{
{"bucket.list", ScopePrefix, false},
{"bucket.read", ScopePrefix, false},
{"bucket.create", ScopePrefix, false},
{"bucket_alias.add", ScopePrefix, false},
{"object.list", ScopePrefix, false},
{"object.read", ScopePrefix, false},
{"object.write", ScopePrefix, false},
{"object.delete", ScopePrefix, false},
{"permission.allow_bucket_key", ScopePrefix, false},
{"key.list", ScopeGlobal, false},
{"key.read", ScopeGlobal, false},
{"key.read_secret", ScopeGlobal, true},
{"key.create", ScopeGlobal, true},
{"key.import", ScopeGlobal, true},
{"key.update", ScopeGlobal, true},
{"key.delete", ScopeGlobal, true},
{"cluster.status", ScopeGlobal, false},
{"cluster.layout.apply", ScopeGlobal, false},
{"node.repair", ScopeGlobal, false},
{"worker.set_variable", ScopeGlobal, false},
{"block.info", ScopeGlobal, false},
}
for _, tc := range cases {
spec, ok := Vocabulary[tc.perm]
if !ok {
t.Errorf("missing permission %q", tc.perm)
continue
}
if spec.Scope != tc.scope {
t.Errorf("%s: scope = %v, want %v", tc.perm, spec.Scope, tc.scope)
}
if spec.AdminOnly != tc.adminOnly {
t.Errorf("%s: adminOnly = %v, want %v", tc.perm, spec.AdminOnly, tc.adminOnly)
}
}
if _, ok := Vocabulary["admin_token.list"]; ok {
t.Error("admin_token.* must not be in the v1 vocabulary")
}
if len(Vocabulary) != 40 {
t.Errorf("vocabulary size = %d, want 40", len(Vocabulary))
}
}
func TestIsValidPermission(t *testing.T) {
if !IsValidPermission("bucket.read") {
t.Error("bucket.read should be valid")
}
if IsValidPermission("bucket.explode") {
t.Error("bucket.explode should be invalid")
}
if IsValidPermission("bucket.*") {
t.Error("globs are not permissions; IsValidPermission must reject them")
}
}
func TestExpandGlob(t *testing.T) {
got := ExpandGlob("object.*")
sort.Strings(got)
want := []string{"object.delete", "object.list", "object.read", "object.write"}
if len(got) != len(want) {
t.Fatalf("object.* expanded to %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("object.* expanded to %v, want %v", got, want)
}
}
// key.* must NOT include admin-only permissions.
for _, p := range ExpandGlob("key.*") {
if Vocabulary[p].AdminOnly {
t.Errorf("glob expansion included admin-only permission %q", p)
}
}
// cluster.* includes cluster.layout.* (prefix match on the dotted name).
found := false
for _, p := range ExpandGlob("cluster.*") {
if p == "cluster.layout.apply" {
found = true
}
}
if !found {
t.Error("cluster.* should include cluster.layout.apply")
}
// Bare * expands to every non-admin-only permission.
star := ExpandGlob("*")
if len(star) == 0 {
t.Fatal("* expanded to nothing")
}
for _, p := range star {
if Vocabulary[p].AdminOnly {
t.Errorf("* expansion included admin-only permission %q", p)
}
}
if got := ExpandGlob("nonexistent.*"); got != nil {
t.Errorf("nonexistent.* should expand to nil, got %v", got)
}
}
+198 -16
View File
@@ -2,19 +2,24 @@ package config
import (
"fmt"
"net"
"os"
"strconv"
"strings"
"github.com/spf13/viper"
"Noooste/garage-ui/pkg/logger"
)
// Config represents the application configuration
type Config struct {
Server ServerConfig `mapstructure:"server"`
Garage GarageConfig `mapstructure:"garage"`
Auth AuthConfig `mapstructure:"auth"`
CORS CORSConfig `mapstructure:"cors"`
Logging LoggingConfig `mapstructure:"logging"`
Server ServerConfig `mapstructure:"server"`
Garage GarageConfig `mapstructure:"garage"`
Auth AuthConfig `mapstructure:"auth"`
CORS CORSConfig `mapstructure:"cors"`
Logging LoggingConfig `mapstructure:"logging"`
AccessControl *AccessControlConfig `mapstructure:"access_control"`
}
// ServerConfig contains server-related configuration
@@ -44,9 +49,11 @@ type GarageConfig struct {
// AuthConfig contains authentication configuration
type AuthConfig struct {
Admin AdminAuthConfig `mapstructure:"admin"`
OIDC OIDCConfig `mapstructure:"oidc"`
JWTPrivKey string `mapstructure:"jwt_private_key"` // Ed25519 private key in PEM format for JWT signing (64 bytes)
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)
MetricsPublic bool `mapstructure:"metrics_public"` // Expose Prometheus metrics at top-level /metrics without auth
}
// AdminAuthConfig contains admin authentication settings
@@ -56,6 +63,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,16 +77,15 @@ 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"`
UsernameAttribute string `mapstructure:"username_attribute"`
NameAttribute string `mapstructure:"name_attribute"`
RoleAttributePath string `mapstructure:"role_attribute_path"`
TeamAttributePath string `mapstructure:"team_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 +94,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 +133,55 @@ type LoggingConfig struct {
Format string `mapstructure:"format"`
}
// AccessControlConfig is the optional access_control section. nil (section
// absent) preserves historical behavior: every authenticated user is admin.
// When present, authorization is default-deny and detailed policy validation
// happens in internal/authz.CompilePolicy at startup.
// This section is config-file only (no env-var binding: nested lists don't
// map to flat env vars).
type AccessControlConfig struct {
Presets map[string][]string `mapstructure:"presets"`
Teams []TeamConfig `mapstructure:"teams"`
}
// TeamConfig binds a set of IdP claim values to bucket-prefix bindings and
// cluster-level permissions.
type TeamConfig struct {
Name string `mapstructure:"name"`
ClaimValues []string `mapstructure:"claim_values"`
Bindings []BindingConfig `mapstructure:"bindings"`
ClusterPermissions []string `mapstructure:"cluster_permissions"`
}
// BindingConfig grants a set of permissions (or presets) over buckets whose
// names match one of the given prefixes.
type BindingConfig struct {
BucketPrefixes []string `mapstructure:"bucket_prefixes"`
Permissions []string `mapstructure:"permissions"`
}
// 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 +191,32 @@ 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")
viper.SetDefault("auth.oidc.cookie_name", "garage_session")
viper.SetDefault("auth.oidc.cookie_http_only", true)
viper.SetDefault("auth.oidc.cookie_same_site", "lax")
viper.SetDefault("auth.oidc.session_max_age", 86400)
// 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 +226,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 {
@@ -131,6 +246,16 @@ func Load(configPath string) (*Config, error) {
return nil, fmt.Errorf("error unmarshaling config: %w", err)
}
// mapstructure leaves AccessControl nil when the section is present but
// decodes to an empty map (e.g. "access_control: {}"), even though
// viper.IsSet still reports it present. AccessControlConfig's documented
// semantics are presence-based, not content-based ("nil = absent =
// historical behavior"; "present, even empty = enabled default-deny"),
// so force allocation here rather than silently falling back to nil.
if cfg.AccessControl == nil && viper.IsSet("access_control") {
cfg.AccessControl = &AccessControlConfig{}
}
// Validate the configuration
if err := cfg.Validate(); err != nil {
return nil, fmt.Errorf("invalid configuration: %w", err)
@@ -167,6 +292,10 @@ func bindEnvVars() {
viper.BindEnv("auth.admin.username", "GARAGE_UI_AUTH_ADMIN_USERNAME")
viper.BindEnv("auth.admin.password", "GARAGE_UI_AUTH_ADMIN_PASSWORD")
viper.BindEnv("auth.jwt_private_key", "GARAGE_UI_AUTH_JWT_PRIVATE_KEY")
viper.BindEnv("auth.metrics_public", "GARAGE_UI_AUTH_METRICS_PUBLIC")
// 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")
@@ -175,16 +304,15 @@ 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")
viper.BindEnv("auth.oidc.username_attribute", "GARAGE_UI_AUTH_OIDC_USERNAME_ATTRIBUTE")
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.team_attribute_path", "GARAGE_UI_AUTH_OIDC_TEAM_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 +333,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 +416,16 @@ func (c *Config) Validate() error {
if len(c.Auth.OIDC.Scopes) == 0 {
return fmt.Errorf("oidc scopes are required when oidc is enabled")
}
// With access_control configured, default-deny protects unmatched
// users, so admin roles become optional. Without it, every
// authenticated route grants full admin access, so an empty admin
// role list would promote every IdP user to cluster admin.
if c.AccessControl == nil && len(c.Auth.OIDC.EffectiveAdminRoles()) == 0 {
return fmt.Errorf("oidc admin_role or admin_roles is required when oidc is enabled without access_control: leaving them empty would grant cluster-admin access to any authenticated IdP user")
}
if c.AccessControl != nil && len(c.AccessControl.Teams) > 0 && c.Auth.OIDC.TeamAttributePath == "" {
return fmt.Errorf("auth.oidc.team_attribute_path is required when access_control.teams is set: teams cannot be resolved without it")
}
}
return nil
@@ -251,7 +433,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
+942
View File
@@ -0,0 +1,942 @@
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: "access_control teams without team_attribute_path rejected",
mutate: func(c *Config) {
applyValidOIDC(c)
c.Auth.OIDC.TeamAttributePath = ""
c.AccessControl = &AccessControlConfig{
Teams: []TeamConfig{{Name: "t", ClaimValues: []string{"g"}}},
}
},
wantErrContains: "team_attribute_path is required",
},
{
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)
}
}
// oidcValidYAML is a minimal configuration that enables OIDC and passes
// Validate, but deliberately omits auth.oidc.cookie_name.
const oidcValidYAML = `
server:
host: "0.0.0.0"
port: 8080
root_url: "https://garage.example.com"
garage:
endpoint: http://garage:3900
admin_endpoint: http://garage:3903
admin_token: supersecret
auth:
oidc:
enabled: true
client_id: "garage-ui"
issuer_url: "https://idp.example.com/realms/main"
scopes:
- openid
admin_roles:
- "garage-ui-admin"
`
func TestLoad_OIDCCookieNameDefaultsWhenUnset(t *testing.T) {
resetViper(t)
path := writeConfigFile(t, oidcValidYAML)
cfg, err := Load(path)
if err != nil {
t.Fatalf("Load: %v", err)
}
// An empty cookie name makes Fiber silently drop the session Set-Cookie
// (net/http rejects empty cookie names), which manifests as an OIDC login
// loop. A non-empty default prevents that footgun.
if cfg.Auth.OIDC.CookieName != "garage_session" {
t.Errorf("CookieName = %q, want garage_session (default)", cfg.Auth.OIDC.CookieName)
}
}
func TestLoad_OIDCCookieNameExplicitValueWins(t *testing.T) {
resetViper(t)
path := writeConfigFile(t, oidcValidYAML+" cookie_name: \"custom_session\"\n")
cfg, err := Load(path)
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Auth.OIDC.CookieName != "custom_session" {
t.Errorf("CookieName = %q, want custom_session (explicit override)", cfg.Auth.OIDC.CookieName)
}
}
func TestLoad_OIDCCookieDefaultsWhenUnset(t *testing.T) {
resetViper(t)
path := writeConfigFile(t, oidcValidYAML)
cfg, err := Load(path)
if err != nil {
t.Fatalf("Load: %v", err)
}
// HTTPOnly must default to true: a session cookie readable from JavaScript
// is an XSS token-theft risk.
if !cfg.Auth.OIDC.CookieHTTPOnly {
t.Errorf("CookieHTTPOnly = false, want true (default)")
}
// SessionMaxAge must default to a positive value so the cookie's MaxAge
// agrees with the 24h JWT instead of becoming a session-only cookie.
if cfg.Auth.OIDC.SessionMaxAge != 86400 {
t.Errorf("SessionMaxAge = %d, want 86400 (default)", cfg.Auth.OIDC.SessionMaxAge)
}
if cfg.Auth.OIDC.CookieSameSite != "lax" {
t.Errorf("CookieSameSite = %q, want lax (default)", cfg.Auth.OIDC.CookieSameSite)
}
}
func TestLoad_OIDCCookieDefaultsCanBeOverridden(t *testing.T) {
resetViper(t)
yaml := oidcValidYAML +
" cookie_http_only: false\n" +
" session_max_age: 3600\n" +
" cookie_same_site: \"strict\"\n"
path := writeConfigFile(t, yaml)
cfg, err := Load(path)
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Auth.OIDC.CookieHTTPOnly {
t.Errorf("CookieHTTPOnly = true, want false (explicit override)")
}
if cfg.Auth.OIDC.SessionMaxAge != 3600 {
t.Errorf("SessionMaxAge = %d, want 3600 (explicit override)", cfg.Auth.OIDC.SessionMaxAge)
}
if cfg.Auth.OIDC.CookieSameSite != "strict" {
t.Errorf("CookieSameSite = %q, want strict (explicit override)", cfg.Auth.OIDC.CookieSameSite)
}
}
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 TestAccessControlConfigParsing(t *testing.T) {
resetViper(t)
dir := t.TempDir()
cfgFile := filepath.Join(dir, "config.yaml")
yaml := `
server:
port: 8080
garage:
endpoint: "http://localhost:3900"
admin_endpoint: "http://localhost:3903"
admin_token: "test-token"
auth:
oidc:
enabled: false
team_attribute_path: "groups"
access_control:
presets:
bucket_readonly: [bucket.list, bucket.read]
teams:
- name: backend
claim_values: ["garage-team-backend"]
bindings:
- bucket_prefixes: ["backend-"]
permissions: ["preset:bucket_readonly", "bucket.create"]
cluster_permissions: [cluster.status]
`
if err := os.WriteFile(cfgFile, []byte(yaml), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := Load(cfgFile)
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.AccessControl == nil {
t.Fatal("AccessControl is nil, want parsed section")
}
if got := cfg.Auth.OIDC.TeamAttributePath; got != "groups" {
t.Errorf("TeamAttributePath = %q, want groups", got)
}
if len(cfg.AccessControl.Teams) != 1 {
t.Fatalf("teams = %d, want 1", len(cfg.AccessControl.Teams))
}
team := cfg.AccessControl.Teams[0]
if team.Name != "backend" || len(team.Bindings) != 1 {
t.Errorf("unexpected team: %+v", team)
}
if team.Bindings[0].BucketPrefixes[0] != "backend-" {
t.Errorf("prefix = %q", team.Bindings[0].BucketPrefixes[0])
}
if cfg.AccessControl.Presets["bucket_readonly"][0] != "bucket.list" {
t.Errorf("preset parse failed: %+v", cfg.AccessControl.Presets)
}
}
func TestAccessControlAbsentIsNil(t *testing.T) {
resetViper(t)
dir := t.TempDir()
cfgFile := filepath.Join(dir, "config.yaml")
yaml := `
garage:
endpoint: "http://localhost:3900"
admin_endpoint: "http://localhost:3903"
admin_token: "test-token"
`
if err := os.WriteFile(cfgFile, []byte(yaml), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := Load(cfgFile)
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.AccessControl != nil {
t.Fatalf("AccessControl = %+v, want nil when section absent", cfg.AccessControl)
}
}
func TestAccessControlPresentButEmptyIsNonNil(t *testing.T) {
// A present-but-empty access_control section pins the enablement
// semantics documented on AccessControlConfig: presence, not content,
// turns on default-deny. An operator who writes "access_control: {}"
// (e.g. while staging a config) must get a non-nil, enabled policy, not
// silently fall back to "every authenticated user is admin".
resetViper(t)
dir := t.TempDir()
cfgFile := filepath.Join(dir, "config.yaml")
yaml := `
garage:
endpoint: "http://localhost:3900"
admin_endpoint: "http://localhost:3903"
admin_token: "test-token"
access_control: {}
`
if err := os.WriteFile(cfgFile, []byte(yaml), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := Load(cfgFile)
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.AccessControl == nil {
t.Fatal("AccessControl = nil, want non-nil when section is present but empty")
}
if len(cfg.AccessControl.Teams) != 0 {
t.Errorf("Teams = %+v, want empty", cfg.AccessControl.Teams)
}
}
func TestOIDCAdminRolesOptionalWithAccessControl(t *testing.T) {
// With access_control present, OIDC no longer requires admin_role:
// default-deny protects unmatched users.
cfg := &Config{
Server: ServerConfig{Port: 8080, RootURL: "https://ui.example.com"},
Garage: GarageConfig{Endpoint: "e", AdminEndpoint: "a", AdminToken: "t"},
Auth: AuthConfig{OIDC: OIDCConfig{
Enabled: true, ClientID: "id", IssuerURL: "https://idp", Scopes: []string{"openid"},
}},
AccessControl: &AccessControlConfig{},
}
if err := cfg.Validate(); err != nil {
t.Errorf("Validate with access_control and no admin_role: %v, want nil", err)
}
cfg.AccessControl = nil
if err := cfg.Validate(); err == nil {
t.Error("Validate without access_control and no admin_role should fail")
}
}
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)
}
})
}
}
func TestLoad_MetricsPublic_DefaultsFalse(t *testing.T) {
resetViper(t)
path := writeConfigFile(t, minimalValidYAML)
cfg, err := Load(path)
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Auth.MetricsPublic {
t.Errorf("Auth.MetricsPublic = true, want false by default")
}
}
func TestLoad_MetricsPublic_YAML(t *testing.T) {
resetViper(t)
path := writeConfigFile(t, minimalValidYAML+`
auth:
metrics_public: true
`)
cfg, err := Load(path)
if err != nil {
t.Fatalf("Load: %v", err)
}
if !cfg.Auth.MetricsPublic {
t.Errorf("Auth.MetricsPublic = false, want true from YAML")
}
}
func TestLoad_MetricsPublic_EnvOverridesYAML(t *testing.T) {
resetViper(t)
path := writeConfigFile(t, minimalValidYAML+`
auth:
metrics_public: false
`)
t.Setenv("GARAGE_UI_AUTH_METRICS_PUBLIC", "true")
cfg, err := Load(path)
if err != nil {
t.Fatalf("Load: %v", err)
}
if !cfg.Auth.MetricsPublic {
t.Errorf("Auth.MetricsPublic = false, want true (env should override YAML)")
}
}
+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)
}
})
}
}
+49 -1
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"
@@ -38,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
@@ -88,7 +93,8 @@ func (h *AuthHandler) LoginAdmin(c fiber.Ctx) error {
// Create user info object
userInfo := &auth.UserInfo{
Username: req.Username,
Username: req.Username,
AuthMethod: "admin",
}
// Generate JWT session token
@@ -108,6 +114,48 @@ 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",
AuthMethod: "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
+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)
}
}
+153 -20
View File
@@ -1,20 +1,21 @@
package handlers
import (
"Noooste/garage-ui/internal/authz"
"Noooste/garage-ui/internal/models"
"Noooste/garage-ui/internal/services"
"github.com/gofiber/fiber/v3"
)
// BucketHandler handles bucket-related operations
// BucketHandler handles bucket-related HTTP requests.
type BucketHandler struct {
adminService *services.GarageAdminService
s3Service *services.S3Service
adminService services.AdminService
s3Service services.S3Storage
}
// NewBucketHandler creates a new bucket handler
func NewBucketHandler(adminService *services.GarageAdminService, s3Service *services.S3Service) *BucketHandler {
// NewBucketHandler creates a new bucket handler.
func NewBucketHandler(adminService services.AdminService, s3Service services.S3Storage) *BucketHandler {
return &BucketHandler{
adminService: adminService,
s3Service: s3Service,
@@ -74,11 +75,16 @@ func (h *BucketHandler) ListBuckets(c fiber.Ctx) error {
Size: &detailedInfo.Bytes,
WebsiteAccess: detailedInfo.WebsiteAccess,
WebsiteConfig: detailedInfo.WebsiteConfig,
Quotas: detailedInfo.Quotas,
}
buckets = append(buckets, bucketInfo)
}
if subj, ok := authz.SubjectFrom(c); ok {
buckets = filterBucketsForSubject(buckets, subj)
}
response := models.BucketListResponse{
Buckets: buckets,
Count: len(buckets),
@@ -230,6 +236,10 @@ func (h *BucketHandler) GetBucketInfo(c fiber.Ctx) error {
)
}
if subj, ok := authz.SubjectFrom(c); ok {
bucketInfo.EffectivePermissions = authz.EffectivePermissions(subj, bucketName)
}
return c.JSON(models.SuccessResponse(bucketInfo))
}
@@ -287,23 +297,60 @@ func (h *BucketHandler) GrantBucketPermission(c fiber.Ctx) error {
)
}
// Build the permission request for Garage Admin API
permRequest := models.BucketKeyPermRequest{
BucketID: bucketInfo.ID,
AccessKeyID: req.AccessKeyID,
Permissions: models.BucketKeyPermission{
Read: req.Permissions.Read,
Write: req.Permissions.Write,
Owner: req.Permissions.Owner,
},
// Garage's AllowBucketKey is additive — false values are no-ops, not revokes.
// To make this endpoint a true "set permissions" operation, split into Allow
// for the requested-true perms and Deny for the requested-false perms.
allow := models.BucketKeyPermission{
Read: req.Permissions.Read,
Write: req.Permissions.Write,
Owner: req.Permissions.Owner,
}
deny := models.BucketKeyPermission{
Read: !req.Permissions.Read,
Write: !req.Permissions.Write,
Owner: !req.Permissions.Owner,
}
// Grant permissions using Garage Admin API
result, err := h.adminService.AllowBucketKey(ctx, permRequest)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to grant permissions: "+err.Error()),
)
var result *models.GarageBucketInfo
if allow.Read || allow.Write || allow.Owner {
r, err := h.adminService.AllowBucketKey(ctx, models.BucketKeyPermRequest{
BucketID: bucketInfo.ID,
AccessKeyID: req.AccessKeyID,
Permissions: allow,
})
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to grant permissions: "+err.Error()),
)
}
result = r
}
if deny.Read || deny.Write || deny.Owner {
r, err := h.adminService.DenyBucketKey(ctx, models.BucketKeyPermRequest{
BucketID: bucketInfo.ID,
AccessKeyID: req.AccessKeyID,
Permissions: deny,
})
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to revoke permissions: "+err.Error()),
)
}
result = r
}
if result == nil {
// Caller passed all-false on a key with no existing perms — nothing to do.
// Fetch current bucket state to return a consistent response.
r, err := h.adminService.GetBucketInfo(ctx, bucketInfo.ID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to fetch bucket info: "+err.Error()),
)
}
result = r
}
return c.JSON(models.SuccessResponse(result))
@@ -382,3 +429,89 @@ func (h *BucketHandler) UpdateBucketWebsite(c fiber.Ctx) 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()),
)
}
return c.JSON(models.SuccessResponse(result))
}
// filterBucketsForSubject applies the access-control view of a bucket list:
// a bucket is visible iff the subject holds bucket.list for it, and each
// visible bucket carries the subject's effective permissions.
func filterBucketsForSubject(buckets []models.BucketInfo, subj authz.Subject) []models.BucketInfo {
out := make([]models.BucketInfo, 0, len(buckets))
for _, b := range buckets {
if !authz.Decide(subj, authz.PermBucketList, authz.Resource{Bucket: b.Name}).Allow {
continue
}
b.EffectivePermissions = authz.EffectivePermissions(subj, b.Name)
out = append(out, b)
}
return out
}
@@ -0,0 +1,46 @@
package handlers
import (
"testing"
"Noooste/garage-ui/internal/authz"
"Noooste/garage-ui/internal/models"
)
func teamSubject() authz.Subject {
return authz.Subject{
ID: "alice",
Bindings: []authz.Binding{{
BucketPrefixes: []string{"backend-"},
Permissions: authz.PermSet{"bucket.list": {}, "bucket.read": {}, "object.read": {}},
}},
}
}
func TestFilterBucketsForSubject(t *testing.T) {
buckets := []models.BucketInfo{
{Name: "backend-api"},
{Name: "backend-assets"},
{Name: "data-warehouse"},
}
got := filterBucketsForSubject(buckets, teamSubject())
if len(got) != 2 {
t.Fatalf("filtered to %d buckets, want 2", len(got))
}
for _, b := range got {
if b.Name == "data-warehouse" {
t.Error("data-warehouse must be filtered out")
}
if len(b.EffectivePermissions) == 0 {
t.Errorf("%s: effective_permissions must be populated", b.Name)
}
}
}
func TestFilterBucketsAdminSeesAll(t *testing.T) {
buckets := []models.BucketInfo{{Name: "a"}, {Name: "b"}}
got := filterBucketsForSubject(buckets, authz.AdminSubject("root"))
if len(got) != 2 {
t.Fatalf("admin sees %d buckets, want 2", len(got))
}
}
@@ -0,0 +1,45 @@
package handlers
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"Noooste/garage-ui/internal/authz"
"Noooste/garage-ui/internal/models"
"Noooste/garage-ui/internal/services/mocks"
"github.com/gofiber/fiber/v3"
)
func TestGetBucketInfoPopulatesEffectivePermissions(t *testing.T) {
admin := &mocks.AdminMock{}
admin.GetBucketInfoByAliasFn = func(_ context.Context, _ string) (*models.GarageBucketInfo, error) {
return &models.GarageBucketInfo{ID: "id-1"}, nil
}
h := NewBucketHandler(admin, nil)
app := fiber.New()
app.Get("/buckets/:name", func(c fiber.Ctx) error {
c.Locals(authz.SubjectLocalsKey, teamSubject())
return h.GetBucketInfo(c)
})
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/backend-api", 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)
}
var body struct {
Data models.GarageBucketInfo `json:"data"`
}
decodeJSON(t, resp.Body, &body)
if len(body.Data.EffectivePermissions) == 0 {
t.Error("effective_permissions must be populated for a subject in scope of the bucket")
}
}
+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)
}
}
+74
View File
@@ -0,0 +1,74 @@
package handlers
import (
"sort"
"Noooste/garage-ui/internal/authz"
"Noooste/garage-ui/internal/models"
"Noooste/garage-ui/internal/services"
"github.com/gofiber/fiber/v3"
)
type CapabilitiesHandler struct {
apiVersion string
capabilities services.Capabilities
accessControlEnabled bool
}
func NewCapabilitiesHandler(apiVersion string, capabilities services.Capabilities, accessControlEnabled bool) *CapabilitiesHandler {
return &CapabilitiesHandler{
apiVersion: apiVersion,
capabilities: capabilities,
accessControlEnabled: accessControlEnabled,
}
}
// accessControlBinding mirrors one compiled binding, unflattened: "read on
// backend-*" plus "write on data-*" must never merge into both-on-both.
type accessControlBinding struct {
BucketPrefixes []string `json:"bucket_prefixes"`
Permissions []string `json:"permissions"`
}
type accessControlBlock struct {
Enabled bool `json:"enabled"`
Subject string `json:"subject,omitempty"`
IsAdmin bool `json:"is_admin,omitempty"`
Bindings []accessControlBinding `json:"bindings,omitempty"`
ClusterPermissions []string `json:"cluster_permissions,omitempty"`
}
func (h *CapabilitiesHandler) GetCapabilities(c fiber.Ctx) error {
ac := accessControlBlock{Enabled: h.accessControlEnabled}
if h.accessControlEnabled {
if subj, ok := authz.SubjectFrom(c); ok {
ac.Subject = subj.ID
ac.IsAdmin = subj.IsAdmin
for _, b := range subj.Bindings {
ac.Bindings = append(ac.Bindings, accessControlBinding{
BucketPrefixes: b.BucketPrefixes,
Permissions: sortedPerms(b.Permissions),
})
}
ac.ClusterPermissions = sortedPerms(subj.ClusterPerms)
}
}
return c.JSON(models.SuccessResponse(fiber.Map{
"garageApiVersion": h.apiVersion,
"features": h.capabilities,
"access_control": ac,
}))
}
func sortedPerms(set authz.PermSet) []string {
if len(set) == 0 {
return nil
}
out := make([]string, 0, len(set))
for p := range set {
out = append(out, p)
}
sort.Strings(out)
return out
}
@@ -0,0 +1,161 @@
package handlers
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
"Noooste/garage-ui/internal/authz"
"Noooste/garage-ui/internal/services"
"github.com/gofiber/fiber/v3"
)
func TestCapabilities_V2(t *testing.T) {
app := fiber.New()
h := NewCapabilitiesHandler("v2", services.CapabilitiesV2(), false)
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(), false)
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)
}
}
func TestSortedPermsEmptyReturnsNil(t *testing.T) {
if got := sortedPerms(authz.PermSet{}); got != nil {
t.Errorf("sortedPerms(empty) = %v, want nil", got)
}
if got := sortedPerms(nil); got != nil {
t.Errorf("sortedPerms(nil) = %v, want nil", got)
}
}
func TestGetCapabilitiesAccessControlDisabled(t *testing.T) {
h := NewCapabilitiesHandler("v2", services.CapabilitiesV2(), false)
app := fiber.New()
app.Get("/capabilities", h.GetCapabilities)
resp, err := app.Test(httptest.NewRequest("GET", "/capabilities", nil))
if err != nil {
t.Fatal(err)
}
body, _ := io.ReadAll(resp.Body)
var envelope struct {
Data struct {
AccessControl struct {
Enabled bool `json:"enabled"`
} `json:"access_control"`
} `json:"data"`
}
if err := json.Unmarshal(body, &envelope); err != nil {
t.Fatal(err)
}
if envelope.Data.AccessControl.Enabled {
t.Error("enabled should be false when access control is off")
}
}
func TestGetCapabilitiesAccessControlSubject(t *testing.T) {
h := NewCapabilitiesHandler("v2", services.CapabilitiesV2(), true)
app := fiber.New()
app.Get("/capabilities", func(c fiber.Ctx) error {
c.Locals(authz.SubjectLocalsKey, authz.Subject{
ID: "alice@example.com",
Bindings: []authz.Binding{{
BucketPrefixes: []string{"backend-"},
Permissions: authz.PermSet{"bucket.list": {}, "bucket.read": {}},
}},
ClusterPerms: authz.PermSet{"cluster.status": {}},
})
return h.GetCapabilities(c)
})
resp, err := app.Test(httptest.NewRequest("GET", "/capabilities", nil))
if err != nil {
t.Fatal(err)
}
body, _ := io.ReadAll(resp.Body)
var envelope struct {
Data struct {
AccessControl struct {
Enabled bool `json:"enabled"`
Subject string `json:"subject"`
IsAdmin bool `json:"is_admin"`
ClusterPermissions []string `json:"cluster_permissions"`
Bindings []struct {
BucketPrefixes []string `json:"bucket_prefixes"`
Permissions []string `json:"permissions"`
} `json:"bindings"`
} `json:"access_control"`
} `json:"data"`
}
if err := json.Unmarshal(body, &envelope); err != nil {
t.Fatal(err)
}
ac := envelope.Data.AccessControl
if !ac.Enabled || ac.Subject != "alice@example.com" || ac.IsAdmin {
t.Errorf("unexpected access_control header fields: %+v", ac)
}
if len(ac.Bindings) != 1 || len(ac.Bindings[0].Permissions) != 2 {
t.Errorf("bindings not mirrored unflattened: %+v", ac.Bindings)
}
if len(ac.ClusterPermissions) != 1 || ac.ClusterPermissions[0] != "cluster.status" {
t.Errorf("cluster_permissions = %v", ac.ClusterPermissions)
}
}
+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)
}
}
+315 -36
View File
@@ -1,9 +1,11 @@
package handlers
import (
"bufio"
"io"
"net/url"
"path"
"strconv"
"strings"
"time"
"Noooste/garage-ui/internal/models"
@@ -12,15 +14,80 @@ 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
}
// PreviewTokenMinter mints signed single-object preview tokens.
// auth.Service satisfies it.
type PreviewTokenMinter interface {
MintPreviewToken(bucket, key string, ttl time.Duration) (string, time.Time, error)
}
// previewTokenTTL is long enough that seeking mid-playback keeps working.
// The frontend mints a fresh URL when a token expires.
const previewTokenTTL = time.Hour
// ObjectHandler handles object-related HTTP requests.
type ObjectHandler struct {
s3Service services.S3Storage
previewTokens PreviewTokenMinter
}
// NewObjectHandler creates a new object handler.
func NewObjectHandler(s3Service services.S3Storage, previewTokens PreviewTokenMinter) *ObjectHandler {
return &ObjectHandler{
s3Service: s3Service,
s3Service: s3Service,
previewTokens: previewTokens,
}
}
@@ -33,6 +100,7 @@ func NewObjectHandler(s3Service *services.S3Service) *ObjectHandler {
// @Produce json
// @Param bucket path string true "Name of the bucket to list objects from"
// @Param prefix query string false "Filter objects by prefix"
// @Param search query string false "Recursively search object keys under prefix by case-insensitive substring (best-effort; bypasses max_keys and continuation_token)"
// @Param max_keys query int false "Maximum number of objects to return (default: 100)"
// @Param continuation_token query string false "Token for pagination to retrieve next page of results"
// @Success 200 {object} models.APIResponse{data=models.ObjectListResponse} "Successfully retrieved list of objects and prefixes"
@@ -53,6 +121,21 @@ func (h *ObjectHandler) ListObjects(c fiber.Ctx) error {
// Get query parameters for filtering and pagination
prefix := c.Query("prefix", "")
// Search mode: a recursive, best-effort substring search across the whole
// subtree under prefix. S3/Garage has no server-side substring search, so
// the backend scans and filters. This bypasses page-token pagination and
// max_keys, see S3Service.SearchObjects -> pagination is handled frontend side.
if search := c.Query("search", ""); search != "" {
results, err := h.s3Service.SearchObjects(ctx, bucketName, prefix, search)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeListFailed, "Failed to search objects: "+err.Error()),
)
}
return c.JSON(models.SuccessResponse(results))
}
continuationToken := c.Query("continuation_token", "")
maxKeysStr := c.Query("max_keys", "100")
@@ -138,6 +221,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
@@ -153,12 +288,8 @@ func (h *ObjectHandler) UploadObject(c fiber.Ctx) error {
// @Failure 404 {object} models.APIResponse{error=models.APIError} "Object not found"
// @Router /api/v1/buckets/{bucket}/objects/{key} [get]
func (h *ObjectHandler) GetObject(c fiber.Ctx) error {
ctx := c.Context()
// Get bucket name from URL parameters
bucketName := c.Params("bucket")
// Get object key from locals (set by route handler) or from params
key, ok := c.Locals("objectKey").(string)
if !ok || key == "" {
key = c.Params("key")
@@ -170,7 +301,17 @@ func (h *ObjectHandler) GetObject(c fiber.Ctx) error {
)
}
// Get object from Garage
// Range requests stream a partial body so media elements can seek.
if rangeHeader := c.Get("Range"); rangeHeader != "" {
return h.getObjectRange(c, bucketName, key, rangeHeader)
}
return h.serveFullObject(c, bucketName, key)
}
// serveFullObject streams the whole object with a 200, the pre-Range behavior.
func (h *ObjectHandler) serveFullObject(c fiber.Ctx, bucketName, key string) error {
ctx := c.Context()
body, objectInfo, err := h.s3Service.GetObject(ctx, bucketName, key)
if err != nil {
return c.Status(fiber.StatusNotFound).JSON(
@@ -178,22 +319,78 @@ func (h *ObjectHandler) GetObject(c fiber.Ctx) error {
)
}
// Set response headers
c.Set("Content-Type", objectInfo.ContentType)
// 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("Accept-Ranges", "bytes")
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))
// SendStream (not SendStreamWriter) keeps the declared Content-Length: the
// streaming writer variant forces fasthttp into unknown-length chunked
// transfer, dropping the header we just set above.
return c.SendStream(body, int(objectInfo.Size))
}
// getObjectRange serves a single-range request with 206 Partial Content.
// Malformed and multi-range headers fall back to the full 200 response.
func (h *ObjectHandler) getObjectRange(c fiber.Ctx, bucketName, key, rangeHeader string) error {
ctx := c.Context()
info, err := h.s3Service.GetObjectMetadata(ctx, bucketName, key)
if err != nil {
return c.Status(fiber.StatusNotFound).JSON(
models.ErrorResponse(models.ErrCodeObjectNotFound, "Object not found: "+err.Error()),
)
}
// 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)
})
rng, unsatisfiable := parseRangeHeader(rangeHeader, info.Size)
if unsatisfiable {
c.Set("Accept-Ranges", "bytes")
c.Set("Content-Range", "bytes */"+strconv.FormatInt(info.Size, 10))
return c.SendStatus(fiber.StatusRequestedRangeNotSatisfiable)
}
if rng == nil {
return h.serveFullObject(c, bucketName, key)
}
body, err := h.s3Service.GetObjectRange(ctx, bucketName, key, rng.start, rng.end)
if err != nil {
return c.Status(fiber.StatusNotFound).JSON(
models.ErrorResponse(models.ErrCodeObjectNotFound, "Object not found: "+err.Error()),
)
}
c.Set("Content-Type", safeContentType(info.ContentType))
c.Set("X-Content-Type-Options", "nosniff")
c.Set("Accept-Ranges", "bytes")
c.Set("Content-Length", strconv.FormatInt(rng.end-rng.start+1, 10))
c.Set("Content-Range", "bytes "+strconv.FormatInt(rng.start, 10)+"-"+strconv.FormatInt(rng.end, 10)+"/"+strconv.FormatInt(info.Size, 10))
c.Set("ETag", info.ETag)
c.Set("Last-Modified", info.LastModified.Format(time.RFC1123))
disposition := "inline"
if c.Query("download") == "true" {
disposition = "attachment"
}
c.Set("Content-Disposition", contentDispositionHeader(disposition, key))
c.Status(fiber.StatusPartialContent)
// SendStream (not SendStreamWriter) keeps the declared Content-Length: the
// streaming writer variant forces fasthttp into unknown-length chunked
// transfer, dropping the header we just set above.
return c.SendStream(body, int(rng.end-rng.start+1))
}
// DeleteObject deletes an object from a bucket
@@ -298,6 +495,7 @@ func (h *ObjectHandler) GetObjectMetadata(c fiber.Ctx) error {
)
}
c.Set("Accept-Ranges", "bytes")
return c.JSON(models.SuccessResponse(metadata))
}
@@ -382,6 +580,48 @@ func (h *ObjectHandler) GetPresignedURL(c fiber.Ctx) error {
return c.JSON(models.SuccessResponse(response))
}
// GetPreviewURL mints a short-lived tokenized URL for streaming this object
//
// @Summary Get a tokenized preview URL for an object
// @Description Returns a relative URL carrying a short-lived token that authorizes streaming this object. Media elements cannot send an Authorization header, so the token rides in the URL instead.
// @Tags Objects
// @Produce json
// @Param bucket path string true "Name of the bucket containing the object"
// @Param key path string true "Key (path) of the object"
// @Success 200 {object} models.APIResponse{data=models.PreviewURLResponse} "Preview URL minted"
// @Failure 400 {object} models.APIResponse{error=models.APIError} "Bucket name and object key are required"
// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to mint the preview token"
// @Router /api/v1/buckets/{bucket}/objects/{key}/preview-url [get]
func (h *ObjectHandler) GetPreviewURL(c fiber.Ctx) error {
bucketName := c.Params("bucket")
key, ok := c.Locals("objectKey").(string)
if !ok || key == "" {
key = c.Params("key")
}
if bucketName == "" || key == "" {
return c.Status(fiber.StatusBadRequest).JSON(
models.ErrorResponse(models.ErrCodeBadRequest, "Bucket name and object key are required"),
)
}
token, expiresAt, err := h.previewTokens.MintPreviewToken(bucketName, key, previewTokenTTL)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeInternalError, "Failed to mint the preview token: "+err.Error()),
)
}
previewURL := "/api/v1/buckets/" + url.PathEscape(bucketName) +
"/objects/" + url.PathEscape(key) + "?pt=" + url.QueryEscape(token)
return c.JSON(models.SuccessResponse(models.PreviewURLResponse{
URL: previewURL,
ExpiresAt: expiresAt.UTC().Format(time.RFC3339),
}))
}
// DeleteMultipleObjects deletes multiple objects from a bucket
//
// @Summary Delete multiple objects from bucket
@@ -389,8 +629,8 @@ func (h *ObjectHandler) GetPresignedURL(c fiber.Ctx) error {
// @Tags Objects
// @Accept json
// @Produce json
// @Param bucket path string true "Name of the bucket containing the objects"
// @Param request body object{keys=[]string,prefix=string} true "List of object keys to delete and optional prefix for path context"
// @Param bucket path string true "Name of the bucket containing the objects"
// @Param request body object{keys=[]string,prefixes=[]string} true "Object keys to delete and/or folder prefixes to delete recursively"
// @Success 200 {object} models.APIResponse{data=models.ObjectDeleteMultipleResponse} "Successfully deleted the objects"
// @Failure 400 {object} models.APIResponse{error=models.APIError} "Invalid request parameters"
// @Failure 404 {object} models.APIResponse{error=models.APIError} "Bucket not found"
@@ -407,10 +647,11 @@ func (h *ObjectHandler) DeleteMultipleObjects(c fiber.Ctx) error {
)
}
// Parse request body to get keys and optional prefix
// Parse request body. "keys" are concrete objects to delete; "prefixes" are
// folders to delete recursively (every object stored under the prefix).
var req struct {
Keys []string `json:"keys"`
Prefix string `json:"prefix,omitempty"`
Keys []string `json:"keys"`
Prefixes []string `json:"prefixes,omitempty"`
}
if err := c.Bind().JSON(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(
@@ -418,23 +659,61 @@ func (h *ObjectHandler) DeleteMultipleObjects(c fiber.Ctx) error {
)
}
if len(req.Keys) == 0 {
if len(req.Keys) == 0 && len(req.Prefixes) == 0 {
return c.Status(fiber.StatusBadRequest).JSON(
models.ErrorResponse(models.ErrCodeBadRequest, "At least one key is required"),
models.ErrorResponse(models.ErrCodeBadRequest, "At least one key or prefix is required"),
)
}
// Delete multiple objects
if err := h.s3Service.DeleteMultipleObjects(ctx, bucketName, req.Keys); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeDeleteFailed, "Failed to delete objects: "+err.Error()),
)
// Validate and normalize folder prefixes before running an irreversible
// recursive delete on a public endpoint. A blank prefix would match the
// entire bucket, and a prefix without a trailing slash (e.g. "photos/2024")
// would also match sibling keys such as "photos/2024-old/...". Reject blanks
// with a 4XX and force a trailing slash so a prefix only ever deletes the
// objects inside its own folder.
prefixes := make([]string, 0, len(req.Prefixes))
for _, p := range req.Prefixes {
trimmed := strings.TrimSpace(p)
if trimmed == "" {
return c.Status(fiber.StatusBadRequest).JSON(
models.ErrorResponse(models.ErrCodeBadRequest, "Prefix must not be blank"),
)
}
if !strings.HasSuffix(trimmed, "/") {
trimmed += "/"
}
prefixes = append(prefixes, trimmed)
}
deleted := 0
// Delete the individually selected objects in a single batch call.
if len(req.Keys) > 0 {
n, err := h.s3Service.DeleteMultipleObjects(ctx, bucketName, req.Keys)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeDeleteFailed, "Failed to delete objects: "+err.Error()),
)
}
deleted += n
}
// Recursively delete every object under each selected folder prefix.
for _, prefix := range prefixes {
n, err := h.s3Service.DeleteObjectsByPrefix(ctx, bucketName, prefix)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(
models.ErrorResponse(models.ErrCodeDeleteFailed, "Failed to delete folder "+prefix+": "+err.Error()),
)
}
deleted += n
}
response := models.ObjectDeleteMultipleResponse{
Bucket: bucketName,
Deleted: len(req.Keys),
Keys: req.Keys,
Bucket: bucketName,
Deleted: deleted,
Keys: req.Keys,
Prefixes: prefixes,
}
return c.JSON(models.SuccessResponse(response))
@@ -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)
}
}
})
}
}
File diff suppressed because it is too large Load Diff
+68
View File
@@ -0,0 +1,68 @@
package handlers
import (
"strconv"
"strings"
)
// byteRange is a resolved, inclusive byte range within an object.
type byteRange struct {
start int64
end int64
}
// parseRangeHeader resolves a Range request header against the object size.
// It supports a single "bytes=" range in its three forms: start-end, start-,
// and -suffix. A nil result with unsatisfiable false means serve the full
// object with 200; absent, malformed, and multi-range headers all land there,
// which RFC 9110 permits. unsatisfiable true means respond 416.
func parseRangeHeader(header string, size int64) (rng *byteRange, unsatisfiable bool) {
spec, ok := strings.CutPrefix(header, "bytes=")
if !ok {
return nil, false
}
spec = strings.TrimSpace(spec)
if strings.Contains(spec, ",") {
return nil, false
}
startStr, endStr, ok := strings.Cut(spec, "-")
if !ok {
return nil, false
}
startStr = strings.TrimSpace(startStr)
endStr = strings.TrimSpace(endStr)
// Suffix form "-n" asks for the last n bytes.
if startStr == "" {
n, err := strconv.ParseInt(endStr, 10, 64)
if err != nil {
return nil, false
}
if n <= 0 || size == 0 {
return nil, true
}
if n > size {
n = size
}
return &byteRange{start: size - n, end: size - 1}, false
}
start, err := strconv.ParseInt(startStr, 10, 64)
if err != nil || start < 0 {
return nil, false
}
if start >= size {
return nil, true
}
if endStr == "" {
return &byteRange{start: start, end: size - 1}, false
}
end, err := strconv.ParseInt(endStr, 10, 64)
if err != nil || end < start {
return nil, false
}
if end >= size {
end = size - 1
}
return &byteRange{start: start, end: end}, false
}
+54
View File
@@ -0,0 +1,54 @@
package handlers
import "testing"
func TestParseRangeHeader(t *testing.T) {
cases := []struct {
name string
header string
size int64
wantStart int64
wantEnd int64
wantRange bool
wantUnsatisfy bool
}{
{name: "absent header serves full", header: "", size: 10, wantRange: false},
{name: "simple range", header: "bytes=2-6", size: 10, wantStart: 2, wantEnd: 6, wantRange: true},
{name: "open ended", header: "bytes=500-", size: 1000, wantStart: 500, wantEnd: 999, wantRange: true},
{name: "suffix", header: "bytes=-300", size: 1000, wantStart: 700, wantEnd: 999, wantRange: true},
{name: "suffix larger than object clamps to full", header: "bytes=-5000", size: 1000, wantStart: 0, wantEnd: 999, wantRange: true},
{name: "end clamped to size", header: "bytes=0-99999", size: 100, wantStart: 0, wantEnd: 99, wantRange: true},
{name: "single byte", header: "bytes=0-0", size: 10, wantStart: 0, wantEnd: 0, wantRange: true},
{name: "start beyond size is unsatisfiable", header: "bytes=100-", size: 100, wantUnsatisfy: true},
{name: "suffix zero is unsatisfiable", header: "bytes=-0", size: 100, wantUnsatisfy: true},
{name: "any range on empty object is unsatisfiable", header: "bytes=0-", size: 0, wantUnsatisfy: true},
{name: "suffix on empty object is unsatisfiable", header: "bytes=-5", size: 0, wantUnsatisfy: true},
{name: "multi range ignored", header: "bytes=0-1,3-4", size: 10, wantRange: false},
{name: "non byte unit ignored", header: "items=0-5", size: 10, wantRange: false},
{name: "end before start ignored", header: "bytes=6-2", size: 10, wantRange: false},
{name: "garbage start ignored", header: "bytes=abc-5", size: 10, wantRange: false},
{name: "garbage end ignored", header: "bytes=5-abc", size: 10, wantRange: false},
{name: "garbage suffix ignored", header: "bytes=-abc", size: 10, wantRange: false},
{name: "negative start ignored", header: "bytes=-5-8", size: 10, wantRange: false},
{name: "missing dash ignored", header: "bytes=5", size: 10, wantRange: false},
{name: "whitespace tolerated", header: "bytes= 2-6 ", size: 10, wantStart: 2, wantEnd: 6, wantRange: true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rng, unsatisfiable := parseRangeHeader(tc.header, tc.size)
if unsatisfiable != tc.wantUnsatisfy {
t.Fatalf("unsatisfiable = %v, want %v", unsatisfiable, tc.wantUnsatisfy)
}
if tc.wantRange {
if rng == nil {
t.Fatalf("rng = nil, want %d-%d", tc.wantStart, tc.wantEnd)
}
if rng.start != tc.wantStart || rng.end != tc.wantEnd {
t.Errorf("rng = %d-%d, want %d-%d", rng.start, rng.end, tc.wantStart, tc.wantEnd)
}
} else if rng != nil {
t.Errorf("rng = %d-%d, want nil", rng.start, rng.end)
}
})
}
}
+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)
}
}
+134 -14
View File
@@ -1,63 +1,183 @@
package middleware
import (
"net/url"
"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
// 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
// Preview tokens authenticate object GETs from media elements, which
// cannot send an Authorization header. The token was minted behind an
// object.read check, names one exact object, and expires on its own.
//
// Bucket and key come from the raw request path (previewRouteParts),
// not from c.Params("bucket")/c.Params("*"). routes.go registers this
// AuthMiddleware twice for the object GET route: once cascaded from
// the /api/v1 group's Use middleware (which runs before Fiber has
// matched the specific wildcard route, so its params are not bound
// yet) and once more directly on the route itself (params bound). The
// group-cascaded pass would otherwise dead-end here on empty params
// and fall through to a 401 before the bound-params pass ever runs.
// Parsing the static path shape gives the same, correct answer in
// both positions without weakening the contract: it only ever
// resolves the exact bucket and key named in the URL.
if pt := c.Query("pt"); pt != "" && c.Method() == fiber.MethodGet {
bucket, _ := previewRouteParts(c)
key := previewObjectKey(c)
if bucket != "" && key != "" && authService.ValidatePreviewToken(pt, bucket, key) == nil {
c.Locals(auth.PreviewTokenLocalsKey, &auth.PreviewClaims{Bucket: bucket, Key: key})
enrichRequestLogger(c, "preview-token", "preview_token")
return c.Next()
}
}
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"),
)
}
}
// enrichRequestLogger rebinds the per-request logger in c.Context() with
// user_id and auth_method. Subsequent logpkg.FromCtx(c.Context()) calls
// return the enriched logger.
func enrichRequestLogger(c fiber.Ctx, userID, authMethod string) {
l := logpkg.FromCtx(c.Context()).With().
Str("user_id", userID).
Str("auth_method", authMethod).
Logger()
c.Locals(LoggerLocalsKey, l)
c.SetContext(logpkg.IntoCtx(c.Context(), l))
}
func authMethodsEnabled(cfg *config.AuthConfig) string {
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, "+")
}
// previewRouteParts extracts the bucket and raw (still percent-encoded)
// object key from a request path shaped like
// "/api/v1/buckets/<bucket>/objects/<key>", the only shape the object GET
// route matches. It parses c.Path() directly rather than reading Fiber's
// bound :bucket/* route params, because those params are unset whenever this
// runs ahead of the specific route match (see the AuthMiddleware comment
// above). c.Path() reflects the incoming request path from the start of
// request handling, independent of routing state, so this returns the same
// answer no matter where in the chain it runs. Any path not matching the
// shape returns ("", "").
func previewRouteParts(c fiber.Ctx) (bucket, rawKey string) {
const prefix = "/api/v1/buckets/"
path := c.Path()
if !strings.HasPrefix(path, prefix) {
return "", ""
}
rest := path[len(prefix):]
slash := strings.IndexByte(rest, '/')
if slash < 0 {
return "", ""
}
bucket = rest[:slash]
rest = rest[slash+1:]
const objectsPrefix = "objects/"
if !strings.HasPrefix(rest, objectsPrefix) {
return "", ""
}
return bucket, rest[len(objectsPrefix):]
}
// previewObjectKey decodes the wildcard object key the same way the routes
// layer does. Requests targeting the JSON subroutes return "" because a
// preview token only ever grants the plain byte download.
func previewObjectKey(c fiber.Ctx) string {
_, raw := previewRouteParts(c)
// A raw trailing slash is the one case where c.Path() (used here) and the
// served c.Params("*") diverge: Fiber trims the trailing slash from the
// bound wildcard, so validating against the un-trimmed key could authorize
// a token for "dir/" to serve "dir", or let "x/metadata/" reach the
// /metadata subroute. The SPA always percent-encodes keys as one segment,
// so a legitimate key ending in "/" arrives as "...%2F", never a raw
// trailing slash. Refuse the raw-trailing-slash form so the validated key
// can never diverge from the served key.
if raw == "" || strings.HasSuffix(raw, "/") {
return ""
}
decoded, err := url.QueryUnescape(raw)
if err != nil {
decoded = raw
}
for _, suffix := range []string{"/metadata", "/presign", "/preview-url"} {
if strings.HasSuffix(decoded, suffix) {
return ""
}
}
return decoded
}
+610
View File
@@ -0,0 +1,610 @@
package middleware
import (
"bytes"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"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"])
}
}
// newPreviewTokenApp mirrors the production object GET route shape. Note it
// registers AuthMiddleware via a bare app.Use(), same as routes.go's /api/v1
// group cascade: bucket and key are read from the raw request path
// (previewRouteParts), not from c.Params("bucket")/c.Params("*"), precisely
// because those Fiber route params are not yet bound when a Use()-registered
// middleware executes ahead of the specific :bucket/* route match. routes.go
// registers AuthMiddleware a second time directly on the object route too;
// this test only needs one registration to exercise the same path-parsing
// code the real group cascade hits first.
func newPreviewTokenApp(t *testing.T, authCfg *config.AuthConfig, svc *auth.Service) *fiber.App {
t.Helper()
app := fiber.New()
app.Use(AuthMiddleware(authCfg, svc))
handler := func(c fiber.Ctx) error {
claims, _ := c.Locals(auth.PreviewTokenLocalsKey).(*auth.PreviewClaims)
if claims == nil {
return c.SendString("no-claims")
}
return c.SendString("claims:" + claims.Bucket + "/" + claims.Key)
}
app.Get("/api/v1/buckets/:bucket/objects/*", handler)
app.Delete("/api/v1/buckets/:bucket/objects/*", handler)
return app
}
func previewAuthConfig() *config.AuthConfig {
return &config.AuthConfig{
Admin: config.AdminAuthConfig{Enabled: true, Username: "admin", Password: "pw"},
}
}
func TestAuthMiddleware_ValidPreviewTokenAllowsObjectGET(t *testing.T) {
authCfg := previewAuthConfig()
svc := newAuthSvc(t, authCfg)
app := newPreviewTokenApp(t, authCfg, svc)
token, _, err := svc.MintPreviewToken("b1", "dir/clip.mp4", time.Minute)
if err != nil {
t.Fatalf("MintPreviewToken: %v", err)
}
req := httptest.NewRequest("GET", "/api/v1/buckets/b1/objects/dir%2Fclip.mp4?pt="+url.QueryEscape(token), 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)
}
body, _ := io.ReadAll(resp.Body)
if string(body) != "claims:b1/dir/clip.mp4" {
t.Errorf("body = %q, want the preview claims set in locals", body)
}
}
func TestAuthMiddleware_PreviewTokenRejections(t *testing.T) {
authCfg := previewAuthConfig()
svc := newAuthSvc(t, authCfg)
app := newPreviewTokenApp(t, authCfg, svc)
good, _, err := svc.MintPreviewToken("b1", "k.mp4", time.Minute)
if err != nil {
t.Fatalf("MintPreviewToken: %v", err)
}
expired, _, err := svc.MintPreviewToken("b1", "k.mp4", -time.Minute)
if err != nil {
t.Fatalf("MintPreviewToken: %v", err)
}
// A token whose claimed key genuinely ends in a slash. On the wire the SPA
// sends this as one percent-encoded segment ("dir%2F"); this case instead
// sends the ambiguous raw form ("dir/"). c.Path() keeps the trailing
// slash, but the served c.Params("*") would be trimmed to "dir", so the
// token would name "dir/" while the handler serves "dir": a different
// object. previewObjectKey refuses the raw-trailing-slash form, so this
// falls through to normal auth and 401s.
trailingDir, _, err := svc.MintPreviewToken("b1", "dir/", time.Minute)
if err != nil {
t.Fatalf("MintPreviewToken: %v", err)
}
// A token for a key ending in "/metadata/". Decoded it is "x/metadata/",
// whose HasSuffix "/metadata" is false because of the trailing slash, so
// the subroute guard would not fire; but the served c.Params("*") is
// trimmed to "x/metadata" and routes to the /metadata subroute. The
// raw-trailing-slash refusal blocks this before either divergence matters.
trailingMeta, _, err := svc.MintPreviewToken("b1", "x/metadata/", time.Minute)
if err != nil {
t.Fatalf("MintPreviewToken: %v", err)
}
cases := []struct {
name string
method string
path string
}{
{name: "wrong key", method: "GET", path: "/api/v1/buckets/b1/objects/other.mp4?pt=" + url.QueryEscape(good)},
{name: "wrong bucket", method: "GET", path: "/api/v1/buckets/b2/objects/k.mp4?pt=" + url.QueryEscape(good)},
{name: "expired", method: "GET", path: "/api/v1/buckets/b1/objects/k.mp4?pt=" + url.QueryEscape(expired)},
{name: "metadata subroute", method: "GET", path: "/api/v1/buckets/b1/objects/k.mp4%2Fmetadata?pt=" + url.QueryEscape(good)},
{name: "presign subroute", method: "GET", path: "/api/v1/buckets/b1/objects/k.mp4%2Fpresign?pt=" + url.QueryEscape(good)},
{name: "preview-url subroute", method: "GET", path: "/api/v1/buckets/b1/objects/k.mp4%2Fpreview-url?pt=" + url.QueryEscape(good)},
{name: "delete method", method: "DELETE", path: "/api/v1/buckets/b1/objects/k.mp4?pt=" + url.QueryEscape(good)},
{name: "garbage token", method: "GET", path: "/api/v1/buckets/b1/objects/k.mp4?pt=garbage"},
{name: "raw trailing slash key", method: "GET", path: "/api/v1/buckets/b1/objects/dir/?pt=" + url.QueryEscape(trailingDir)},
{name: "raw trailing slash reaching metadata subroute", method: "GET", path: "/api/v1/buckets/b1/objects/x/metadata/?pt=" + url.QueryEscape(trailingMeta)},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
resp, err := app.Test(httptest.NewRequest(tc.method, tc.path, nil))
if err != nil {
t.Fatalf("app.Test: %v", err)
}
if resp.StatusCode != 401 {
t.Errorf("status = %d, want 401 fallthrough to normal auth", resp.StatusCode)
}
})
}
}
// TestPreviewRouteParts exercises previewRouteParts directly against every
// branch of its path-shape parsing: no "/api/v1/buckets/" prefix, a bucket
// segment with nothing after it, a bucket segment followed by something
// other than "objects/", and the well formed shape. This parsing runs in
// place of Fiber's :bucket/* param binding (see the comment on
// previewRouteParts), so its edge cases need direct coverage independent of
// AuthMiddleware's own tests.
func TestPreviewRouteParts(t *testing.T) {
app := fiber.New()
var gotBucket, gotKey string
app.Get("/*", func(c fiber.Ctx) error {
gotBucket, gotKey = previewRouteParts(c)
return c.SendString("ok")
})
cases := []struct {
name string
path string
wantBucket string
wantKey string
}{
{name: "no buckets prefix", path: "/other/path", wantBucket: "", wantKey: ""},
{name: "bucket segment with no trailing slash", path: "/api/v1/buckets/mybucket", wantBucket: "", wantKey: ""},
{name: "segment after bucket is not objects", path: "/api/v1/buckets/mybucket/permissions", wantBucket: "", wantKey: ""},
{name: "well formed", path: "/api/v1/buckets/mybucket/objects/dir%2Fclip.mp4", wantBucket: "mybucket", wantKey: "dir%2Fclip.mp4"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
gotBucket, gotKey = "unset", "unset"
resp, err := app.Test(httptest.NewRequest("GET", tc.path, nil))
if err != nil {
t.Fatalf("app.Test: %v", err)
}
if resp.StatusCode != 200 {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
if gotBucket != tc.wantBucket || gotKey != tc.wantKey {
t.Errorf("previewRouteParts(%q) = (%q, %q), want (%q, %q)", tc.path, gotBucket, gotKey, tc.wantBucket, tc.wantKey)
}
})
}
}
// TestPreviewObjectKey covers previewObjectKey's own branches beyond what
// the AuthMiddleware rejection tests exercise incidentally: a plain key with
// no reserved suffix decodes normally, and a path that previewRouteParts
// can't parse at all yields "".
func TestPreviewObjectKey(t *testing.T) {
app := fiber.New()
var got string
app.Get("/*", func(c fiber.Ctx) error {
got = previewObjectKey(c)
return c.SendString("ok")
})
cases := []struct {
name string
path string
want string
}{
{name: "plain key decodes", path: "/api/v1/buckets/b/objects/dir%2Fclip.mp4", want: "dir/clip.mp4"},
{name: "unparseable route returns empty", path: "/not-a-bucket-route", want: ""},
// A key that genuinely ends in a slash is legitimate when the SPA sends
// it as one encoded segment ("dir%2F"): raw has no literal trailing
// slash, so it is accepted and decodes to "dir/", matching the served
// key. Only the raw-trailing-slash form is refused.
{name: "encoded trailing slash accepted", path: "/api/v1/buckets/b/objects/dir%2F", want: "dir/"},
// The ambiguous raw-trailing-slash form is refused (returns "").
{name: "raw trailing slash refused", path: "/api/v1/buckets/b/objects/dir/", want: ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got = "unset"
resp, err := app.Test(httptest.NewRequest("GET", tc.path, nil))
if err != nil {
t.Fatalf("app.Test: %v", err)
}
if resp.StatusCode != 200 {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
if got != tc.want {
t.Errorf("previewObjectKey(%q) = %q, want %q", tc.path, got, tc.want)
}
})
}
}
+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)
}
}
+26 -1
View File
@@ -1,6 +1,9 @@
package models
import "time"
import (
"encoding/json"
"time"
)
// GarageKeyInfo represents detailed information about a Garage access key
type GarageKeyInfo struct {
@@ -83,6 +86,10 @@ type GarageBucketInfo struct {
UnfinishedMultipartUploadParts int64 `json:"unfinishedMultipartUploadParts"`
UnfinishedMultipartUploadBytes int64 `json:"unfinishedMultipartUploadBytes"`
Quotas *BucketQuotas `json:"quotas,omitempty"`
// EffectivePermissions is the caller's prefix-scoped permissions on this
// bucket, computed server-side. Omitted when access control is disabled.
EffectivePermissions []string `json:"effective_permissions,omitempty"`
}
// BucketWebsiteConfig represents website configuration for a bucket
@@ -180,6 +187,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"`
@@ -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)
}
}
+8 -37
View File
@@ -12,48 +12,11 @@ type GrantBucketPermissionRequest struct {
Permissions BucketKeyPermission `json:"permissions" validate:"required"`
}
// DeleteBucketRequest represents a request to delete a bucket
type DeleteBucketRequest struct {
Name string `json:"name" validate:"required"`
}
// ListObjectsRequest represents a request to list objects in a bucket
type ListObjectsRequest struct {
Bucket string `json:"bucket" validate:"required"`
Prefix string `json:"prefix,omitempty"`
MaxKeys int `json:"max_keys,omitempty"`
Marker string `json:"marker,omitempty"`
}
// UploadObjectRequest represents metadata for an object upload
type UploadObjectRequest struct {
Bucket string `json:"bucket" validate:"required"`
Key string `json:"key" validate:"required"`
ContentType string `json:"content_type,omitempty"`
}
// DeleteObjectRequest represents a request to delete an object
type DeleteObjectRequest struct {
Bucket string `json:"bucket" validate:"required"`
Key string `json:"key" validate:"required"`
}
// GetObjectRequest represents a request to get/download an object
type GetObjectRequest struct {
Bucket string `json:"bucket" validate:"required"`
Key string `json:"key" validate:"required"`
}
// CreateUserRequest represents a request to create a new user/key
type CreateUserRequest struct {
Name string `json:"name,omitempty"`
}
// DeleteUserRequest represents a request to delete a user/key
type DeleteUserRequest struct {
AccessKey string `json:"access_key" validate:"required"`
}
// UpdateUserRequest represents a request to update user permissions
type UpdateUserRequest struct {
Status *string `json:"status,omitempty"` // "active" or "inactive"
@@ -66,3 +29,11 @@ type UpdateBucketWebsiteRequest struct {
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"`
}
+15 -10
View File
@@ -47,6 +47,11 @@ type BucketInfo struct {
Region string `json:"region,omitempty"`
WebsiteAccess bool `json:"websiteAccess"`
WebsiteConfig *BucketWebsiteConfig `json:"websiteConfig,omitempty"`
Quotas *BucketQuotas `json:"quotas,omitempty"`
// EffectivePermissions is the caller's prefix-scoped permissions on this
// bucket, computed server-side. Omitted when access control is disabled.
EffectivePermissions []string `json:"effective_permissions,omitempty"`
}
// BucketListResponse represents a list of buckets
@@ -138,13 +143,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
@@ -152,10 +150,16 @@ type PresignedURLResponse struct {
Key string `json:"key"`
}
type PreviewURLResponse struct {
URL string `json:"url"`
ExpiresAt string `json:"expires_at"`
}
type ObjectDeleteMultipleResponse struct {
Bucket string `json:"bucket"`
Deleted int `json:"deleted"`
Keys []string `json:"keys"`
Bucket string `json:"bucket"`
Deleted int `json:"deleted"`
Keys []string `json:"keys"`
Prefixes []string `json:"prefixes,omitempty"`
}
// UserListResponse represents a list of users/keys
@@ -203,4 +207,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)
}
}
+132 -77
View File
@@ -2,6 +2,7 @@ package routes
import (
"Noooste/garage-ui/internal/auth"
"Noooste/garage-ui/internal/authz"
"Noooste/garage-ui/internal/config"
"Noooste/garage-ui/internal/handlers"
"Noooste/garage-ui/internal/middleware"
@@ -29,6 +30,8 @@ func SetupRoutes(
userHandler *handlers.UserHandler,
clusterHandler *handlers.ClusterHandler,
monitoringHandler *handlers.MonitoringHandler,
capabilitiesHandler *handlers.CapabilitiesHandler,
az *authz.Middleware,
) {
// Apply CORS middleware globally
app.Use(middleware.CORSMiddleware(&cfg.CORS))
@@ -46,125 +49,124 @@ func SetupRoutes(
// Auth configuration endpoint (always accessible, no auth required)
app.Get("/auth/config", authHandler.GetAuthConfig)
// Public Prometheus metrics endpoint (no auth), opt-in via auth.metrics_public.
// Registered outside /api/v1 so it bypasses the AuthMiddleware/ResolveSubject
// cascade and the VerifyRouteCoverage fail-closed guard entirely; the
// authenticated /api/v1/monitoring/metrics route is unaffected. Because it is
// registered before the SPA fallback below, Fiber matches it first.
// Protect it at the network layer (NetworkPolicy / trusted scrape network).
if cfg.Auth.MetricsPublic {
app.Get("/metrics", monitoringHandler.GetMetrics)
}
// API v1 group
api := app.Group("/api/v1")
// Apply authentication middleware to all API routes
api.Use(middleware.AuthMiddleware(&cfg.Auth, authService))
// Resolve the authz Subject once per request, right after authentication.
api.Use(az.ResolveSubject())
api.Get("/capabilities", capabilitiesHandler.GetCapabilities)
// Bucket routes
buckets := api.Group("/buckets")
{
buckets.Get("/", bucketHandler.ListBuckets) // List all buckets
buckets.Post("/", bucketHandler.CreateBucket) // Create a new bucket
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.Get("/", az.Require(authz.ScopeNone, authz.PermBucketList), bucketHandler.ListBuckets) // List all buckets
buckets.Post("/", az.Require(authz.BucketFromBody(), authz.PermBucketCreate), bucketHandler.CreateBucket) // Create a new bucket
buckets.Get("/:name", az.Require(authz.BucketFromParam("name"), authz.PermBucketRead), bucketHandler.GetBucketInfo) // Get bucket info
buckets.Delete("/:name", az.Require(authz.BucketFromParam("name"), authz.PermBucketDelete), bucketHandler.DeleteBucket) // Delete a bucket
buckets.Post("/:name/permissions", az.Require(authz.BucketFromParam("name"), authz.PermAllowBucketKey, authz.PermDenyBucketKey), bucketHandler.GrantBucketPermission) // Grant bucket permissions (allow+deny)
buckets.Put("/:name/website", az.Require(authz.BucketFromParam("name"), authz.PermBucketUpdate), bucketHandler.UpdateBucketWebsite) // Update bucket website configuration
buckets.Put("/:name/quotas", az.Require(authz.BucketFromParam("name"), authz.PermBucketUpdate), bucketHandler.UpdateBucketQuotas) // Update bucket quotas
}
// Object routes
objects := api.Group("/buckets/:bucket/objects")
{
objects.Get("/", objectHandler.ListObjects) // List objects in bucket
objects.Post("/", objectHandler.UploadObject) // Upload object (multipart)
objects.Post("/upload-multiple", objectHandler.UploadMultipleObjects) // Upload multiple objects
objects.Post("/delete-multiple", objectHandler.DeleteMultipleObjects) // Delete multiple objects
objects.Get("/", az.Require(authz.BucketFromParam("bucket"), authz.PermObjectList), objectHandler.ListObjects) // List objects in bucket
objects.Post("/", az.Require(authz.BucketFromParam("bucket"), authz.PermObjectWrite), objectHandler.UploadObject) // Upload object (multipart)
objects.Post("/upload-multiple", az.Require(authz.BucketFromParam("bucket"), authz.PermObjectWrite), objectHandler.UploadMultipleObjects) // Upload multiple objects
objects.Post("/delete-multiple", az.Require(authz.BucketFromParam("bucket"), authz.PermObjectDelete), objectHandler.DeleteMultipleObjects) // Delete multiple objects
}
// Directory routes (zero-byte directory markers)
api.Post("/buckets/:bucket/directories", az.Require(authz.BucketFromParam("bucket"), authz.PermObjectWrite), 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
}
// Object-specific routes with wildcard key parameter (supports paths with slashes)
// These need to be registered on the main app with auth middleware applied
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)
case strings.HasSuffix(path, "/preview-url"):
c.Locals("objectKey", strings.TrimSuffix(path, "/preview-url"))
return objectHandler.GetPreviewURL(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)
}
// Register with auth middleware
app.Get("/api/v1/buckets/:bucket/objects/*", middleware.AuthMiddleware(&cfg.Auth, authService), objectWildcardHandler)
app.Delete("/api/v1/buckets/:bucket/objects/*", middleware.AuthMiddleware(&cfg.Auth, authService), objectDeleteHandler)
app.Head("/api/v1/buckets/:bucket/objects/*", middleware.AuthMiddleware(&cfg.Auth, authService), objectHeadHandler)
// Register with auth middleware. Although these routes live on app, not
// api, the api group's .Use() middlewares (AuthMiddleware, ResolveSubject)
// cascade onto them by path prefix, so ResolveSubject is not repeated here
// (TestWildcardObjectRoutes_EnforceAuthzViaGroupCascade locks that in).
app.Get("/api/v1/buckets/:bucket/objects/*", middleware.AuthMiddleware(&cfg.Auth, authService), az.Require(authz.BucketFromParam("bucket"), authz.PermObjectRead), objectWildcardHandler)
app.Delete("/api/v1/buckets/:bucket/objects/*", middleware.AuthMiddleware(&cfg.Auth, authService), az.Require(authz.BucketFromParam("bucket"), authz.PermObjectDelete), objectDeleteHandler)
app.Head("/api/v1/buckets/:bucket/objects/*", middleware.AuthMiddleware(&cfg.Auth, authService), az.Require(authz.BucketFromParam("bucket"), authz.PermObjectRead), objectHeadHandler)
// User/Key management routes
users := api.Group("/users")
{
users.Get("/", userHandler.ListUsers) // List all users/keys
users.Post("/", userHandler.CreateUser) // Create new user/key
users.Get("/:access_key", userHandler.GetUser) // Get user info
users.Get("/:access_key/secret", userHandler.GetUserSecretKey) // Get user secret key
users.Delete("/:access_key", userHandler.DeleteUser) // Delete user/key
users.Patch("/:access_key", userHandler.UpdateUserPermissions) // Update user permissions
users.Get("/", az.Require(authz.ScopeNone, authz.PermKeyList), userHandler.ListUsers) // List all users/keys
users.Post("/", az.Require(authz.ScopeNone, authz.PermKeyCreate), userHandler.CreateUser) // Create new user/key
users.Get("/:access_key", az.Require(authz.ScopeNone, authz.PermKeyRead), userHandler.GetUser) // Get user info
users.Get("/:access_key/secret", az.Require(authz.ScopeNone, authz.PermKeyReadSecret), userHandler.GetUserSecretKey) // Get user secret key
users.Delete("/:access_key", az.Require(authz.ScopeNone, authz.PermKeyDelete), userHandler.DeleteUser) // Delete user/key
users.Patch("/:access_key", az.Require(authz.ScopeNone, authz.PermKeyUpdate), userHandler.UpdateUserPermissions) // Update user permissions
}
// Cluster management routes
cluster := api.Group("/cluster")
{
cluster.Get("/health", clusterHandler.GetHealth) // Get cluster health
cluster.Get("/status", clusterHandler.GetStatus) // Get cluster status
cluster.Get("/statistics", clusterHandler.GetStatistics) // Get cluster statistics
cluster.Get("/nodes/:node_id", clusterHandler.GetNodeInfo) // Get node info
cluster.Get("/nodes/:node_id/statistics", clusterHandler.GetNodeStatistics) // Get node statistics
cluster.Get("/health", az.Require(authz.ScopeNone, authz.PermClusterHealth), clusterHandler.GetHealth) // Get cluster health
cluster.Get("/status", az.Require(authz.ScopeNone, authz.PermClusterStatus), clusterHandler.GetStatus) // Get cluster status
cluster.Get("/statistics", az.Require(authz.ScopeNone, authz.PermClusterStatistics), clusterHandler.GetStatistics) // Get cluster statistics
cluster.Get("/nodes/:node_id", az.Require(authz.ScopeNone, authz.PermNodeInfo), clusterHandler.GetNodeInfo) // Get node info
cluster.Get("/nodes/:node_id/statistics", az.Require(authz.ScopeNone, authz.PermNodeStatistics), clusterHandler.GetNodeStatistics) // Get node statistics
}
// Monitoring routes
monitoring := api.Group("/monitoring")
{
monitoring.Get("/metrics", monitoringHandler.GetMetrics) // Get Prometheus metrics
monitoring.Get("/admin-health", monitoringHandler.CheckAdminHealth) // Check Admin API health
monitoring.Get("/dashboard", monitoringHandler.GetDashboardMetrics) // Get dashboard metrics
monitoring.Get("/metrics", az.Require(authz.ScopeNone, authz.PermClusterStatistics), monitoringHandler.GetMetrics) // Get Prometheus metrics
monitoring.Get("/admin-health", az.Require(authz.ScopeNone, authz.PermClusterHealth), monitoringHandler.CheckAdminHealth) // Check Admin API health
monitoring.Get("/dashboard", az.Require(authz.ScopeNone, authz.PermClusterStatistics), monitoringHandler.GetDashboardMetrics) // Get dashboard metrics
}
// Admin auth login endpoint (only if admin is enabled)
@@ -172,8 +174,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)
}
@@ -226,6 +233,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 {
@@ -242,6 +254,48 @@ func SetupRoutes(
})
}
// With access_control configured, non-admin users may log in:
// they get team-scoped (possibly zero) permissions and
// default-deny protects everything else. Without it, the
// admin role remains the only thing standing between an IdP
// account and full cluster access, so keep the historical gate.
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 cfg.AccessControl == nil && !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",
})
}
}
// Teams follow the same claim-location fallbacks as roles.
if cfg.Auth.OIDC.TeamAttributePath != "" && len(userInfo.Teams) == 0 {
if teams := authService.ExtractTeamsFromAccessToken(token.AccessToken); len(teams) > 0 {
userInfo.Teams = teams
}
}
if cfg.Auth.OIDC.TeamAttributePath != "" && len(userInfo.Teams) == 0 {
if ui, err := authService.GetUserInfo(ctx, token); err == nil && len(ui.Teams) > 0 {
userInfo.Teams = ui.Teams
}
}
userInfo.AuthMethod = "oidc"
// Generate JWT session token
sessionToken, err := authService.GenerateSessionToken(userInfo)
if err != nil {
@@ -292,7 +346,8 @@ func SetupRoutes(
if strings.HasPrefix(path, "/api/") ||
strings.HasPrefix(path, "/auth") ||
strings.HasPrefix(path, "/health") ||
strings.HasPrefix(path, "/docs") {
strings.HasPrefix(path, "/docs") ||
path == "/metrics" {
logger.Debug().Str("path", path).Msg("API or health check route, skipping SPA fallback")
return c.Next()
}
@@ -0,0 +1,302 @@
package routes
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"Noooste/garage-ui/internal/auth"
"Noooste/garage-ui/internal/authz"
"Noooste/garage-ui/internal/config"
"Noooste/garage-ui/internal/handlers"
"Noooste/garage-ui/internal/models"
"Noooste/garage-ui/internal/services"
"Noooste/garage-ui/internal/services/mocks"
"github.com/gofiber/fiber/v3"
)
// newTestAppWithAuthz builds a fully-wired fiber.App via SetupRoutes, reusing
// newTestApp's fixture (disabled-policy authz middleware, admin auth enabled
// so every /api/v1 route is reachable), and returns the *fiber.App directly
// for route-table inspection.
func newTestAppWithAuthz(t *testing.T) *fiber.App {
t.Helper()
f := newTestApp(t, func(c *config.Config) {
c.Auth.Admin.Enabled = true
c.Auth.Admin.Username = "admin"
c.Auth.Admin.Password = "pw"
})
return f.App
}
// TestEveryAPIRouteDeclaresPermission is the CI-level fail-closed guarantee:
// a new /api/v1 route without an authz.Require declaration fails this test
// (and would also refuse to boot via the same check in main).
func TestEveryAPIRouteDeclaresPermission(t *testing.T) {
app := newTestAppWithAuthz(t)
if err := authz.VerifyRouteCoverage(app); err != nil {
t.Fatalf("route coverage: %v", err)
}
}
// newEnabledPolicyFixture builds a SetupRoutes app with an ENABLED
// access-control policy (one team, claim "g-t", bucket prefix "allowed-",
// permissions bucket.list + object.list + object.read) and returns the
// fixture plus a Bearer session token for a member of that team. Admin auth
// is enabled so AuthMiddleware accepts the Bearer JWT; the resolver trusts
// the signed AuthMethod claim ("oidc"), so the user resolves through the
// team policy, not as the synthetic admin.
func newEnabledPolicyFixture(t *testing.T) (*routeFixture, string) {
t.Helper()
cfg := &config.Config{
Server: config.ServerConfig{
Port: 8080,
Environment: "test",
},
Auth: config.AuthConfig{
Admin: config.AdminAuthConfig{
Enabled: true,
Username: "admin",
Password: "pw",
},
},
CORS: config.CORSConfig{},
AccessControl: &config.AccessControlConfig{
Teams: []config.TeamConfig{{
Name: "team-t",
ClaimValues: []string{"g-t"},
Bindings: []config.BindingConfig{{
BucketPrefixes: []string{"allowed-"},
Permissions: []string{"bucket.list", "object.list", "object.read"},
}},
}},
},
}
svc, err := auth.NewAuthService(&cfg.Auth, &cfg.Server)
if err != nil {
t.Fatalf("NewAuthService: %v", err)
}
policy, err := authz.CompilePolicy(cfg.AccessControl)
if err != nil {
t.Fatalf("CompilePolicy: %v", err)
}
az := authz.NewMiddleware(policy, authz.NewTeamResolver(policy, nil), authz.NewAuthorizer())
admin := &mocks.AdminMock{}
s3 := &mocks.S3Mock{}
app := fiber.New()
SetupRoutes(
app,
cfg,
svc,
handlers.NewHealthHandler("test"),
handlers.NewBucketHandler(admin, s3),
handlers.NewObjectHandler(s3, svc),
handlers.NewUserHandler(admin),
handlers.NewClusterHandler(admin),
handlers.NewMonitoringHandler(admin, s3),
handlers.NewCapabilitiesHandler("v2", services.CapabilitiesV2(), false),
az,
)
token, err := svc.GenerateSessionToken(&auth.UserInfo{
Username: "team-user",
Email: "team-user@example.com",
Teams: []string{"g-t"},
AuthMethod: "oidc",
})
if err != nil {
t.Fatalf("GenerateSessionToken: %v", err)
}
return &routeFixture{App: app, Admin: admin, S3: s3, Auth: svc, Cfg: cfg}, token
}
// TestWildcardObjectRoutes_EnforceAuthzViaGroupCascade locks in the Fiber
// behavior the wildcard wiring relies on: the api group's .Use() middlewares
// (AuthMiddleware, ResolveSubject) cascade by path prefix onto the wildcard
// object routes registered directly on app, so those routes do NOT repeat
// ResolveSubject themselves. If the cascade ever stopped covering them, the
// allowed-bucket request below would 403 with reason no_subject instead of
// reaching the handler.
func TestWildcardObjectRoutes_EnforceAuthzViaGroupCascade(t *testing.T) {
f, token := newEnabledPolicyFixture(t)
f.S3.GetObjectFn = func(_ context.Context, _, key string) (io.ReadCloser, *models.ObjectInfo, error) {
return io.NopCloser(strings.NewReader("hello")), &models.ObjectInfo{Key: key, Size: 5, ContentType: "text/plain"}, nil
}
do := func(method, path string) int {
t.Helper()
req := httptest.NewRequest(method, path, nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err := f.App.Test(req)
if err != nil {
t.Fatalf("app.Test(%s %s): %v", method, path, err)
}
return resp.StatusCode
}
// In-scope bucket + held permission (object.read) → the request passes
// Require and reaches the handler. This is the discriminating assertion:
// it can only succeed if the group-level ResolveSubject ran for this
// wildcard route (no subject → Require denies everything).
if code := do("GET", "/api/v1/buckets/allowed-data/objects/somekey"); code != 200 {
t.Errorf("GET allowed bucket: status = %d, want 200 (cascaded ResolveSubject + Require allow)", code)
}
// Out-of-scope bucket → default deny.
if code := do("GET", "/api/v1/buckets/denied-data/objects/somekey"); code != 403 {
t.Errorf("GET denied bucket: status = %d, want 403", code)
}
// DELETE requires object.delete, which the team does not hold, so it is denied
// even on an in-scope bucket.
if code := do("DELETE", "/api/v1/buckets/allowed-data/objects/somekey"); code != 403 {
t.Errorf("DELETE allowed bucket without object.delete: status = %d, want 403", code)
}
// HEAD wildcard is denied on an out-of-scope bucket too.
if code := do("HEAD", "/api/v1/buckets/denied-data/objects/somekey"); code != 403 {
t.Errorf("HEAD denied bucket: status = %d, want 403", code)
}
}
// TestListBuckets_HTTPFiltersByPolicyAndAddsEffectivePermissions is the
// HTTP-level companion to handlers.TestListBuckets_MapsAliasesAndStats: it
// drives GET /api/v1/buckets through the full authz-wired route (not just the
// handler in isolation) for a team-scoped session, with the mocked admin
// service returning buckets both inside and outside the team's "allowed-"
// prefix. Only in-scope buckets should come back, each carrying the caller's
// effective_permissions.
func TestListBuckets_HTTPFiltersByPolicyAndAddsEffectivePermissions(t *testing.T) {
f, token := newEnabledPolicyFixture(t)
f.Admin.ListBucketsFn = func(_ context.Context) ([]models.ListBucketsResponseItem, error) {
return []models.ListBucketsResponseItem{
{ID: "id-a", Created: time.Unix(0, 0), GlobalAliases: []string{"allowed-a"}},
{ID: "id-b", Created: time.Unix(0, 0), GlobalAliases: []string{"allowed-b"}},
{ID: "id-x", Created: time.Unix(0, 0), GlobalAliases: []string{"denied-x"}},
}, nil
}
f.Admin.GetBucketInfoByAliasFn = func(_ context.Context, alias string) (*models.GarageBucketInfo, error) {
return &models.GarageBucketInfo{ID: alias, Objects: 0, Bytes: 0}, nil
}
req := httptest.NewRequest("GET", "/api/v1/buckets", nil)
req.Header.Set("Authorization", "Bearer "+token)
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 struct {
Data models.BucketListResponse `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
t.Fatalf("decode: %v", err)
}
if body.Data.Count != 2 {
t.Fatalf("count = %d, want 2 (denied-x filtered out): %+v", body.Data.Count, body.Data.Buckets)
}
seen := map[string]bool{}
for _, b := range body.Data.Buckets {
seen[b.Name] = true
if !strings.HasPrefix(b.Name, "allowed-") {
t.Errorf("bucket %q returned, want only allowed-* buckets", b.Name)
}
if len(b.EffectivePermissions) == 0 {
t.Errorf("bucket %q: effective_permissions missing", b.Name)
}
}
if !seen["allowed-a"] || !seen["allowed-b"] {
t.Errorf("missing expected buckets, got: %+v", body.Data.Buckets)
}
if seen["denied-x"] {
t.Error("denied-x should not be visible to a team without bucket.list on that prefix")
}
}
// TestPreviewTokenGrantsObjectGET exercises the full production chain: group
// cascade AuthMiddleware accepts the token, ResolveSubject finds no user,
// and Require allows via the preview claims instead of a subject.
func TestPreviewTokenGrantsObjectGET(t *testing.T) {
f, _ := newEnabledPolicyFixture(t)
// The full-object body echoes the key the handler was actually asked to
// serve (the decoded c.Params("*")). Asserting the streamed body equals
// the exact key the token was minted for makes any future divergence
// between the validated key and the served key fail loudly here rather
// than hide behind a constant body.
const mintedKey = "media/clip.mp4"
f.S3.GetObjectFn = func(_ context.Context, _, key string) (io.ReadCloser, *models.ObjectInfo, error) {
return io.NopCloser(strings.NewReader(key)), &models.ObjectInfo{Key: key, Size: int64(len(key)), ContentType: "video/mp4", LastModified: time.Now()}, nil
}
f.S3.GetObjectMetadataFn = func(_ context.Context, _, key string) (*models.ObjectInfo, error) {
return &models.ObjectInfo{Key: key, Size: 5, ContentType: "video/mp4", LastModified: time.Now()}, nil
}
f.S3.GetObjectRangeFn = func(_ context.Context, _, _ string, start, end int64) (io.ReadCloser, error) {
return io.NopCloser(strings.NewReader("ell")), nil
}
token, _, err := f.Auth.MintPreviewToken("allowed-data", mintedKey, time.Minute)
if err != nil {
t.Fatalf("MintPreviewToken: %v", err)
}
tokenized := "/api/v1/buckets/allowed-data/objects/media%2Fclip.mp4?pt=" + url.QueryEscape(token)
// No Authorization header anywhere in this test.
do := func(method, path, rangeHeader string) *http.Response {
t.Helper()
req := httptest.NewRequest(method, path, nil)
if rangeHeader != "" {
req.Header.Set("Range", rangeHeader)
}
resp, err := f.App.Test(req)
if err != nil {
t.Fatalf("app.Test(%s %s): %v", method, path, err)
}
return resp
}
resp := do("GET", tokenized, "")
if resp.StatusCode != 200 {
t.Fatalf("tokenized GET: status = %d, want 200", resp.StatusCode)
}
body, _ := io.ReadAll(resp.Body)
if string(body) != mintedKey {
t.Errorf("served key = %q, want %q (validated key must equal served key)", body, mintedKey)
}
// Seeking works through the same token.
resp = do("GET", tokenized, "bytes=1-3")
if resp.StatusCode != 206 {
t.Errorf("tokenized ranged GET: status = %d, want 206", resp.StatusCode)
}
// The token never opens the JSON subroutes or other objects.
if resp := do("GET", "/api/v1/buckets/allowed-data/objects/media%2Fclip.mp4%2Fmetadata?pt="+url.QueryEscape(token), ""); resp.StatusCode != 401 {
t.Errorf("metadata with token: status = %d, want 401", resp.StatusCode)
}
if resp := do("GET", "/api/v1/buckets/allowed-data/objects/other.mp4?pt="+url.QueryEscape(token), ""); resp.StatusCode != 401 {
t.Errorf("other object with token: status = %d, want 401", resp.StatusCode)
}
if resp := do("GET", "/api/v1/buckets/allowed-data/objects/media%2Fclip.mp4", ""); resp.StatusCode != 401 {
t.Errorf("no token, no auth: status = %d, want 401", resp.StatusCode)
}
}
@@ -0,0 +1,93 @@
package routes
import (
"context"
"io"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"Noooste/garage-ui/internal/authz"
"Noooste/garage-ui/internal/config"
)
// Flag off (default): /metrics is not registered, and the authenticated
// /api/v1/monitoring/metrics route still rejects unauthenticated requests.
func TestRoutes_MetricsPublic_Disabled_NotRegistered(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"
// MetricsPublic defaults to false.
})
expectStatus(t, f.App, httptest.NewRequest("GET", "/metrics", nil), 404)
expectStatus(t, f.App, httptest.NewRequest("GET", "/api/v1/monitoring/metrics", nil), 401)
}
// Flag on, with admin auth enabled: /metrics serves without credentials, while
// the authenticated /api/v1/monitoring/metrics route still requires auth.
func TestRoutes_MetricsPublic_Enabled_ServesWithoutAuth(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.Auth.MetricsPublic = true
})
f.Admin.GetMetricsFn = func(_ context.Context) (string, error) {
return "garage_metric 1", nil
}
resp := expectStatus(t, f.App, httptest.NewRequest("GET", "/metrics", nil), 200)
body, _ := io.ReadAll(resp.Body)
if !strings.Contains(string(body), "garage_metric") {
t.Errorf("GET /metrics body = %q, want it to contain the metrics text", string(body))
}
// The /api/v1 route stays gated; the fail-closed guarantee is intact.
expectStatus(t, f.App, httptest.NewRequest("GET", "/api/v1/monitoring/metrics", nil), 401)
}
// Route coverage must still pass with the flag on: /metrics is outside /api/v1,
// so VerifyRouteCoverage neither requires a Require handler for it nor errors.
func TestRoutes_MetricsPublic_Enabled_RouteCoverageStillPasses(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.Auth.MetricsPublic = true
})
if err := authz.VerifyRouteCoverage(f.App); err != nil {
t.Errorf("VerifyRouteCoverage returned error with metrics_public on: %v", err)
}
}
// With the metrics flag OFF and the SPA frontend present, GET /metrics must
// return 404 (not the SPA index.html), so a misconfigured Prometheus scrape
// fails loudly instead of silently receiving HTML with a 200 status.
func TestRoutes_MetricsPublic_Disabled_WithSPA_Returns404(t *testing.T) {
dir := t.TempDir()
t.Chdir(dir)
// Create ./frontend/dist/index.html so the SPA fallback mounts.
if err := os.MkdirAll(filepath.Join(dir, "frontend", "dist"), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(filepath.Join(dir, "frontend", "dist", "index.html"),
[]byte("<!doctype html><title>spa</title>"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
f := newTestApp(t, func(c *config.Config) {
c.Auth.Admin.Enabled = true
c.Auth.Admin.Username = "admin"
c.Auth.Admin.Password = "pw"
// MetricsPublic defaults to false → no /metrics route registered.
})
// SPA fallback is mounted; /metrics must be excluded from it → 404, not
// index.html with 200.
expectStatus(t, f.App, httptest.NewRequest("GET", "/metrics", nil), 404)
}
+278
View File
@@ -0,0 +1,278 @@
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_GET_PreviewURLSuffixRoutesToPreviewURL(t *testing.T) {
f := newNoAuthFixture(t)
req := plainReq(http.MethodGet, "/api/v1/buckets/b1/objects/sub/clip.mp4/preview-url", 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)
}
var body struct {
Data models.PreviewURLResponse `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
t.Fatalf("decode: %v", err)
}
// The dispatch trims the /preview-url suffix, so the key becomes sub/clip.mp4,
// percent-encoded whole (slash to %2F) in the returned URL, with a pt token.
if !strings.Contains(body.Data.URL, "/api/v1/buckets/b1/objects/sub%2Fclip.mp4?pt=") {
t.Errorf("url = %q, want the whole-encoded key with a pt token", body.Data.URL)
}
}
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)
}
@@ -0,0 +1,103 @@
package routes
import (
"net/http"
"net/http/httptest"
"testing"
"Noooste/garage-ui/internal/config"
)
// newOIDCTeamFixture builds an OIDC-enabled fixture with team_attribute_path
// set and no admin-role gate, so a non-admin user can complete the callback
// and have their teams resolved.
func newOIDCTeamFixture(t *testing.T, teamPath 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: "", // no role gate: non-admin OIDC users may log in
UsernameAttribute: "preferred_username",
EmailAttribute: "email",
NameAttribute: "name",
RoleAttributePath: "resource_access.test-client.roles",
TeamAttributePath: teamPath,
CookieName: "session",
CookieHTTPOnly: true,
CookieSameSite: "Lax",
SessionMaxAge: 3600,
}
})
return f, iss
}
// runCallback drives the OIDC callback happy path and returns the resolved
// session's user info (decoded from the session cookie).
func runCallbackTeams(t *testing.T, f *routeFixture) []string {
t.Helper()
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("callback: %v", err)
}
if resp.StatusCode != 303 {
t.Fatalf("callback status = %d, want 303", resp.StatusCode)
}
var sess *http.Cookie
for _, c := range resp.Cookies() {
if c.Name == "session" && c.Value != "" {
sess = c
}
}
if sess == nil {
t.Fatal("no session cookie in callback response")
}
info, err := f.Auth.ValidateSessionToken(sess.Value)
if err != nil {
t.Fatalf("ValidateSessionToken: %v", err)
}
return info.Teams
}
func TestRoutes_OIDCCallback_TeamsFromIDToken(t *testing.T) {
f, iss := newOIDCTeamFixture(t, "groups")
// The verified ID token carries the team claim directly.
iss.DefaultIDClaims["groups"] = []any{"garage-team-backend"}
teams := runCallbackTeams(t, f)
if len(teams) != 1 || teams[0] != "garage-team-backend" {
t.Errorf("Teams = %v, want [garage-team-backend] from the ID token", teams)
}
}
func TestRoutes_OIDCCallback_TeamsFromAccessTokenFallback(t *testing.T) {
f, iss := newOIDCTeamFixture(t, "groups")
// ID token carries no team claim; the access token does.
iss.DefaultAccessClaims["groups"] = []any{"garage-team-data"}
teams := runCallbackTeams(t, f)
if len(teams) != 1 || teams[0] != "garage-team-data" {
t.Errorf("Teams = %v, want [garage-team-data] from the access-token fallback", teams)
}
}
func TestRoutes_OIDCCallback_TeamsFromUserInfoFallback(t *testing.T) {
f, iss := newOIDCTeamFixture(t, "groups")
// Neither token carries the team claim; only the userinfo endpoint does.
iss.UserInfoFn = func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"sub":"user-1","preferred_username":"alice","email":"alice@example.com","groups":["garage-team-ops"]}`))
}
teams := runCallbackTeams(t, f)
if len(teams) != 1 || teams[0] != "garage-team-ops" {
t.Errorf("Teams = %v, want [garage-team-ops] from the userinfo fallback", teams)
}
}
+615
View File
@@ -0,0 +1,615 @@
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/authz"
"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{}
policy, err := authz.CompilePolicy(nil)
if err != nil {
t.Fatalf("CompilePolicy: %v", err)
}
az := authz.NewMiddleware(policy, authz.NewTeamResolver(policy, nil), authz.NewAuthorizer())
app := fiber.New()
SetupRoutes(
app,
cfg,
svc,
handlers.NewHealthHandler("test"),
handlers.NewBucketHandler(admin, s3),
handlers.NewObjectHandler(s3, svc),
handlers.NewUserHandler(admin),
handlers.NewClusterHandler(admin),
handlers.NewMonitoringHandler(admin, s3),
handlers.NewCapabilitiesHandler("v2", services.CapabilitiesV2(), false),
az,
)
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"},
{"GET", "/api/v1/buckets/b1/objects/folder/file.txt/preview-url"},
{"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)
}
}
+132
View File
@@ -0,0 +1,132 @@
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
}
// errProbeNotFound means the probed route returned 404. Garage v2.x also serves
// /v1/health, so a 404 on /v2 is the only reliable "this is a v1 server" signal.
var errProbeNotFound = errors.New("probe endpoint not found")
func NewAdminService(cfg *config.GarageConfig, logLevel string) (*AdminServiceResult, error) {
// retry so a startup fails doesn't lock us to a v1 client.
err := probeEndpointWithRetry(cfg, "/v2/GetClusterHealth")
if 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
}
// only fall back to v1 on a real 404
// other errors mean the server is up but the probe failed transiently; picking v1 against
// a v2.x server breaks /v1/status with "v1/ endpoint is no longer supported" (issue #78).
if !errors.Is(err, errProbeNotFound) {
return nil, fmt.Errorf(
"could not detect Garage admin API version at %s: %w. Ensure Garage v1.1+ is running and the admin API is reachable",
cfg.AdminEndpoint, err,
)
}
if err := probeEndpointWithRetry(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,
)
}
const probeAttempts = 4
// probeEndpointWithRetry retries transient failures with backoff, but returns a
// 404 immediately, a missing route won't appear on a retry.
func probeEndpointWithRetry(cfg *config.GarageConfig, path string) error {
var err error
backoff := 250 * time.Millisecond
for attempt := range probeAttempts {
err = probeEndpoint(cfg, path)
if err == nil || errors.Is(err, errProbeNotFound) {
return err
}
if attempt < probeAttempts-1 {
time.Sleep(backoff)
backoff *= 2
}
}
return err
}
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 == http.StatusNotFound {
return fmt.Errorf("probe %s returned status 404: %w", path, errProbeNotFound)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("probe %s returned status %d", path, resp.StatusCode)
}
return nil
}
@@ -0,0 +1,149 @@
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")
}
}
// Garage v2.x serves /v1/health too, so a transient failure of the /v2 probe
// must not cause a permanent downgrade to the (broken on v2.x) v1 client.
// Regression test for https://github.com/Noooste/garage-ui/issues/78
func TestDetectVersion_V2_TransientProbeFailure(t *testing.T) {
var v2Hits int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/v2/GetClusterHealth":
v2Hits++
if v2Hits < 3 { // fail the first two attempts, then recover
w.WriteHeader(http.StatusServiceUnavailable)
return
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"status":"healthy"}`))
case "/v1/health": // v2.x still answers this
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"status":"healthy"}`))
default:
w.WriteHeader(http.StatusNotFound)
}
}))
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 after transient probe failure, got %s", result.APIVersion)
}
}
// A server that answers /v1/health but returns a server error (not 404) for
// /v2/GetClusterHealth must NOT be detected as v1, because v2.x servers also
// answer /v1/health. Falling through to v1 here is the issue #78 misdetection.
func TestDetectVersion_DoesNotDowngradeOnV2ServerError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/v2/GetClusterHealth":
w.WriteHeader(http.StatusServiceUnavailable)
case "/v1/health":
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"status":"healthy"}`))
default:
w.WriteHeader(http.StatusNotFound)
}
}))
t.Cleanup(srv.Close)
cfg := &config.GarageConfig{AdminEndpoint: srv.URL, AdminToken: "tok"}
result, err := NewAdminService(cfg, "")
if err == nil && result.APIVersion == "v1" {
t.Fatal("must not downgrade to v1 when /v2 returns a server error; v2.x also serves /v1/health")
}
}
+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,31 +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, logLevel string) *GarageAdminService {
// NewGarageV2AdminService creates a new Garage Admin API service
func NewGarageV2AdminService(cfg *config.GarageConfig, logLevel string) *GarageV2AdminService {
session := azuretls.NewSession()
if logLevel == "debug" {
session.Log()
}
return &GarageAdminService{
return &GarageV2AdminService{
baseURL: cfg.AdminEndpoint,
token: cfg.AdminToken,
httpClient: session,
@@ -36,7 +38,7 @@ func NewGarageAdminService(cfg *config.GarageConfig, logLevel string) *GarageAdm
}
// 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()
@@ -80,7 +82,7 @@ func decodeResponse(resp *azuretls.Response, target interface{}) error {
}
// 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)
@@ -95,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)
@@ -110,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"
@@ -130,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)
@@ -147,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)
@@ -163,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)
@@ -177,105 +179,182 @@ func (s *GarageAdminService) ImportKey(ctx context.Context, req models.ImportKey
return &result, nil
}
// ListBuckets returns all buckets in the cluster
func (s *GarageAdminService) ListBuckets(ctx context.Context) ([]models.ListBucketsResponseItem, error) {
// 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()
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
}
// 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)
@@ -290,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)
@@ -304,23 +383,39 @@ func (s *GarageAdminService) RemoveBucketAlias(ctx context.Context, req models.R
return &result, nil
}
// AllowBucketKey grants permissions for a key on a bucket
func (s *GarageAdminService) AllowBucketKey(ctx context.Context, req models.BucketKeyPermRequest) (*models.GarageBucketInfo, error) {
// 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()
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)
@@ -335,7 +430,7 @@ func (s *GarageAdminService) DenyBucketKey(ctx context.Context, req models.Bucke
}
// 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)
@@ -350,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)
@@ -365,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)
@@ -380,7 +475,7 @@ func (s *GarageAdminService) GetClusterStatistics(ctx context.Context) (*models.
}
// 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)
@@ -397,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)
@@ -414,7 +509,7 @@ func (s *GarageAdminService) GetNodeStatistics(ctx context.Context, nodeID strin
}
// 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)
@@ -427,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)
}
}
+74
View File
@@ -0,0 +1,74 @@
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)
SearchObjects(ctx context.Context, bucketName, prefix, search 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)
GetObjectRange(ctx context.Context, bucketName, key string, start, end int64) (io.ReadCloser, 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) (int, error)
DeleteObjectsByPrefix(ctx context.Context, bucketName, prefix string) (int, 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, keys []string) (int, error) { return len(keys), 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 n, err := m.DeleteMultipleObjects(ctx, "b", []string{"k"}); err != nil || n != 1 {
t.Errorf("DeleteMultipleObjects = (%d, %v)", n, 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)
}
}
+170
View File
@@ -0,0 +1,170 @@
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)
SearchObjectsFn func(ctx context.Context, bucketName, prefix, search 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)
GetObjectRangeFn func(ctx context.Context, bucketName, key string, start, end int64) (io.ReadCloser, 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) (int, error)
DeleteObjectsByPrefixFn func(ctx context.Context, bucketName, prefix string) (int, 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) SearchObjects(ctx context.Context, bucketName, prefix, search string) (*models.ObjectListResponse, error) {
m.record("SearchObjects", bucketName, prefix, search)
if m.SearchObjectsFn == nil {
return nil, s3NotConfigured("SearchObjects")
}
return m.SearchObjectsFn(ctx, bucketName, prefix, search)
}
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) GetObjectRange(ctx context.Context, bucketName, key string, start, end int64) (io.ReadCloser, error) {
m.record("GetObjectRange", bucketName, key)
if m.GetObjectRangeFn == nil {
return nil, s3NotConfigured("GetObjectRange")
}
return m.GetObjectRangeFn(ctx, bucketName, key, start, end)
}
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) (int, error) {
m.record("DeleteMultipleObjects", bucketName, keys)
if m.DeleteMultipleObjectsFn == nil {
return 0, s3NotConfigured("DeleteMultipleObjects")
}
return m.DeleteMultipleObjectsFn(ctx, bucketName, keys)
}
func (m *S3Mock) DeleteObjectsByPrefix(ctx context.Context, bucketName, prefix string) (int, error) {
m.record("DeleteObjectsByPrefix", bucketName, prefix)
if m.DeleteObjectsByPrefixFn == nil {
return 0, s3NotConfigured("DeleteObjectsByPrefix")
}
return m.DeleteObjectsByPrefixFn(ctx, bucketName, prefix)
}
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)
}
+309 -61
View File
@@ -1,6 +1,7 @@
package services
import (
"bytes"
"context"
"fmt"
"io"
@@ -10,6 +11,7 @@ import (
"Noooste/garage-ui/internal/config"
"Noooste/garage-ui/internal/models"
logpkg "Noooste/garage-ui/pkg/logger"
"Noooste/garage-ui/pkg/utils"
"github.com/minio/minio-go/v7"
@@ -20,11 +22,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 +39,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 +53,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 +173,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 +194,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 +214,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 +241,21 @@ func (s *S3Service) ListObjects(ctx context.Context, bucketName, prefix string,
return nil, fmt.Errorf("failed to list objects in bucket %s: %w", bucketName, err)
}
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 {
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 +266,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 +289,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 +299,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{
@@ -280,10 +325,101 @@ func (s *S3Service) ListObjects(ctx context.Context, bucketName, prefix string,
}, nil
}
const (
searchMaxScan = 10000 // stop after scanning this many objects
searchMaxResults = 1000 // stop after collecting this many matches
searchPageSize = 1000 // objects requested per ListObjectsV2 page
)
func objectMatchesSearch(key string, size int64, lowerQuery string) bool {
if strings.HasSuffix(key, "/") && size == 0 {
return false
}
return strings.Contains(strings.ToLower(key), lowerQuery)
}
// SearchObjects performs a recursive, best-effort substring search over object
// keys under the given prefix.
func (s *S3Service) SearchObjects(ctx context.Context, bucketName, prefix, search string) (*models.ObjectListResponse, error) {
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)
}
core := &minio.Core{Client: client}
lowerQuery := strings.ToLower(search)
matches := make([]models.ObjectInfo, 0, 64)
scanned := 0
truncated := false
token := ""
scan:
for {
result, err := core.ListObjectsV2(
bucketName,
prefix,
"",
token,
"",
searchPageSize,
)
if err != nil {
return nil, fmt.Errorf("failed to search objects in bucket %s: %w", bucketName, err)
}
for _, obj := range result.Contents {
scanned++
if objectMatchesSearch(obj.Key, obj.Size, lowerQuery) {
matches = append(matches, models.ObjectInfo{
Key: obj.Key,
Size: obj.Size,
LastModified: obj.LastModified,
ETag: obj.ETag,
StorageClass: obj.StorageClass,
})
if len(matches) >= searchMaxResults {
truncated = true
break scan
}
}
if scanned >= searchMaxScan {
truncated = true
break scan
}
}
if !result.IsTruncated || result.NextContinuationToken == "" {
break
}
token = result.NextContinuationToken
}
if truncated {
logpkg.FromCtx(ctx).Warn().
Str("bucket", bucketName).
Str("prefix", prefix).
Int("scanned", scanned).
Int("matches", len(matches)).
Msg("search hit scan/result cap; results are partial")
}
return &models.ObjectListResponse{
Bucket: bucketName,
Objects: matches,
Prefixes: []string{},
Count: len(matches),
IsTruncated: truncated,
// Search returns all matches up to the cap in one response; there is no
// token-based pagination for search results.
NextContinuationToken: "",
}, nil
}
// 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,10 +451,43 @@ 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)
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)
}
@@ -355,10 +524,38 @@ func (s *S3Service) GetObject(ctx context.Context, bucketName, key string) (io.R
return object, objectInfo, nil
}
// GetObjectRange retrieves an inclusive byte range of an object. The caller
// resolves the range against the object size beforehand, so this method does
// not stat the object again.
func (s *S3Service) GetObjectRange(ctx context.Context, bucketName, key string, start, end int64) (io.ReadCloser, error) {
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)
}
opts := minio.GetObjectOptions{}
if err := opts.SetRange(start, end); err != nil {
return nil, fmt.Errorf("invalid range %d-%d for object %s: %w", start, end, key, err)
}
var object *minio.Object
retryConfig := utils.DefaultRetryConfig()
err = utils.RetryWithBackoff(ctx, retryConfig, func() error {
var getErr error
object, getErr = client.GetObject(ctx, bucketName, key, opts)
return getErr
})
if err != nil {
return nil, fmt.Errorf("failed to get object %s from bucket %s: %w", key, bucketName, err)
}
return object, nil
}
// 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)
client, err := s.getMinioClient(ctx, bucketName, OpWrite)
if err != nil {
return fmt.Errorf("failed to get MinIO client for bucket %s: %w", bucketName, err)
}
@@ -378,7 +575,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)
}
@@ -406,7 +603,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)
}
@@ -435,16 +632,23 @@ func (s *S3Service) GetObjectMetadata(ctx context.Context, bucketName, key strin
}, nil
}
// DeleteMultipleObjects deletes multiple objects from a bucket
func (s *S3Service) DeleteMultipleObjects(ctx context.Context, bucketName string, keys []string) error {
// DeleteMultipleObjects deletes multiple objects from a bucket and returns the
// number of objects that were removed (requested keys minus any that failed).
//
// Note: S3/MinIO batch delete is idempotent — removing a key that does not
// exist succeeds and is not reported on the error channel, so it counts toward
// the returned total. The count therefore reflects "keys the delete operation
// did not fail on", which is the strongest signal obtainable without a
// per-key existence check.
func (s *S3Service) DeleteMultipleObjects(ctx context.Context, bucketName string, keys []string) (int, error) {
if len(keys) == 0 {
return nil
return 0, nil
}
// 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)
return 0, fmt.Errorf("failed to get MinIO client for bucket %s: %w", bucketName, err)
}
// Create channel for objects to delete
@@ -460,24 +664,68 @@ func (s *S3Service) DeleteMultipleObjects(ctx context.Context, bucketName string
}
}()
// Call MinIO RemoveObjects API (batch delete)
// Call MinIO RemoveObjects API (batch delete). RemoveObjects only surfaces
// the objects it FAILED to delete, so we drain the whole channel (which also
// avoids leaking the sender goroutine) and count failures.
errorCh := client.RemoveObjects(ctx, bucketName, objectsCh, minio.RemoveObjectsOptions{})
// Check for errors
for err := range errorCh {
if err.Err != nil {
return fmt.Errorf("failed to delete object %s from bucket %s: %w", err.ObjectName, bucketName, err.Err)
failed := 0
var firstErr error
for rerr := range errorCh {
if rerr.Err != nil {
failed++
if firstErr == nil {
firstErr = fmt.Errorf("failed to delete object %s from bucket %s: %w", rerr.ObjectName, bucketName, rerr.Err)
}
}
}
return nil
if firstErr != nil {
return len(keys) - failed, firstErr
}
return len(keys), nil
}
// DeleteObjectsByPrefix recursively deletes every object stored under the given
// prefix (i.e. a "folder"), including the directory marker itself. It returns
// the number of objects that were deleted.
func (s *S3Service) DeleteObjectsByPrefix(ctx context.Context, bucketName, prefix string) (int, error) {
if prefix == "" {
return 0, fmt.Errorf("prefix is required for recursive delete")
}
// Get bucket-specific MinIO client
client, err := s.getMinioClient(ctx, bucketName, OpWrite)
if err != nil {
return 0, fmt.Errorf("failed to get MinIO client for bucket %s: %w", bucketName, err)
}
// List every object under the prefix recursively (no delimiter), so nested
// folders are flattened into their concrete keys.
keys := make([]string, 0)
for obj := range client.ListObjects(ctx, bucketName, minio.ListObjectsOptions{
Prefix: prefix,
Recursive: true,
}) {
if obj.Err != nil {
return 0, fmt.Errorf("failed to list objects under prefix %s in bucket %s: %w", prefix, bucketName, obj.Err)
}
keys = append(keys, obj.Key)
}
if len(keys) == 0 {
return 0, nil
}
return s.DeleteMultipleObjects(ctx, bucketName, keys)
}
// GetPresignedURL generates a pre-signed URL for temporary access to an object
// 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)
}
@@ -516,7 +764,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 {
+737
View File
@@ -0,0 +1,737 @@
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 n, err := s3.DeleteMultipleObjects(context.Background(), "whatever", nil); err != nil || n != 0 {
t.Fatalf("empty keys should return (0, nil), got (%d, %v)", n, 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")
}
}
// s3PrefixDeleteHandler serves a ListObjectsV2 response (GET) from listBody and
// a successful multi-object DeleteResult (POST /{bucket}?delete), recording how
// many batch-delete requests were made.
func s3PrefixDeleteHandler(listBody string, deletePosts *int) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost {
*deletePosts++
w.Header().Set("Content-Type", "application/xml")
w.WriteHeader(http.StatusOK)
_, _ = io.WriteString(w, `<?xml version="1.0" encoding="UTF-8"?><DeleteResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/"></DeleteResult>`)
return
}
// Any GET is treated as a ListObjectsV2 request.
w.Header().Set("Content-Type", "application/xml")
w.WriteHeader(http.StatusOK)
_, _ = io.WriteString(w, listBody)
})
}
func TestS3_DeleteObjectsByPrefix_EmptyPrefixIsError(t *testing.T) {
// A blank prefix must be rejected before any network call — it would
// otherwise match (and delete) every object in the bucket.
called := false
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
s3ErrorXML(w, http.StatusInternalServerError, "ShouldNotHappen", "")
})
s3 := newS3TestService(t, h)
n, err := s3.DeleteObjectsByPrefix(context.Background(), "b-TestS3_DeleteObjectsByPrefix_EmptyPrefixIsError", "")
if err == nil {
t.Fatal("empty prefix should return an error")
}
if n != 0 {
t.Errorf("count = %d, want 0", n)
}
if called {
t.Error("no S3 request should be made for an empty prefix")
}
}
func TestS3_DeleteObjectsByPrefix_ListsThenDeletes(t *testing.T) {
contents := []struct {
Key string
Size int64
LastModified string
ETag string
}{
{Key: "docs/a"}, {Key: "docs/b"}, {Key: "docs/sub/c"},
}
listBody := listBucketResultXML("b", false, "", contents, nil)
deletePosts := 0
s3 := newS3TestService(t, s3PrefixDeleteHandler(listBody, &deletePosts))
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
n, err := s3.DeleteObjectsByPrefix(ctx, "b-TestS3_DeleteObjectsByPrefix_ListsThenDeletes", "docs/")
if err != nil {
t.Fatalf("DeleteObjectsByPrefix: %v", err)
}
if n != 3 {
t.Errorf("deleted = %d, want 3 (all objects listed under the prefix)", n)
}
if deletePosts == 0 {
t.Error("expected a batch-delete request to be made")
}
}
func TestS3_DeleteObjectsByPrefix_NoObjectsReturnsZero(t *testing.T) {
listBody := listBucketResultXML("b", false, "", nil, nil)
deletePosts := 0
s3 := newS3TestService(t, s3PrefixDeleteHandler(listBody, &deletePosts))
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
n, err := s3.DeleteObjectsByPrefix(ctx, "b-TestS3_DeleteObjectsByPrefix_NoObjectsReturnsZero", "empty/")
if err != nil {
t.Fatalf("DeleteObjectsByPrefix: %v", err)
}
if n != 0 {
t.Errorf("deleted = %d, want 0", n)
}
if deletePosts != 0 {
t.Errorf("no batch-delete should be made when nothing matches, got %d", deletePosts)
}
}
func TestS3_DeleteObjectsByPrefix_ListErrorPropagates(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.DeleteObjectsByPrefix(ctx, "b-TestS3_DeleteObjectsByPrefix_ListErrorPropagates", "docs/")
if err == nil {
t.Fatal("expected the list error to propagate, 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)
}
}
}
func TestGetObjectRange_SendsRangeHeaderAndStreamsBody(t *testing.T) {
bucket := uniqueBucket2(t)
var gotRange string
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotRange = r.Header.Get("Range")
w.Header().Set("Content-Range", "bytes 2-6/10")
w.Header().Set("Content-Length", "5")
// The MinIO client parses Last-Modified from the response headers on
// the first Read, so the fake server must set a valid one.
w.Header().Set("Last-Modified", time.Now().UTC().Format(http.TimeFormat))
w.WriteHeader(http.StatusPartialContent)
_, _ = w.Write([]byte("23456"))
})
s3 := newS3TestService(t, handler)
body, err := s3.GetObjectRange(context.Background(), bucket, "file.bin", 2, 6)
if err != nil {
t.Fatalf("GetObjectRange: %v", err)
}
defer body.Close()
data, err := io.ReadAll(body)
if err != nil {
t.Fatalf("read body: %v", err)
}
if string(data) != "23456" {
t.Errorf("body = %q, want %q", data, "23456")
}
if gotRange != "bytes=2-6" {
t.Errorf("Range header = %q, want %q", gotRange, "bytes=2-6")
}
}
func TestGetObjectRange_InvalidRangeRejectedLocally(t *testing.T) {
bucket := uniqueBucket2(t)
var called bool
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
})
s3 := newS3TestService(t, handler)
// end before start is a caller bug; SetRange rejects it before any request.
if _, err := s3.GetObjectRange(context.Background(), bucket, "file.bin", 6, 2); err == nil {
t.Fatal("expected error for inverted range")
}
if called {
t.Error("no S3 request should be sent for an invalid range")
}
}
func TestGetObjectRange_ClientAcquisitionFailurePropagates(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)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
if _, err := s3.GetObjectRange(ctx, bucket, "missing", 0, 4); err == nil {
t.Fatal("expected error, got nil")
}
}
+121
View File
@@ -0,0 +1,121 @@
package services
import (
"context"
"io"
"net/http"
"strings"
"testing"
"time"
)
// s3SearchHandler serves a two-page recursive ListObjectsV2 response and counts
// HEAD (StatObject) requests. Page selection is driven by the
// `continuation-token` query parameter, mirroring how the MinIO SDK paginates.
func s3SearchHandler(bucket string, page1, page2 []struct {
Key string
Size int64
LastModified string
ETag string
}) (http.Handler, *int, *string) {
var heads int
var lastDelimiter string
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodHead {
heads++
w.Header().Set("Content-Length", "0")
w.WriteHeader(http.StatusOK)
return
}
lastDelimiter = r.URL.Query().Get("delimiter")
w.Header().Set("Content-Type", "application/xml")
w.WriteHeader(http.StatusOK)
if r.URL.Query().Get("continuation-token") == "PAGE2" {
_, _ = io.WriteString(w, listBucketResultXML(bucket, false, "", page2, nil))
return
}
_, _ = io.WriteString(w, listBucketResultXML(bucket, true, "PAGE2", page1, nil))
})
return h, &heads, &lastDelimiter
}
// TestS3_SearchObjects_FindsMatchOnLaterPage reproduces issue #87: an object
// that lives on the second page of a listing must still be found by search.
// SearchObjects should page through the whole listing recursively.
func TestS3_SearchObjects_FindsMatchOnLaterPage(t *testing.T) {
bucket := "b-TestS3_SearchObjects_FindsMatchOnLaterPage"
page1 := []struct {
Key string
Size int64
LastModified string
ETag string
}{
{Key: "docs/alpha.txt", Size: 10},
{Key: "docs/beta.txt", Size: 10},
}
page2 := []struct {
Key string
Size int64
LastModified string
ETag string
}{
{Key: "docs/target-report.pdf", Size: 20},
{Key: "docs/gamma.txt", Size: 10},
}
h, heads, lastDelimiter := s3SearchHandler(bucket, page1, page2)
s3 := newS3TestService(t, h)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
got, err := s3.SearchObjects(ctx, bucket, "", "target")
if err != nil {
t.Fatalf("SearchObjects: %v", err)
}
if got.Count != 1 || len(got.Objects) != 1 || got.Objects[0].Key != "docs/target-report.pdf" {
t.Fatalf("expected to find docs/target-report.pdf on page 2, got %+v", got.Objects)
}
// Search must be recursive (no delimiter) so it descends into folders.
if *lastDelimiter != "" {
t.Errorf("delimiter = %q, want empty (recursive listing)", *lastDelimiter)
}
// Search must not fetch ContentType per object — that would be N stat calls.
if *heads != 0 {
t.Errorf("StatObject (HEAD) calls = %d, want 0 during search", *heads)
}
}
// TestS3_SearchObjects_ExcludesDirectoryMarkersAndIsCaseInsensitive verifies
// that zero-byte directory markers never appear as matches and that matching
// is a case-insensitive substring test on the full key.
func TestS3_SearchObjects_ExcludesDirectoryMarkersAndIsCaseInsensitive(t *testing.T) {
bucket := "b-TestS3_SearchObjects_ExcludesDirectoryMarkers"
page1 := []struct {
Key string
Size int64
LastModified string
ETag string
}{
{Key: "Reports/", Size: 0}, // directory marker — must be excluded
{Key: "Reports/Q1-REPORT.csv", Size: 5}, // matches "report" case-insensitively
{Key: "images/logo.png", Size: 5}, // no match
}
h, _, _ := s3SearchHandler(bucket, page1, nil)
s3 := newS3TestService(t, h)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
got, err := s3.SearchObjects(ctx, bucket, "", "report")
if err != nil {
t.Fatalf("SearchObjects: %v", err)
}
if got.Count != 1 || got.Objects[0].Key != "Reports/Q1-REPORT.csv" {
t.Fatalf("expected only Reports/Q1-REPORT.csv, got %+v", got.Objects)
}
for _, o := range got.Objects {
if strings.HasSuffix(o.Key, "/") {
t.Errorf("directory marker leaked into results: %q", o.Key)
}
}
}
+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)
}
}
+90 -24
View File
@@ -5,17 +5,23 @@ import (
"fmt"
"os"
"os/signal"
"runtime"
"runtime/debug"
"syscall"
"time"
"Noooste/garage-ui/internal/auth"
"Noooste/garage-ui/internal/authz"
"Noooste/garage-ui/internal/config"
"Noooste/garage-ui/internal/handlers"
appmw "Noooste/garage-ui/internal/middleware"
"Noooste/garage-ui/internal/routes"
"Noooste/garage-ui/internal/services"
"Noooste/garage-ui/pkg/logger"
"github.com/gofiber/fiber/v3"
"github.com/gofiber/fiber/v3/middleware/recover"
"github.com/rs/zerolog/log"
)
// @title Garage UI API
@@ -59,10 +65,24 @@ 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()
// Env var fallback for --garage-toml
if *garageTomlPath == "" {
if envPath := os.Getenv("GARAGE_UI_GARAGE_TOML"); envPath != "" {
*garageTomlPath = envPath
}
}
// 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)
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")
@@ -78,12 +98,25 @@ func main() {
logger.Info().
Str("config_path", *configPath).
Str("version", version).
Str("go_version", runtime.Version()).
Str("environment", cfg.Server.Environment).
Msg("Starting Garage UI Backend")
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
logger.Info().Msg("Initializing Garage Admin service")
adminService := services.NewGarageAdminService(&cfg.Garage, cfg.Logging.Level)
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, cfg.AccessControl != nil)
logger.Info().Msg("Initializing S3 service")
s3Service := services.NewS3Service(&cfg.Garage, adminService)
@@ -96,6 +129,9 @@ 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")
}
@@ -105,10 +141,19 @@ func main() {
logger.Fatal().Err(err).Msg("Failed to initialize auth service")
}
policy, err := authz.CompilePolicy(cfg.AccessControl)
if err != nil {
logger.Fatal().Err(err).Msg("Invalid access_control configuration")
}
if cfg.AccessControl != nil && !cfg.Auth.OIDC.Enabled {
logger.Warn().Msg("access_control is configured but OIDC is disabled: admin and token logins are always full-admin in v1, so the policy currently gates nothing")
}
azMiddleware := authz.NewMiddleware(policy, authz.NewTeamResolver(policy, cfg.Auth.OIDC.EffectiveAdminRoles()), authz.NewAuthorizer())
// Initialize handlers
healthHandler := handlers.NewHealthHandler(version)
bucketHandler := handlers.NewBucketHandler(adminService, s3Service)
objectHandler := handlers.NewObjectHandler(s3Service)
objectHandler := handlers.NewObjectHandler(s3Service, authService)
userHandler := handlers.NewUserHandler(adminService)
clusterHandler := handlers.NewClusterHandler(adminService)
monitoringHandler := handlers.NewMonitoringHandler(adminService, s3Service)
@@ -140,15 +185,29 @@ func main() {
// Create Fiber app with configuration
app := fiber.New(fiber.Config{
AppName: "Garage UI Backend v" + version,
AppName: "Garage UI Backend | Version: " + version,
BodyLimit: int(maxBodySize),
ReadBufferSize: readBufferSize,
WriteBufferSize: writeBufferSize,
ErrorHandler: customErrorHandler,
})
// Apply global middleware
app.Use(recover.New()) // Panic recovery
// Apply global middleware (order matters):
// 1. recover — must be outermost so panics become 500s.
// 2. RequestID — mints/reads X-Request-ID before any logger needs it.
// 3. Logging — builds per-request zerolog logger + emits access log.
// Auth middleware is installed per-route inside routes.SetupRoutes.
app.Use(recover.New(recover.Config{
EnableStackTrace: true,
StackTraceHandler: func(c fiber.Ctx, e interface{}) {
logger.FromCtx(c.Context()).Error().
Interface("panic", e).
Bytes("stack", debug.Stack()).
Msg("panic_recovered")
},
}))
app.Use(appmw.RequestID())
app.Use(appmw.Logging(log.Logger))
// Setup routes
logger.Info().Msg("Setting up routes")
@@ -162,54 +221,61 @@ func main() {
userHandler,
clusterHandler,
monitoringHandler,
capabilitiesHandler,
azMiddleware,
)
if err := authz.VerifyRouteCoverage(app); err != nil {
logger.Fatal().Err(err).Msg("authz route coverage check failed")
}
// Start server in a goroutine
go func() {
addr := cfg.GetAddress()
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 {
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
logger.Info().Msg("Shutting down server")
logger.Info().Str("signal", sig.String()).Msg("Shutting down server")
shutdownStart := time.Now()
if err := app.Shutdown(); err != nil {
logger.Fatal().Err(err).Msg("Server shutdown failed")
}
logger.Info().Msg("Server stopped gracefully")
logger.Info().
Dur("shutdown_duration", time.Since(shutdownStart)).
Msg("Server stopped gracefully")
}
// customErrorHandler handles errors globally
// customErrorHandler handles errors globally. It uses the per-request logger
// from c.Context() so request_id / user_id attach automatically, and it
// demotes expected 4xx responses to warn (5xx stays at error).
func customErrorHandler(c fiber.Ctx, err error) error {
// Default to 500 Internal Server Error
code := fiber.StatusInternalServerError
// Check if it's a Fiber error
if e, ok := err.(*fiber.Error); ok {
code = e.Code
}
// Log the error
logger.Error().
Err(err).
Int("status_code", code).
Str("method", c.Method()).
Str("path", c.Path()).
Msg("Request error")
l := logger.FromCtx(c.Context())
evt := l.Error()
if code >= 400 && code < 500 {
evt = l.Warn()
}
evt.Err(err).Int("status_code", code).Msg("request_error")
// Return JSON error response
return c.Status(code).JSON(fiber.Map{
"success": false,
"error": fiber.Map{

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