Compare commits
147 Commits
v0.2.0-dev3
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6766dec933 | |||
| a061500d50 | |||
| 985b7b30ce | |||
| 8f6cb2b1da | |||
| 0e6b7b9215 | |||
| c0976a1e1d | |||
| 5d33382e30 | |||
| 477b544be7 | |||
| 90dffe594a | |||
| 9f73d032e6 | |||
| 16dc2eb996 | |||
| 0d804fbd39 | |||
| 3098e474f2 | |||
| b28e975a56 | |||
| 430d1a5d68 | |||
| 460793632e | |||
| f888b7c77c | |||
| ef118dc3a7 | |||
| 34ba8ef851 | |||
| 1ffdfe83c8 | |||
| e0cd59dcdf | |||
| 1c1e4d4b2b | |||
| ae245c8a31 | |||
| 28c186f3eb | |||
| 46aa3752c8 | |||
| d502dac457 | |||
| 67d8f633b0 | |||
| 22be89b2ff | |||
| ae97dd8f01 | |||
| 45f8770799 | |||
| e3191c2686 | |||
| 24997db960 | |||
| 3c69cc5f26 | |||
| 4b0e98008b | |||
| bc67e50606 | |||
| fe1765597c | |||
| 1c9043c697 | |||
| b80cfef844 | |||
| 186af18d54 | |||
| 4b3a562acb | |||
| 5427758eaa | |||
| c30400cf84 | |||
| d05b9ce324 | |||
| 1f16edd39c | |||
| 36ec8e800e | |||
| 1b645b0c2c | |||
| dc9ea5716a | |||
| 699f11afaa | |||
| 657d919331 | |||
| 01c4c16778 | |||
| 3521e63073 | |||
| 71d3c8446a | |||
| 709b9f2ad3 | |||
| fd49c4e4c9 | |||
| ff46ff6232 | |||
| 66abbc64b3 | |||
| cb3839e618 | |||
| f04c38168d | |||
| c45846535c | |||
| c8cb3c4923 | |||
| c8337de3a8 | |||
| 5cc4b02114 | |||
| 741232c797 | |||
| a9337ff59b | |||
| ff3977aeb5 | |||
| 5388f0da8f | |||
| 42055930ed | |||
| f1eeca60bf | |||
| a5f064761b | |||
| b90e4f71be | |||
| 21f10a0274 | |||
| 390ccd7893 | |||
| b8b4d039ff | |||
| 1e6ab4f3cc | |||
| 9feaaf7c04 | |||
| a6ecf2af05 | |||
| ee6de30b40 | |||
| c165d04c77 | |||
| e3a74c4791 | |||
| 93d7b3aeb2 | |||
| c7d7e8c215 | |||
| 4c92203023 | |||
| 6c3c13a321 | |||
| a293e0e007 | |||
| 153f95bf6b | |||
| 614bf5b04a | |||
| a74d00d81f | |||
| 13bbdac837 | |||
| 12b06a4565 | |||
| 093b3ebdcc | |||
| 4ebe8ec56f | |||
| 123f8d14f5 | |||
| aa3e8ac33b | |||
| c9ed8fed83 | |||
| 16e416420a | |||
| a7e79e5343 | |||
| f48d6f0812 | |||
| 1cd9e734bc | |||
| eb1e7fe96a | |||
| af7af32d4e | |||
| c951e5fa4b | |||
| a828020994 | |||
| dcef1e2cbd | |||
| 13e6fa3d1d | |||
| fece799627 | |||
| c5324db1ad | |||
| bfed48421b | |||
| 38cc1dded0 | |||
| d83313d866 | |||
| 6f10510c49 | |||
| dd0e0d43fb | |||
| 4447f6533f | |||
| 09289371a2 | |||
| 50d33f5dfc | |||
| cb1b14b941 | |||
| 03bb3e8fb6 | |||
| ae6af68ed9 | |||
| e918a0a940 | |||
| 005211f073 | |||
| 646813b977 | |||
| 361756859a | |||
| 9022b90f02 | |||
| 68be5ea2be | |||
| afc4da7491 | |||
| 1522439c68 | |||
| e4f86cd176 | |||
| dd275d2e78 | |||
| c14463fb8e | |||
| ab188dac4a | |||
| cbdab9a775 | |||
| 901ae70d02 | |||
| d0040be103 | |||
| 047a653446 | |||
| f63ce3452e | |||
| 5e68a77e15 | |||
| 46f904fe59 | |||
| 0baf31422a | |||
| 98842d7c7e | |||
| bd4c3b1c5f | |||
| 68d773d4af | |||
| 54a5e42db6 | |||
| 43ba68a4f9 | |||
| a0458fb5d0 | |||
| 6dc0feb374 | |||
| 33d8705431 | |||
| e7db44f45d | |||
| 126e138382 |
|
Before Width: | Height: | Size: 248 KiB After Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 364 KiB After Width: | Height: | Size: 135 KiB |
|
Before Width: | Height: | Size: 289 KiB After Width: | Height: | Size: 115 KiB |
|
Before Width: | Height: | Size: 344 KiB After Width: | Height: | Size: 117 KiB |
@@ -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"
|
||||
@@ -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:
|
||||
|
||||
@@ -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 }}"
|
||||
@@ -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"
|
||||
@@ -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 }}
|
||||
@@ -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 }}"
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
".": "0.10.0"
|
||||
}
|
||||
@@ -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))
|
||||
@@ -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.
|
||||
|
||||
@@ -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"]
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
[](https://github.com/Noooste/garage-ui/actions/workflows/build.yml)
|
||||
[](https://github.com/Noooste/garage-ui/actions/workflows/release.yml)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://go.dev/)
|
||||
[](https://nodejs.org/)
|
||||
[](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>
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
// newHealthTestApp builds a bare Fiber app with the health endpoint mounted.
|
||||
func newHealthTestApp(t *testing.T, version string) *fiber.App {
|
||||
t.Helper()
|
||||
h := NewHealthHandler(version)
|
||||
app := fiber.New()
|
||||
app.Get("/health", h.Check)
|
||||
return app
|
||||
}
|
||||
|
||||
func TestHealthCheck_ReturnsHealthyEnvelope(t *testing.T) {
|
||||
app := newHealthTestApp(t, "v0.0.0-test")
|
||||
req := httptest.NewRequest(http.MethodGet, "/health", nil)
|
||||
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read body: %v", err)
|
||||
}
|
||||
|
||||
var envelope struct {
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
Status string `json:"status"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Version string `json:"version"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &envelope); err != nil {
|
||||
t.Fatalf("decode body: %v\n%s", err, body)
|
||||
}
|
||||
|
||||
if !envelope.Success {
|
||||
t.Error("success = false, want true")
|
||||
}
|
||||
if envelope.Data.Status != "healthy" {
|
||||
t.Errorf("status = %q, want healthy", envelope.Data.Status)
|
||||
}
|
||||
if envelope.Data.Version != "v0.0.0-test" {
|
||||
t.Errorf("version = %q, want v0.0.0-test", envelope.Data.Version)
|
||||
}
|
||||
if envelope.Data.Timestamp.IsZero() {
|
||||
t.Error("timestamp zero")
|
||||
}
|
||||
// Timestamp must be within a reasonable window (1 minute) of now.
|
||||
if d := time.Since(envelope.Data.Timestamp); d < 0 || d > time.Minute {
|
||||
t.Errorf("timestamp delta = %v, want within 1 minute of now", d)
|
||||
}
|
||||
}
|
||||
@@ -7,14 +7,14 @@ import (
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
// MonitoringHandler handles monitoring operations
|
||||
// MonitoringHandler handles metrics and dashboard HTTP requests.
|
||||
type MonitoringHandler struct {
|
||||
adminService *services.GarageAdminService
|
||||
s3Service *services.S3Service
|
||||
adminService services.AdminService
|
||||
s3Service services.S3Storage
|
||||
}
|
||||
|
||||
// NewMonitoringHandler creates a new monitoring handler
|
||||
func NewMonitoringHandler(adminService *services.GarageAdminService, s3Service *services.S3Service) *MonitoringHandler {
|
||||
// NewMonitoringHandler creates a new monitoring handler.
|
||||
func NewMonitoringHandler(adminService services.AdminService, s3Service services.S3Storage) *MonitoringHandler {
|
||||
return &MonitoringHandler{
|
||||
adminService: adminService,
|
||||
s3Service: s3Service,
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"Noooste/garage-ui/internal/models"
|
||||
"Noooste/garage-ui/internal/services/mocks"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
func newMonitoringTestApp(t *testing.T) (*fiber.App, *mocks.AdminMock) {
|
||||
t.Helper()
|
||||
admin := &mocks.AdminMock{}
|
||||
h := NewMonitoringHandler(admin, nil) // s3Service unused by this handler
|
||||
app := fiber.New()
|
||||
app.Get("/monitoring/metrics", h.GetMetrics)
|
||||
app.Get("/monitoring/admin-health", h.CheckAdminHealth)
|
||||
app.Get("/monitoring/dashboard", h.GetDashboardMetrics)
|
||||
return app, admin
|
||||
}
|
||||
|
||||
func TestMonitoring_GetMetrics_PassesThroughAsPlainText(t *testing.T) {
|
||||
app, admin := newMonitoringTestApp(t)
|
||||
admin.GetMetricsFn = func(_ context.Context) (string, error) {
|
||||
return "# HELP foo\nfoo 1\n", nil
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, "/monitoring/metrics", nil)
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
if ct := resp.Header.Get("Content-Type"); ct != "text/plain; charset=utf-8" {
|
||||
t.Errorf("Content-Type = %q", ct)
|
||||
}
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
if string(b) != "# HELP foo\nfoo 1\n" {
|
||||
t.Errorf("body = %q", b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonitoring_GetMetrics_ServiceErrorReturns500JSON(t *testing.T) {
|
||||
app, admin := newMonitoringTestApp(t)
|
||||
admin.GetMetricsFn = func(_ context.Context) (string, error) {
|
||||
return "", errors.New("scrape failed")
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, "/monitoring/metrics", nil)
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want 500", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonitoring_CheckAdminHealth_Healthy(t *testing.T) {
|
||||
app, admin := newMonitoringTestApp(t)
|
||||
admin.HealthCheckFn = func(_ context.Context) error { return nil }
|
||||
req := httptest.NewRequest(http.MethodGet, "/monitoring/admin-health", nil)
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
var body struct {
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
} `json:"data"`
|
||||
}
|
||||
_ = json.NewDecoder(resp.Body).Decode(&body)
|
||||
if !body.Success || body.Data.Status != "healthy" {
|
||||
t.Errorf("body = %+v", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonitoring_CheckAdminHealth_Unhealthy503(t *testing.T) {
|
||||
app, admin := newMonitoringTestApp(t)
|
||||
admin.HealthCheckFn = func(_ context.Context) error { return errors.New("down") }
|
||||
req := httptest.NewRequest(http.MethodGet, "/monitoring/admin-health", nil)
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusServiceUnavailable {
|
||||
t.Fatalf("status = %d, want 503", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonitoring_GetDashboardMetrics_AggregatesSizesAndPercentages(t *testing.T) {
|
||||
app, admin := newMonitoringTestApp(t)
|
||||
admin.ListBucketsFn = func(_ context.Context) ([]models.ListBucketsResponseItem, error) {
|
||||
return []models.ListBucketsResponseItem{
|
||||
{ID: "b1", Created: time.Unix(0, 0), GlobalAliases: []string{"alpha"}},
|
||||
{ID: "b2", Created: time.Unix(0, 0), GlobalAliases: []string{"beta"}},
|
||||
}, nil
|
||||
}
|
||||
admin.GetBucketInfoFn = func(_ context.Context, id string) (*models.GarageBucketInfo, error) {
|
||||
switch id {
|
||||
case "b1":
|
||||
return &models.GarageBucketInfo{ID: "b1", Bytes: 300, Objects: 3}, nil
|
||||
case "b2":
|
||||
return &models.GarageBucketInfo{ID: "b2", Bytes: 100, Objects: 1}, nil
|
||||
}
|
||||
return nil, errors.New("unexpected id: " + id)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/monitoring/dashboard", nil)
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Data models.DashboardMetrics `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
|
||||
if body.Data.TotalSize != 400 {
|
||||
t.Errorf("TotalSize = %d, want 400", body.Data.TotalSize)
|
||||
}
|
||||
if body.Data.ObjectCount != 4 {
|
||||
t.Errorf("ObjectCount = %d, want 4", body.Data.ObjectCount)
|
||||
}
|
||||
if body.Data.BucketCount != 2 {
|
||||
t.Errorf("BucketCount = %d, want 2", body.Data.BucketCount)
|
||||
}
|
||||
if len(body.Data.UsageByBucket) != 2 {
|
||||
t.Fatalf("UsageByBucket len = %d, want 2", len(body.Data.UsageByBucket))
|
||||
}
|
||||
// Percentages: 300/400 = 75, 100/400 = 25.
|
||||
for _, u := range body.Data.UsageByBucket {
|
||||
if u.BucketName == "alpha" && u.Percentage != 75 {
|
||||
t.Errorf("alpha pct = %v, want 75", u.Percentage)
|
||||
}
|
||||
if u.BucketName == "beta" && u.Percentage != 25 {
|
||||
t.Errorf("beta pct = %v, want 25", u.Percentage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonitoring_GetDashboardMetrics_SkipsInaccessibleBuckets(t *testing.T) {
|
||||
app, admin := newMonitoringTestApp(t)
|
||||
admin.ListBucketsFn = func(_ context.Context) ([]models.ListBucketsResponseItem, error) {
|
||||
return []models.ListBucketsResponseItem{
|
||||
{ID: "good", GlobalAliases: []string{"good"}},
|
||||
{ID: "bad", GlobalAliases: []string{"bad"}},
|
||||
}, nil
|
||||
}
|
||||
admin.GetBucketInfoFn = func(_ context.Context, id string) (*models.GarageBucketInfo, error) {
|
||||
if id == "bad" {
|
||||
return nil, errors.New("access denied")
|
||||
}
|
||||
return &models.GarageBucketInfo{ID: "good", Bytes: 10, Objects: 1}, nil
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, "/monitoring/dashboard", nil)
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
var body struct {
|
||||
Data models.DashboardMetrics `json:"data"`
|
||||
}
|
||||
_ = json.NewDecoder(resp.Body).Decode(&body)
|
||||
if body.Data.BucketCount != 2 {
|
||||
t.Errorf("BucketCount = %d, want 2 (count includes inaccessible)", body.Data.BucketCount)
|
||||
}
|
||||
if len(body.Data.UsageByBucket) != 1 {
|
||||
t.Errorf("UsageByBucket = %d, want 1 (inaccessible skipped)", len(body.Data.UsageByBucket))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonitoring_GetDashboardMetrics_ListBucketsErrorReturns500(t *testing.T) {
|
||||
app, admin := newMonitoringTestApp(t)
|
||||
admin.ListBucketsFn = func(_ context.Context) ([]models.ListBucketsResponseItem, error) {
|
||||
return nil, errors.New("upstream")
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, "/monitoring/dashboard", nil)
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want 500", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
@@ -1,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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -9,13 +9,13 @@ import (
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
// UserHandler handles user/key management operations using Garage Admin API
|
||||
// UserHandler handles user and access key HTTP requests.
|
||||
type UserHandler struct {
|
||||
adminService *services.GarageAdminService
|
||||
adminService services.AdminService
|
||||
}
|
||||
|
||||
// NewUserHandler creates a new user handler
|
||||
func NewUserHandler(adminService *services.GarageAdminService) *UserHandler {
|
||||
// NewUserHandler creates a new user handler.
|
||||
func NewUserHandler(adminService services.AdminService) *UserHandler {
|
||||
return &UserHandler{
|
||||
adminService: adminService,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"Noooste/garage-ui/internal/models"
|
||||
"Noooste/garage-ui/internal/services/mocks"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
func newUsersTestApp(t *testing.T) (*fiber.App, *mocks.AdminMock) {
|
||||
t.Helper()
|
||||
admin := &mocks.AdminMock{}
|
||||
h := NewUserHandler(admin)
|
||||
app := fiber.New()
|
||||
app.Get("/users", h.ListUsers)
|
||||
app.Post("/users", h.CreateUser)
|
||||
app.Get("/users/:access_key", h.GetUser)
|
||||
app.Get("/users/:access_key/secret", h.GetUserSecretKey)
|
||||
app.Delete("/users/:access_key", h.DeleteUser)
|
||||
app.Patch("/users/:access_key", h.UpdateUserPermissions)
|
||||
return app, admin
|
||||
}
|
||||
|
||||
// --- ListUsers ---
|
||||
|
||||
func TestListUsers_MapsAndSkipsFailed(t *testing.T) {
|
||||
app, admin := newUsersTestApp(t)
|
||||
admin.ListKeysFn = func(_ context.Context) ([]models.ListKeysResponseItem, error) {
|
||||
return []models.ListKeysResponseItem{
|
||||
{ID: "AKIA-1", Name: "one"},
|
||||
{ID: "AKIA-2", Name: "bad-detail"},
|
||||
{ID: "AKIA-3", Name: "three"},
|
||||
}, nil
|
||||
}
|
||||
admin.GetKeyInfoFn = func(_ context.Context, id string, showSecret bool) (*models.GarageKeyInfo, error) {
|
||||
if showSecret {
|
||||
t.Error("ListUsers must not request secret")
|
||||
}
|
||||
if id == "AKIA-2" {
|
||||
return nil, errors.New("forbidden")
|
||||
}
|
||||
return &models.GarageKeyInfo{AccessKeyID: id, Name: id, Expired: false}, nil
|
||||
}
|
||||
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/users", nil))
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d", resp.StatusCode)
|
||||
}
|
||||
var body struct {
|
||||
Data models.UserListResponse `json:"data"`
|
||||
}
|
||||
decodeJSON(t, resp.Body, &body)
|
||||
if body.Data.Count != 2 {
|
||||
t.Errorf("count = %d, want 2 (failed detail skipped)", body.Data.Count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListUsers_ListError500(t *testing.T) {
|
||||
app, admin := newUsersTestApp(t)
|
||||
admin.ListKeysFn = func(_ context.Context) ([]models.ListKeysResponseItem, error) {
|
||||
return nil, errors.New("boom")
|
||||
}
|
||||
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/users", nil))
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want 500", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// --- CreateUser ---
|
||||
|
||||
func TestCreateUser_Success201(t *testing.T) {
|
||||
app, admin := newUsersTestApp(t)
|
||||
admin.CreateKeyFn = func(_ context.Context, req models.CreateKeyRequest) (*models.GarageKeyInfo, error) {
|
||||
if req.Name == nil || *req.Name != "alice" {
|
||||
t.Errorf("Name = %v", req.Name)
|
||||
}
|
||||
sk := "secret-xyz"
|
||||
return &models.GarageKeyInfo{AccessKeyID: "AKIA-1", Name: "alice", SecretAccessKey: &sk}, nil
|
||||
}
|
||||
body, _ := json.Marshal(models.CreateUserRequest{Name: "alice"})
|
||||
req := httptest.NewRequest(http.MethodPost, "/users", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("status = %d, want 201", resp.StatusCode)
|
||||
}
|
||||
var decoded struct {
|
||||
Data models.UserInfo `json:"data"`
|
||||
}
|
||||
decodeJSON(t, resp.Body, &decoded)
|
||||
if decoded.Data.SecretKey == nil || *decoded.Data.SecretKey != "secret-xyz" {
|
||||
t.Errorf("SecretKey = %v, want secret-xyz", decoded.Data.SecretKey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateUser_MalformedJSON400(t *testing.T) {
|
||||
app, _ := newUsersTestApp(t)
|
||||
req := httptest.NewRequest(http.MethodPost, "/users", strings.NewReader("{not-json"))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateUser_AdminError500(t *testing.T) {
|
||||
app, admin := newUsersTestApp(t)
|
||||
admin.CreateKeyFn = func(_ context.Context, _ models.CreateKeyRequest) (*models.GarageKeyInfo, error) {
|
||||
return nil, errors.New("boom")
|
||||
}
|
||||
body, _ := json.Marshal(models.CreateUserRequest{})
|
||||
req := httptest.NewRequest(http.MethodPost, "/users", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want 500", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// --- GetUser ---
|
||||
|
||||
func TestGetUser_Success(t *testing.T) {
|
||||
app, admin := newUsersTestApp(t)
|
||||
admin.GetKeyInfoFn = func(_ context.Context, id string, showSecret bool) (*models.GarageKeyInfo, error) {
|
||||
if showSecret {
|
||||
t.Error("GetUser must not request secret")
|
||||
}
|
||||
return &models.GarageKeyInfo{AccessKeyID: id, Name: "alice"}, nil
|
||||
}
|
||||
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/users/AKIA-1", nil))
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUser_ServiceError500(t *testing.T) {
|
||||
app, admin := newUsersTestApp(t)
|
||||
admin.GetKeyInfoFn = func(_ context.Context, _ string, _ bool) (*models.GarageKeyInfo, error) {
|
||||
return nil, errors.New("boom")
|
||||
}
|
||||
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/users/AKIA-1", nil))
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want 500", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// --- GetUserSecretKey ---
|
||||
|
||||
func TestGetUserSecretKey_Success(t *testing.T) {
|
||||
app, admin := newUsersTestApp(t)
|
||||
admin.GetKeyInfoFn = func(_ context.Context, id string, showSecret bool) (*models.GarageKeyInfo, error) {
|
||||
if !showSecret {
|
||||
t.Error("GetUserSecretKey must request secret")
|
||||
}
|
||||
sk := "s3cr3t"
|
||||
return &models.GarageKeyInfo{AccessKeyID: id, SecretAccessKey: &sk}, nil
|
||||
}
|
||||
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/users/AKIA-1/secret", nil))
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d", resp.StatusCode)
|
||||
}
|
||||
var body struct {
|
||||
Data map[string]string `json:"data"`
|
||||
}
|
||||
decodeJSON(t, resp.Body, &body)
|
||||
if body.Data["secretKey"] != "s3cr3t" {
|
||||
t.Errorf("secretKey = %q", body.Data["secretKey"])
|
||||
}
|
||||
}
|
||||
|
||||
// --- DeleteUser ---
|
||||
|
||||
func TestDeleteUser_Success(t *testing.T) {
|
||||
app, admin := newUsersTestApp(t)
|
||||
admin.DeleteKeyFn = func(_ context.Context, id string) error {
|
||||
if id != "AKIA-1" {
|
||||
t.Errorf("id = %q", id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
resp, err := app.Test(httptest.NewRequest(http.MethodDelete, "/users/AKIA-1", nil))
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteUser_ServiceError500(t *testing.T) {
|
||||
app, admin := newUsersTestApp(t)
|
||||
admin.DeleteKeyFn = func(_ context.Context, _ string) error { return errors.New("boom") }
|
||||
resp, err := app.Test(httptest.NewRequest(http.MethodDelete, "/users/AKIA-1", nil))
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want 500", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// --- UpdateUserPermissions ---
|
||||
|
||||
func TestUpdateUser_StatusInactiveSetsPastExpiration(t *testing.T) {
|
||||
app, admin := newUsersTestApp(t)
|
||||
admin.UpdateKeyFn = func(_ context.Context, _ string, req models.UpdateKeyRequest) (*models.GarageKeyInfo, error) {
|
||||
if req.NeverExpires {
|
||||
t.Error("NeverExpires should be false when deactivating")
|
||||
}
|
||||
if req.Expiration == nil || !req.Expiration.Before(time.Now()) {
|
||||
t.Errorf("Expiration = %v, want past time", req.Expiration)
|
||||
}
|
||||
return &models.GarageKeyInfo{}, nil
|
||||
}
|
||||
status := "inactive"
|
||||
body, _ := json.Marshal(models.UpdateUserRequest{Status: &status})
|
||||
req := httptest.NewRequest(http.MethodPatch, "/users/AKIA-1", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateUser_StatusActiveSetsNeverExpires(t *testing.T) {
|
||||
app, admin := newUsersTestApp(t)
|
||||
admin.UpdateKeyFn = func(_ context.Context, _ string, req models.UpdateKeyRequest) (*models.GarageKeyInfo, error) {
|
||||
if !req.NeverExpires {
|
||||
t.Error("NeverExpires should be true when activating")
|
||||
}
|
||||
return &models.GarageKeyInfo{}, nil
|
||||
}
|
||||
status := "active"
|
||||
body, _ := json.Marshal(models.UpdateUserRequest{Status: &status})
|
||||
req := httptest.NewRequest(http.MethodPatch, "/users/AKIA-1", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateUser_ExplicitExpiration(t *testing.T) {
|
||||
app, admin := newUsersTestApp(t)
|
||||
wantTime, _ := time.Parse(time.RFC3339, "2030-01-02T03:04:05Z")
|
||||
admin.UpdateKeyFn = func(_ context.Context, _ string, req models.UpdateKeyRequest) (*models.GarageKeyInfo, error) {
|
||||
if req.Expiration == nil || !req.Expiration.Equal(wantTime) {
|
||||
t.Errorf("Expiration = %v, want %v", req.Expiration, wantTime)
|
||||
}
|
||||
if req.NeverExpires {
|
||||
t.Error("NeverExpires should be false with explicit expiration")
|
||||
}
|
||||
return &models.GarageKeyInfo{}, nil
|
||||
}
|
||||
exp := "2030-01-02T03:04:05Z"
|
||||
body, _ := json.Marshal(models.UpdateUserRequest{Expiration: &exp})
|
||||
req := httptest.NewRequest(http.MethodPatch, "/users/AKIA-1", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateUser_BadExpirationFormat400(t *testing.T) {
|
||||
app, _ := newUsersTestApp(t)
|
||||
exp := "not-a-date"
|
||||
body, _ := json.Marshal(models.UpdateUserRequest{Expiration: &exp})
|
||||
req := httptest.NewRequest(http.MethodPatch, "/users/AKIA-1", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateUser_MalformedJSON400(t *testing.T) {
|
||||
app, _ := newUsersTestApp(t)
|
||||
req := httptest.NewRequest(http.MethodPatch, "/users/AKIA-1", strings.NewReader("{not-json"))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateUser_AdminError500(t *testing.T) {
|
||||
app, admin := newUsersTestApp(t)
|
||||
admin.UpdateKeyFn = func(_ context.Context, _ string, _ models.UpdateKeyRequest) (*models.GarageKeyInfo, error) {
|
||||
return nil, errors.New("boom")
|
||||
}
|
||||
status := "active"
|
||||
body, _ := json.Marshal(models.UpdateUserRequest{Status: &status})
|
||||
req := httptest.NewRequest(http.MethodPatch, "/users/AKIA-1", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want 500", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"Noooste/garage-ui/internal/config"
|
||||
@@ -20,10 +21,14 @@ func CORSMiddleware(cfg *config.CORSConfig) fiber.Handler {
|
||||
return func(c fiber.Ctx) error {
|
||||
origin := c.Get("Origin")
|
||||
|
||||
// Check if origin is allowed
|
||||
if origin != "" && isAllowedOrigin(origin, cfg.AllowedOrigins) {
|
||||
// Check if origin is allowed. When credentials are allowed we refuse
|
||||
// to treat "*" as a match: reflecting an arbitrary Origin alongside
|
||||
// Access-Control-Allow-Credentials: true lets any site read responses
|
||||
// cross-origin with the user's session cookie.
|
||||
if origin != "" && isAllowedOrigin(origin, cfg.AllowedOrigins, cfg.AllowCredentials) {
|
||||
// Set CORS headers
|
||||
c.Set("Access-Control-Allow-Origin", origin)
|
||||
c.Set("Vary", "Origin")
|
||||
|
||||
if cfg.AllowCredentials {
|
||||
c.Set("Access-Control-Allow-Credentials", "true")
|
||||
@@ -41,7 +46,7 @@ func CORSMiddleware(cfg *config.CORSConfig) fiber.Handler {
|
||||
|
||||
// Set max age for preflight cache
|
||||
if cfg.MaxAge > 0 {
|
||||
c.Set("Access-Control-Max-Age", string(rune(cfg.MaxAge)))
|
||||
c.Set("Access-Control-Max-Age", strconv.Itoa(cfg.MaxAge))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,10 +59,14 @@ func CORSMiddleware(cfg *config.CORSConfig) fiber.Handler {
|
||||
}
|
||||
}
|
||||
|
||||
// isAllowedOrigin checks if an origin is in the allowed list
|
||||
func isAllowedOrigin(origin string, allowedOrigins []string) bool {
|
||||
// isAllowedOrigin checks if an origin is in the allowed list.
|
||||
// When allowCredentials is true, "*" is NOT honored — exact match is required.
|
||||
func isAllowedOrigin(origin string, allowedOrigins []string, allowCredentials bool) bool {
|
||||
for _, allowed := range allowedOrigins {
|
||||
if allowed == "*" || allowed == origin {
|
||||
if allowed == origin {
|
||||
return true
|
||||
}
|
||||
if allowed == "*" && !allowCredentials {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"Noooste/garage-ui/internal/config"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
func newCORSApp(t *testing.T, cfg *config.CORSConfig) *fiber.App {
|
||||
t.Helper()
|
||||
app := fiber.New()
|
||||
app.Use(CORSMiddleware(cfg))
|
||||
app.Get("/x", func(c fiber.Ctx) error {
|
||||
return c.SendString("ok")
|
||||
})
|
||||
return app
|
||||
}
|
||||
|
||||
func TestCORS_Disabled_NoHeadersSet(t *testing.T) {
|
||||
cfg := &config.CORSConfig{Enabled: false}
|
||||
app := newCORSApp(t, cfg)
|
||||
|
||||
req := httptest.NewRequest("GET", "/x", nil)
|
||||
req.Header.Set("Origin", "https://foo.example")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "" {
|
||||
t.Errorf("Allow-Origin = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCORS_Enabled_AllowedOriginEchoes(t *testing.T) {
|
||||
cfg := &config.CORSConfig{
|
||||
Enabled: true,
|
||||
AllowedOrigins: []string{"https://ok.example"},
|
||||
AllowedMethods: []string{"GET", "POST"},
|
||||
AllowedHeaders: []string{"Authorization", "Content-Type"},
|
||||
MaxAge: 300,
|
||||
}
|
||||
app := newCORSApp(t, cfg)
|
||||
|
||||
req := httptest.NewRequest("GET", "/x", nil)
|
||||
req.Header.Set("Origin", "https://ok.example")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "https://ok.example" {
|
||||
t.Errorf("Allow-Origin = %q", got)
|
||||
}
|
||||
if got := resp.Header.Get("Vary"); got != "Origin" {
|
||||
t.Errorf("Vary = %q, want Origin", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCORS_Enabled_OriginNotInList_NoHeaders(t *testing.T) {
|
||||
cfg := &config.CORSConfig{
|
||||
Enabled: true,
|
||||
AllowedOrigins: []string{"https://ok.example"},
|
||||
}
|
||||
app := newCORSApp(t, cfg)
|
||||
|
||||
req := httptest.NewRequest("GET", "/x", nil)
|
||||
req.Header.Set("Origin", "https://evil.example")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "" {
|
||||
t.Errorf("Allow-Origin = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCORS_Enabled_NoOriginHeader_NoCORSHeaders(t *testing.T) {
|
||||
cfg := &config.CORSConfig{
|
||||
Enabled: true,
|
||||
AllowedOrigins: []string{"*"},
|
||||
}
|
||||
app := newCORSApp(t, cfg)
|
||||
|
||||
req := httptest.NewRequest("GET", "/x", nil)
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "" {
|
||||
t.Errorf("Allow-Origin = %q, want empty (no Origin header)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCORS_Wildcard_NoCredentials_AllowsAnyOrigin(t *testing.T) {
|
||||
cfg := &config.CORSConfig{
|
||||
Enabled: true,
|
||||
AllowedOrigins: []string{"*"},
|
||||
AllowCredentials: false,
|
||||
}
|
||||
app := newCORSApp(t, cfg)
|
||||
|
||||
req := httptest.NewRequest("GET", "/x", nil)
|
||||
req.Header.Set("Origin", "https://anywhere.example")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "https://anywhere.example" {
|
||||
t.Errorf("Allow-Origin = %q, want echo", got)
|
||||
}
|
||||
if got := resp.Header.Get("Access-Control-Allow-Credentials"); got != "" {
|
||||
t.Errorf("Allow-Credentials set unexpectedly: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCORS_Wildcard_WithCredentials_RejectsUnlistedOrigin(t *testing.T) {
|
||||
cfg := &config.CORSConfig{
|
||||
Enabled: true,
|
||||
AllowedOrigins: []string{"*"},
|
||||
AllowCredentials: true,
|
||||
}
|
||||
app := newCORSApp(t, cfg)
|
||||
|
||||
req := httptest.NewRequest("GET", "/x", nil)
|
||||
req.Header.Set("Origin", "https://evil.example")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "" {
|
||||
t.Errorf("Allow-Origin = %q, want empty (wildcard+creds must not honor *)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCORS_ExactMatch_WithCredentials_SetsAllowCredentials(t *testing.T) {
|
||||
cfg := &config.CORSConfig{
|
||||
Enabled: true,
|
||||
AllowedOrigins: []string{"https://ok.example"},
|
||||
AllowCredentials: true,
|
||||
}
|
||||
app := newCORSApp(t, cfg)
|
||||
|
||||
req := httptest.NewRequest("GET", "/x", nil)
|
||||
req.Header.Set("Origin", "https://ok.example")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "https://ok.example" {
|
||||
t.Errorf("Allow-Origin = %q", got)
|
||||
}
|
||||
if got := resp.Header.Get("Access-Control-Allow-Credentials"); got != "true" {
|
||||
t.Errorf("Allow-Credentials = %q, want true", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCORS_Preflight_AllowedOrigin_Returns204WithHeaders(t *testing.T) {
|
||||
cfg := &config.CORSConfig{
|
||||
Enabled: true,
|
||||
AllowedOrigins: []string{"https://ok.example"},
|
||||
AllowedMethods: []string{"GET", "POST", "PUT"},
|
||||
AllowedHeaders: []string{"Authorization"},
|
||||
MaxAge: 600,
|
||||
}
|
||||
app := newCORSApp(t, cfg)
|
||||
|
||||
req := httptest.NewRequest("OPTIONS", "/x", nil)
|
||||
req.Header.Set("Origin", "https://ok.example")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
if resp.StatusCode != 204 {
|
||||
t.Fatalf("status = %d, want 204", resp.StatusCode)
|
||||
}
|
||||
if got := resp.Header.Get("Access-Control-Allow-Methods"); got != "GET, POST, PUT" {
|
||||
t.Errorf("Allow-Methods = %q", got)
|
||||
}
|
||||
if got := resp.Header.Get("Access-Control-Allow-Headers"); got != "Authorization" {
|
||||
t.Errorf("Allow-Headers = %q", got)
|
||||
}
|
||||
if got := resp.Header.Get("Access-Control-Max-Age"); got != "600" {
|
||||
t.Errorf("Max-Age = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCORS_Preflight_DisallowedOrigin_Returns204NoHeaders(t *testing.T) {
|
||||
cfg := &config.CORSConfig{
|
||||
Enabled: true,
|
||||
AllowedOrigins: []string{"https://ok.example"},
|
||||
}
|
||||
app := newCORSApp(t, cfg)
|
||||
|
||||
req := httptest.NewRequest("OPTIONS", "/x", nil)
|
||||
req.Header.Set("Origin", "https://evil.example")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
if resp.StatusCode != 204 {
|
||||
t.Fatalf("status = %d, want 204", resp.StatusCode)
|
||||
}
|
||||
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "" {
|
||||
t.Errorf("Allow-Origin set for disallowed preflight: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCORS_EmptyAllowedMethods_NoAllowMethodsHeader(t *testing.T) {
|
||||
cfg := &config.CORSConfig{
|
||||
Enabled: true,
|
||||
AllowedOrigins: []string{"https://ok.example"},
|
||||
// AllowedMethods intentionally nil
|
||||
}
|
||||
app := newCORSApp(t, cfg)
|
||||
|
||||
req := httptest.NewRequest("GET", "/x", nil)
|
||||
req.Header.Set("Origin", "https://ok.example")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
if got := resp.Header.Get("Access-Control-Allow-Methods"); got != "" {
|
||||
t.Errorf("Allow-Methods set when list empty: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCORS_MaxAgeZero_NoMaxAgeHeader(t *testing.T) {
|
||||
cfg := &config.CORSConfig{
|
||||
Enabled: true,
|
||||
AllowedOrigins: []string{"https://ok.example"},
|
||||
MaxAge: 0,
|
||||
}
|
||||
app := newCORSApp(t, cfg)
|
||||
|
||||
req := httptest.NewRequest("GET", "/x", nil)
|
||||
req.Header.Set("Origin", "https://ok.example")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
if got := resp.Header.Get("Access-Control-Max-Age"); got != "" {
|
||||
t.Errorf("Max-Age set when zero: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsAllowedOrigin(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
origin string
|
||||
allowed []string
|
||||
allowCredentials bool
|
||||
want bool
|
||||
}{
|
||||
{"exact match", "https://ok.example", []string{"https://ok.example"}, false, true},
|
||||
{"exact match with creds", "https://ok.example", []string{"https://ok.example"}, true, true},
|
||||
{"wildcard without creds matches", "https://any.example", []string{"*"}, false, true},
|
||||
{"wildcard with creds rejected", "https://any.example", []string{"*"}, true, false},
|
||||
{"no match", "https://evil.example", []string{"https://ok.example"}, false, false},
|
||||
{"empty allowed list", "https://ok.example", nil, false, false},
|
||||
{"multiple entries — exact hit", "https://b.example", []string{"https://a.example", "https://b.example"}, false, true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := isAllowedOrigin(tc.origin, tc.allowed, tc.allowCredentials); got != tc.want {
|
||||
t.Errorf("isAllowedOrigin(%q, %v, %v) = %v, want %v",
|
||||
tc.origin, tc.allowed, tc.allowCredentials, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
logpkg "Noooste/garage-ui/pkg/logger"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
// LoggerLocalsKey is the fiber.Ctx.Locals key carrying the per-request logger.
|
||||
const LoggerLocalsKey = "logger"
|
||||
|
||||
// Logging returns middleware that (1) builds a per-request zerolog.Logger
|
||||
// bound with request_id/method/path/remote_ip/user_agent, (2) injects it into
|
||||
// c.Context() so service layers can retrieve it via logger.FromCtx, and
|
||||
// (3) emits a single access-log line after the handler runs.
|
||||
//
|
||||
// The base logger is the one to derive from — typically the global zerolog
|
||||
// logger configured at startup. Tests pass a buffer-backed logger here.
|
||||
//
|
||||
// Access-log line fields: request_id, method, path, remote_ip, user_agent,
|
||||
// status, duration_ms, bytes_out. Skipped for /health and OPTIONS.
|
||||
// Level is chosen from status: >=500 error, >=400 warn, else info.
|
||||
func Logging(base zerolog.Logger) fiber.Handler {
|
||||
return func(c fiber.Ctx) error {
|
||||
requestID, _ := c.Locals(RequestIDLocalsKey).(string)
|
||||
|
||||
reqLogger := base.With().
|
||||
Str("request_id", requestID).
|
||||
Str("method", c.Method()).
|
||||
Str("path", c.Path()).
|
||||
Str("remote_ip", c.IP()).
|
||||
Str("user_agent", c.Get("User-Agent")).
|
||||
Logger()
|
||||
|
||||
c.Locals(LoggerLocalsKey, reqLogger)
|
||||
c.SetContext(logpkg.IntoCtx(c.Context(), reqLogger))
|
||||
|
||||
start := time.Now()
|
||||
err := c.Next()
|
||||
duration := time.Since(start)
|
||||
|
||||
if skipAccessLog(c) {
|
||||
return err
|
||||
}
|
||||
|
||||
status := c.Response().StatusCode()
|
||||
bytesOut := len(c.Response().Body())
|
||||
|
||||
evt := eventForStatus(&reqLogger, status)
|
||||
evt.
|
||||
Int("status", status).
|
||||
Float64("duration_ms", float64(duration.Microseconds())/1000.0).
|
||||
Int("bytes_out", bytesOut).
|
||||
Msg("http_request")
|
||||
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
func skipAccessLog(c fiber.Ctx) bool {
|
||||
if c.Method() == fiber.MethodOptions {
|
||||
return true
|
||||
}
|
||||
if c.Path() == "/health" {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func eventForStatus(l *zerolog.Logger, status int) *zerolog.Event {
|
||||
switch {
|
||||
case status >= 500:
|
||||
return l.Error()
|
||||
case status >= 400:
|
||||
return l.Warn()
|
||||
default:
|
||||
return l.Info()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
logpkg "Noooste/garage-ui/pkg/logger"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
// newLoggingTestApp installs RequestID + Logging middleware with a provided
|
||||
// zerolog.Logger writing to buf so tests can assert on the JSON output.
|
||||
func newLoggingTestApp(t *testing.T, buf *bytes.Buffer, handler fiber.Handler) *fiber.App {
|
||||
t.Helper()
|
||||
base := zerolog.New(buf)
|
||||
app := fiber.New()
|
||||
app.Use(RequestID())
|
||||
app.Use(Logging(base))
|
||||
app.Get("/ping", handler)
|
||||
app.Get("/health", handler)
|
||||
return app
|
||||
}
|
||||
|
||||
func parseLines(t *testing.T, buf *bytes.Buffer) []map[string]any {
|
||||
t.Helper()
|
||||
out := []map[string]any{}
|
||||
for _, line := range strings.Split(buf.String(), "\n") {
|
||||
if strings.TrimSpace(line) == "" {
|
||||
continue
|
||||
}
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal([]byte(line), &m); err != nil {
|
||||
t.Fatalf("not JSON: %v — %s", err, line)
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestLogging_InjectsLoggerIntoContext(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
app := newLoggingTestApp(t, &buf, func(c fiber.Ctx) error {
|
||||
logpkg.FromCtx(c.Context()).Info().Str("stage", "handler").Msg("handled")
|
||||
return c.SendString("ok")
|
||||
})
|
||||
|
||||
req := httptest.NewRequest("GET", "/ping", nil)
|
||||
if _, err := app.Test(req); err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
|
||||
lines := parseLines(t, &buf)
|
||||
if len(lines) < 2 {
|
||||
t.Fatalf("expected >=2 log lines (handler + access), got %d: %q", len(lines), buf.String())
|
||||
}
|
||||
|
||||
h := lines[0]
|
||||
if h["stage"] != "handler" {
|
||||
t.Errorf("handler line stage = %v", h["stage"])
|
||||
}
|
||||
if _, ok := h["request_id"].(string); !ok || h["request_id"] == "" {
|
||||
t.Errorf("handler line missing request_id: %v", h)
|
||||
}
|
||||
if h["method"] != "GET" || h["path"] != "/ping" {
|
||||
t.Errorf("handler line missing method/path: %v", h)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogging_EmitsAccessLogLine(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
app := newLoggingTestApp(t, &buf, func(c fiber.Ctx) error {
|
||||
return c.SendString("ok")
|
||||
})
|
||||
|
||||
req := httptest.NewRequest("GET", "/ping", nil)
|
||||
if _, err := app.Test(req); err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
|
||||
lines := parseLines(t, &buf)
|
||||
var access map[string]any
|
||||
for i := len(lines) - 1; i >= 0; i-- {
|
||||
if _, ok := lines[i]["status"]; ok {
|
||||
access = lines[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if access == nil {
|
||||
t.Fatalf("no access-log line found: %s", buf.String())
|
||||
}
|
||||
|
||||
if access["method"] != "GET" || access["path"] != "/ping" {
|
||||
t.Errorf("access line method/path wrong: %v", access)
|
||||
}
|
||||
if got, _ := access["status"].(float64); got != 200 {
|
||||
t.Errorf("access line status = %v, want 200", access["status"])
|
||||
}
|
||||
if _, ok := access["duration_ms"].(float64); !ok {
|
||||
t.Errorf("access line missing duration_ms: %v", access)
|
||||
}
|
||||
if access["level"] != "info" {
|
||||
t.Errorf("200 should log at info; got level %v", access["level"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogging_SkipsHealthEndpoint(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
app := newLoggingTestApp(t, &buf, func(c fiber.Ctx) error {
|
||||
return c.SendString("ok")
|
||||
})
|
||||
|
||||
req := httptest.NewRequest("GET", "/health", nil)
|
||||
if _, err := app.Test(req); err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
|
||||
for _, line := range parseLines(t, &buf) {
|
||||
if _, hasStatus := line["status"]; hasStatus {
|
||||
if line["path"] == "/health" {
|
||||
t.Errorf("/health should not emit an access log line: %v", line)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogging_LevelByStatus(t *testing.T) {
|
||||
cases := []struct {
|
||||
status int
|
||||
wantLevel string
|
||||
}{
|
||||
{200, "info"},
|
||||
{404, "warn"},
|
||||
{500, "error"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.wantLevel, func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
app := newLoggingTestApp(t, &buf, func(c fiber.Ctx) error {
|
||||
return c.Status(tc.status).SendString("x")
|
||||
})
|
||||
req := httptest.NewRequest("GET", "/ping", nil)
|
||||
if _, err := app.Test(req); err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
|
||||
var access map[string]any
|
||||
for _, line := range parseLines(t, &buf) {
|
||||
if _, ok := line["status"]; ok {
|
||||
access = line
|
||||
}
|
||||
}
|
||||
if access == nil {
|
||||
t.Fatalf("no access line")
|
||||
}
|
||||
if access["level"] != tc.wantLevel {
|
||||
t.Errorf("status %d → level %v, want %v", tc.status, access["level"], tc.wantLevel)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"github.com/gofiber/fiber/v3"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// RequestIDHeader is the HTTP header used to read an incoming request ID
|
||||
// (for cross-service correlation) and to echo the request ID in the response.
|
||||
const RequestIDHeader = "X-Request-ID"
|
||||
|
||||
// RequestIDLocalsKey is the fiber.Ctx.Locals key carrying the request ID.
|
||||
const RequestIDLocalsKey = "request_id"
|
||||
|
||||
// RequestID returns middleware that assigns a request ID to every request.
|
||||
// If the client sends X-Request-ID, that value is used; otherwise a new
|
||||
// UUIDv4 is generated. The ID is stored on c.Locals and echoed in the
|
||||
// response header so clients and downstream services can correlate logs.
|
||||
func RequestID() fiber.Handler {
|
||||
return func(c fiber.Ctx) error {
|
||||
id := c.Get(RequestIDHeader)
|
||||
if id == "" {
|
||||
id = uuid.NewString()
|
||||
}
|
||||
c.Locals(RequestIDLocalsKey, id)
|
||||
c.Set(RequestIDHeader, id)
|
||||
return c.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func newTestApp(t *testing.T, handler fiber.Handler, mw ...fiber.Handler) *fiber.App {
|
||||
t.Helper()
|
||||
app := fiber.New()
|
||||
for _, m := range mw {
|
||||
app.Use(m)
|
||||
}
|
||||
app.Get("/ping", handler)
|
||||
return app
|
||||
}
|
||||
|
||||
func TestRequestID_GeneratesWhenAbsent(t *testing.T) {
|
||||
var seen string
|
||||
app := newTestApp(t, func(c fiber.Ctx) error {
|
||||
seen, _ = c.Locals("request_id").(string)
|
||||
return c.SendString("ok")
|
||||
}, RequestID())
|
||||
|
||||
req := httptest.NewRequest("GET", "/ping", nil)
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
|
||||
if seen == "" {
|
||||
t.Fatal("request_id not set on c.Locals")
|
||||
}
|
||||
if _, err := uuid.Parse(seen); err != nil {
|
||||
t.Errorf("request_id %q is not a valid UUID: %v", seen, err)
|
||||
}
|
||||
if got := resp.Header.Get("X-Request-ID"); got != seen {
|
||||
t.Errorf("X-Request-ID response header = %q, want %q", got, seen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestID_HonorsIncomingHeader(t *testing.T) {
|
||||
var seen string
|
||||
app := newTestApp(t, func(c fiber.Ctx) error {
|
||||
seen, _ = c.Locals("request_id").(string)
|
||||
return c.SendString("ok")
|
||||
}, RequestID())
|
||||
|
||||
req := httptest.NewRequest("GET", "/ping", nil)
|
||||
req.Header.Set("X-Request-ID", "incoming-abc-123")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
|
||||
if seen != "incoming-abc-123" {
|
||||
t.Errorf("request_id = %q, want incoming-abc-123", seen)
|
||||
}
|
||||
if got := resp.Header.Get("X-Request-ID"); got != "incoming-abc-123" {
|
||||
t.Errorf("X-Request-ID response header = %q, want incoming-abc-123", got)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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{
|
||||
|
||||