diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..d7828f6 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,74 @@ +name: backend-tests + +on: + pull_request: + paths: + - 'backend/**' + - 'Dockerfile' + - 'Makefile' + - 'scripts/coverage-gate.sh' + - '.github/workflows/test.yml' + push: + branches: [main] + +jobs: + unit: + name: Unit tests + coverage gate + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.25' + cache: true + cache-dependency-path: backend/go.sum + + - name: Generate swagger docs + run: | + go install github.com/swaggo/swag/cmd/swag@latest + cd backend && swag init + + - name: Run unit tests with race detector and coverage + run: | + cd backend + go test -race -coverprofile=../coverage.out -coverpkg=./... ./... + + - name: Enforce coverage gate + run: bash scripts/coverage-gate.sh coverage.out + + - name: Upload coverage artifact + if: always() + uses: actions/upload-artifact@v4 + with: + name: coverage + path: coverage.out + + 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 diff --git a/.gitignore b/.gitignore index de29b96..3961f0a 100644 --- a/.gitignore +++ b/.gitignore @@ -54,10 +54,12 @@ dist-ssr .env* !config.yaml.example docker-compose.*.yml +!backend/tests/smoke/docker-compose.test.yml data/ meta/ garage.toml +!backend/tests/smoke/garage.toml backend/docs/ config.yaml diff --git a/Makefile b/Makefile index 283c7d1..f964be9 100644 --- a/Makefile +++ b/Makefile @@ -135,3 +135,22 @@ install: update: prod-pull prod-restart .DEFAULT_GOAL := help + +.PHONY: test test-race test-cover test-smoke + +## test: Run backend unit tests +test: + cd backend && go test ./... + +## test-race: Run backend unit tests with the race detector +test-race: + cd backend && go test -race ./... + +## test-cover: Run backend unit tests with coverage and enforce the coverage gate +test-cover: + cd backend && go test -coverprofile=../coverage.out -coverpkg=./... ./... + bash scripts/coverage-gate.sh coverage.out + +## test-smoke: Run the docker-compose smoke test (requires Docker + compose v2) +test-smoke: + cd backend && go test -tags=smoke -timeout 10m ./tests/smoke/... diff --git a/backend/go.mod b/backend/go.mod index 816d81f..469491e 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -3,75 +3,82 @@ module Noooste/garage-ui go 1.25.3 require ( - github.com/Noooste/azuretls-client v1.12.11 + github.com/Noooste/azuretls-client v1.13.2 github.com/Noooste/swagger v1.2.0 - github.com/coreos/go-oidc/v3 v3.17.0 + github.com/coreos/go-oidc/v3 v3.18.0 github.com/gofiber/fiber/v3 v3.1.0 github.com/golang-jwt/jwt/v5 v5.3.1 - github.com/minio/minio-go/v7 v7.0.98 - github.com/rs/zerolog v1.34.0 + github.com/google/uuid v1.6.0 + github.com/minio/minio-go/v7 v7.0.100 + github.com/rs/zerolog v1.35.0 github.com/spf13/viper v1.21.0 - golang.org/x/oauth2 v0.35.0 + github.com/swaggo/swag v1.16.6 + golang.org/x/oauth2 v0.36.0 ) require ( github.com/KyleBanks/depth v1.2.1 // indirect github.com/Noooste/fhttp v1.0.15 // indirect github.com/Noooste/go-socks4 v0.0.2 // indirect - github.com/Noooste/uquic-go v1.0.3 // indirect - github.com/Noooste/utls v1.3.20 // indirect + github.com/Noooste/uquic-go v1.0.5 // indirect + github.com/Noooste/utls v1.3.21 // indirect github.com/Noooste/websocket v1.0.3 // indirect - github.com/andybalholm/brotli v1.2.0 // indirect + github.com/andybalholm/brotli v1.2.1 // indirect github.com/bdandy/go-errors v1.2.2 // indirect - github.com/cloudflare/circl v1.6.1 // indirect + github.com/cloudflare/circl v1.6.3 // indirect github.com/dustin/go-humanize v1.0.1 // indirect - github.com/fatih/color v1.18.0 // indirect + github.com/fatih/color v1.19.0 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/gaukas/clienthellod v0.4.2 // indirect github.com/gaukas/godicttls v0.0.4 // indirect github.com/go-ini/ini v1.67.0 // indirect - github.com/go-jose/go-jose/v4 v4.1.3 // indirect - github.com/go-openapi/jsonpointer v0.21.0 // indirect - github.com/go-openapi/jsonreference v0.21.0 // indirect - github.com/go-openapi/spec v0.21.0 // indirect - github.com/go-openapi/swag v0.23.0 // indirect - github.com/go-viper/mapstructure/v2 v2.4.0 // indirect - github.com/gofiber/schema v1.7.0 // indirect - github.com/gofiber/utils/v2 v2.0.2 // indirect + github.com/go-jose/go-jose/v4 v4.1.4 // indirect + github.com/go-openapi/jsonpointer v0.23.1 // indirect + github.com/go-openapi/jsonreference v0.21.5 // indirect + github.com/go-openapi/spec v0.22.4 // indirect + github.com/go-openapi/swag v0.26.0 // indirect + github.com/go-openapi/swag/conv v0.26.0 // indirect + github.com/go-openapi/swag/jsonname v0.26.0 // indirect + github.com/go-openapi/swag/jsonutils v0.26.0 // indirect + github.com/go-openapi/swag/loading v0.26.0 // indirect + github.com/go-openapi/swag/stringutils v0.26.0 // indirect + github.com/go-openapi/swag/typeutils v0.26.0 // indirect + github.com/go-openapi/swag/yamlutils v0.26.0 // indirect + github.com/go-viper/mapstructure/v2 v2.5.0 // indirect + github.com/gofiber/schema v1.7.1 // indirect + github.com/gofiber/utils/v2 v2.0.3 // indirect github.com/google/gopacket v1.1.19 // indirect - github.com/google/uuid v1.6.0 // indirect github.com/josharian/intern v1.0.0 // indirect - github.com/klauspost/compress v1.18.4 // indirect - github.com/klauspost/cpuid/v2 v2.2.11 // indirect + github.com/klauspost/compress v1.18.5 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/klauspost/crc32 v1.3.0 // indirect - github.com/mailru/easyjson v0.9.0 // indirect + github.com/mailru/easyjson v0.9.2 // indirect github.com/mattn/go-colorable v0.1.14 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-isatty v0.0.21 // indirect github.com/minio/crc64nvme v1.1.1 // indirect github.com/minio/md5-simd v1.1.2 // indirect - github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/pelletier/go-toml/v2 v2.3.0 // indirect github.com/philhofer/fwd v1.2.0 // indirect github.com/quic-go/qpack v0.6.0 // indirect - github.com/refraction-networking/utls v1.8.1 // indirect + github.com/refraction-networking/utls v1.8.2 // indirect github.com/rs/xid v1.6.0 // indirect - github.com/sagikazarmark/locafero v0.11.0 // indirect + github.com/sagikazarmark/locafero v0.12.0 // indirect github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect github.com/spf13/afero v1.15.0 // indirect github.com/spf13/cast v1.10.0 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/swaggo/files/v2 v2.0.2 // indirect - github.com/swaggo/swag v1.16.6 // indirect - github.com/tinylib/msgp v1.6.3 // indirect + github.com/tinylib/msgp v1.6.4 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect - github.com/valyala/fasthttp v1.69.0 // indirect + github.com/valyala/fasthttp v1.70.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.48.0 // indirect - golang.org/x/mod v0.32.0 // indirect - golang.org/x/net v0.50.0 // indirect - golang.org/x/sync v0.19.0 // indirect - golang.org/x/sys v0.41.0 // indirect - golang.org/x/text v0.34.0 // indirect - golang.org/x/tools v0.41.0 // indirect + golang.org/x/crypto v0.50.0 // indirect + golang.org/x/mod v0.35.0 // indirect + golang.org/x/net v0.53.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.43.0 // indirect + golang.org/x/text v0.36.0 // indirect + golang.org/x/tools v0.44.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/backend/go.sum b/backend/go.sum index b619526..069425a 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -4,6 +4,8 @@ github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1 github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/Noooste/azuretls-client v1.12.11 h1:8IvtfPf+K6wOqiRROL/APGkxQCO/+jyjH0S39rnItfQ= github.com/Noooste/azuretls-client v1.12.11/go.mod h1:lvXW8wpaOwrwtDrSt8nv/Dd8NAbCMVNRoU4sFrAaxYs= +github.com/Noooste/azuretls-client v1.13.2 h1:8Dli5aKP5O6qN/FNSGFVkpQ1V1F1gGawAkLIE2Nrk+U= +github.com/Noooste/azuretls-client v1.13.2/go.mod h1:ON+SmiBm4Zy5vAhJmBNZk61Y7nqf4iM/b1MC1lN47Bk= github.com/Noooste/fhttp v1.0.15 h1:sYRWOKgr1x4L+wA6REMJCs4Z/lFOSJmuQHSIXMXCcPs= github.com/Noooste/fhttp v1.0.15/go.mod h1:YZtq+i2M11Y22UiOR6gjNSLMNLiPhURh6M44oFVQ1TE= github.com/Noooste/go-socks4 v0.0.2 h1:DwHCYiCEAdjfNrQOFIid7qgKCll7ubhGS1ji5O8FYng= @@ -12,18 +14,28 @@ github.com/Noooste/swagger v1.2.0 h1:zGHin8k2V9mXDB1gxXOdKe4V8zhw79ycsw+/L2hH/pk github.com/Noooste/swagger v1.2.0/go.mod h1:5N+iUZlFA43k2Paf42EZ+SFndBG1niSA1FAnwiNP1PM= github.com/Noooste/uquic-go v1.0.3 h1:VP8npQmU4lkVLm9Ug5Q18SJ8ExFDfUZIzd13YjYaLHE= github.com/Noooste/uquic-go v1.0.3/go.mod h1:MxkrvgpNcbIOSQxqglC3e/798O/6zuL3mBhlFN+04w4= +github.com/Noooste/uquic-go v1.0.5 h1:HWfrxhxgB1a9Y2Au5mfFs2Y5Dy13OQIwa86D/kULPtE= +github.com/Noooste/uquic-go v1.0.5/go.mod h1:1y+qiy23PqLKudi4kQiJ0b3zXXYcyctEBRfZPTuyBz4= github.com/Noooste/utls v1.3.20 h1:QzBNGGJ184bNMLodOzvM9YWc4vZ36QodIjqFQOHoZ88= github.com/Noooste/utls v1.3.20/go.mod h1:XEy+VEbTxmH6krfSG5YT7wDbjHTEi2zUXTG33R0PAAg= +github.com/Noooste/utls v1.3.21 h1:5yEzTibikzF0/d0REfbjXURGHxJDKCrRghVAU/OQBko= +github.com/Noooste/utls v1.3.21/go.mod h1:XEy+VEbTxmH6krfSG5YT7wDbjHTEi2zUXTG33R0PAAg= github.com/Noooste/websocket v1.0.3 h1:drW7tvZ3YqzqI9wApnaH1Q0syFMXO7gbLlsBWjZvMNA= github.com/Noooste/websocket v1.0.3/go.mod h1:Qhw0Rtuju/fPPbcb3R5XGq7poa51qPDL462jTltl9nQ= github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro= +github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/bdandy/go-errors v1.2.2 h1:WdFv/oukjTJCLa79UfkGmwX7ZxONAihKu4V0mLIs11Q= github.com/bdandy/go-errors v1.2.2/go.mod h1:NkYHl4Fey9oRRdbB1CoC6e84tuqQHiqrOcZpqFEkBxM= github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= +github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= +github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc= github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8= +github.com/coreos/go-oidc/v3 v3.18.0 h1:V9orjXynvu5wiC9SemFTWnG4F45v403aIcjWo0d41+A= +github.com/coreos/go-oidc/v3 v3.18.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -31,12 +43,15 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= +github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/fxamacker/cbor/v2 v2.9.1 h1:2rWm8B193Ll4VdjsJY28jxs70IdDsHRWgQYAI80+rMQ= github.com/gaukas/clienthellod v0.4.2 h1:LPJ+LSeqt99pqeCV4C0cllk+pyWmERisP7w6qWr7eqE= github.com/gaukas/clienthellod v0.4.2/go.mod h1:M57+dsu0ZScvmdnNxaxsDPM46WhSEdPYAOdNgfL7IKA= github.com/gaukas/godicttls v0.0.4 h1:NlRaXb3J6hAnTmWdsEKb9bcSBD6BvcIjdGdeb0zfXbk= @@ -45,28 +60,58 @@ github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A= github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= +github.com/go-openapi/jsonpointer v0.23.1 h1:1HBACs7XIwR2RcmItfdSFlALhGbe6S92p0ry4d1GWg4= +github.com/go-openapi/jsonpointer v0.23.1/go.mod h1:iWRmZTrGn7XwYhtPt/fvdSFj1OfNBngqRT2UG3BxSqY= github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4= +github.com/go-openapi/jsonreference v0.21.5 h1:6uCGVXU/aNF13AQNggxfysJ+5ZcU4nEAe+pJyVWRdiE= +github.com/go-openapi/jsonreference v0.21.5/go.mod h1:u25Bw85sX4E2jzFodh1FOKMTZLcfifd1Q+iKKOUxExw= github.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9ZY= github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk= +github.com/go-openapi/spec v0.22.4 h1:4pxGjipMKu0FzFiu/DPwN3CTBRlVM2yLf/YTWorYfDQ= +github.com/go-openapi/spec v0.22.4/go.mod h1:WQ6Ai0VPWMZgMT4XySjlRIE6GP1bGQOtEThn3gcWLtQ= github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= +github.com/go-openapi/swag v0.26.0 h1:GVDXCmfvhfu1BxiHo8/FA+BbKmhecHnG3varjON5/RI= +github.com/go-openapi/swag v0.26.0/go.mod h1:82g3193sZJRbocs7bNCqGfIgq8pkuwVwCfhKIRlEQF0= +github.com/go-openapi/swag/conv v0.26.0 h1:5yGGsPYI1ZCva93U0AoKi/iZrNhaJEjr324YVsiD89I= +github.com/go-openapi/swag/conv v0.26.0/go.mod h1:tpAmIL7X58VPnHHiSO4uE3jBeRamGsFsfdDeDtb5ECE= +github.com/go-openapi/swag/jsonname v0.26.0 h1:gV1NFX9M8avo0YSpmWogqfQISigCmpaiNci8cGECU5w= +github.com/go-openapi/swag/jsonname v0.26.0/go.mod h1:urBBR8bZNoDYGr653ynhIx+gTeIz0ARZxHkAPktJK2M= +github.com/go-openapi/swag/jsonutils v0.26.0 h1:FawFML2iAXsPqmERscuMPIHmFsoP1tOqWkxBaKNMsnA= +github.com/go-openapi/swag/jsonutils v0.26.0/go.mod h1:2VmA0CJlyFqgawOaPI9psnjFDqzyivIqLYN34t9p91E= +github.com/go-openapi/swag/loading v0.26.0 h1:Apg6zaKhCJurpJer0DCxq99qwmhFddBhaMX7kilDcko= +github.com/go-openapi/swag/loading v0.26.0/go.mod h1:dBxQ/6V2uBaAQdevN18VELE6xSpJWZxLX4txe12JwDg= +github.com/go-openapi/swag/stringutils v0.26.0 h1:qZQngLxs5s7SLijc3N2ZO+fUq2o8LjuWAASSrJuh+xg= +github.com/go-openapi/swag/stringutils v0.26.0/go.mod h1:sWn5uY+QIIspwPhvgnqJsH8xqFT2ZbYcvbcFanRyhFE= +github.com/go-openapi/swag/typeutils v0.26.0 h1:2kdEwdiNWy+JJdOvu5MA2IIg2SylWAFuuyQIKYybfq4= +github.com/go-openapi/swag/typeutils v0.26.0/go.mod h1:oovDuIUvTrEHVMqWilQzKzV4YlSKgyZmFh7AlfABNVE= +github.com/go-openapi/swag/yamlutils v0.26.0 h1:H7O8l/8NJJQ/oiReEN+oMpnGMyt8G0hl460nRZxhLMQ= +github.com/go-openapi/swag/yamlutils v0.26.0/go.mod h1:1evKEGAtP37Pkwcc7EWMF0hedX0/x3Rkvei2wtG/TbU= github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= +github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gofiber/fiber/v3 v3.1.0 h1:1p4I820pIa+FGxfwWuQZ5rAyX0WlGZbGT6Hnuxt6hKY= github.com/gofiber/fiber/v3 v3.1.0/go.mod h1:n2nYQovvL9z3Too/FGOfgtERjW3GQcAUqgfoezGBZdU= github.com/gofiber/schema v1.7.0 h1:yNM+FNRZjyYEli9Ey0AXRBrAY9jTnb+kmGs3lJGPvKg= github.com/gofiber/schema v1.7.0/go.mod h1:A/X5Ffyru4p9eBdp99qu+nzviHzQiZ7odLT+TwxWhbk= +github.com/gofiber/schema v1.7.1 h1:oSJBKdgP8JeIME4TQSAqlNKTU2iBB+2RNmKi8Nsc+TI= +github.com/gofiber/schema v1.7.1/go.mod h1:A/X5Ffyru4p9eBdp99qu+nzviHzQiZ7odLT+TwxWhbk= github.com/gofiber/utils/v2 v2.0.2 h1:ShRRssz0F3AhTlAQcuEj54OEDtWF7+HJDwEi/aa6QLI= github.com/gofiber/utils/v2 v2.0.2/go.mod h1:+9Ub4NqQ+IaJoTliq5LfdmOJAA/Hzwf4pXOxOa3RrJ0= +github.com/gofiber/utils/v2 v2.0.3 h1:qJyfS/t7s7Z4+/zlU1i1pafYNP2+xLupVPgkW8ce1uI= +github.com/gofiber/utils/v2 v2.0.3/go.mod h1:GGERKU3Vhj5z6hS8YKvxL99A54DjOvTFZ0cjZnG4Lj4= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= @@ -81,9 +126,13 @@ github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8Hm github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= +github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4OxRzU= github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM= github.com/klauspost/crc32 v1.3.0/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -92,6 +141,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= +github.com/mailru/easyjson v0.9.2 h1:dX8U45hQsZpxd80nLvDGihsQ/OxlvTkVUXH2r/8cb2M= +github.com/mailru/easyjson v0.9.2/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= @@ -99,18 +150,24 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/ github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs= +github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI= github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg= github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34= github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM= github.com/minio/minio-go/v7 v7.0.98 h1:MeAVKjLVz+XJ28zFcuYyImNSAh8Mq725uNW4beRisi0= github.com/minio/minio-go/v7 v7.0.98/go.mod h1:cY0Y+W7yozf0mdIclrttzo1Iiu7mEf9y7nk2uXqMOvM= +github.com/minio/minio-go/v7 v7.0.100 h1:ShkWi8Tyj9RtU57OQB2HIXKz4bFgtVib0bbT1sbtLI8= +github.com/minio/minio-go/v7 v7.0.100/go.mod h1:EtGNKtlX20iL2yaYnxEigaIvj0G0GwSDnifnG8ClIdw= github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns= github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A= github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM= +github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= @@ -120,14 +177,21 @@ github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= github.com/refraction-networking/utls v1.8.1 h1:yNY1kapmQU8JeM1sSw2H2asfTIwWxIkrMJI0pRUOCAo= github.com/refraction-networking/utls v1.8.1/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM= +github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo= +github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= +github.com/rs/zerolog v1.35.0 h1:VD0ykx7HMiMJytqINBsKcbLS+BJ4WYjz+05us+LRTdI= +github.com/rs/zerolog v1.35.0/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw= github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= +github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4= +github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI= github.com/shamaton/msgpack/v3 v3.1.0 h1:jsk0vEAqVvvS9+fTZ5/EcQ9tz860c9pWxJ4Iwecz8gU= github.com/shamaton/msgpack/v3 v3.1.0/go.mod h1:DcQG8jrdrQCIxr3HlMYkiXdMhK+KfN2CitkyzsQV4uc= github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= @@ -150,10 +214,14 @@ github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI= github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg= github.com/tinylib/msgp v1.6.3 h1:bCSxiTz386UTgyT1i0MSCvdbWjVW+8sG3PjkGsZQt4s= github.com/tinylib/msgp v1.6.3/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= +github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ= +github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZyVI= github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw= +github.com/valyala/fasthttp v1.70.0 h1:LAhMGcWk13QZWm85+eg8ZBNbrq5mnkWFGbHMUJHIdXA= +github.com/valyala/fasthttp v1.70.0/go.mod h1:oDZEHHkJ/Buyklg6uURmYs19442zFSnCIfX3j1FY3pE= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= @@ -166,21 +234,31 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 h1:y5zboxd6LQAqYIhHnB48p0ByQ/GnQx2BE33L8BOHQkI= golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6/go.mod h1:U6Lno4MTRCDY+Ba7aCcauB9T60gsv5s4ralQzP72ZoQ= golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= +golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= +golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= +golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= +golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -188,16 +266,23 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= +golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= +golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/backend/internal/auth/auth.go b/backend/internal/auth/auth.go index de6f02b..6b01f8e 100644 --- a/backend/internal/auth/auth.go +++ b/backend/internal/auth/auth.go @@ -1,6 +1,7 @@ package auth import ( + "Noooste/garage-ui/pkg/logger" "context" "crypto/subtle" "encoding/base64" @@ -107,35 +108,6 @@ func (a *Service) ValidateBasicAuth(username, password string) bool { return usernameMatch && passwordMatch } -// ParseBasicAuth parses the Authorization header for basic auth -func ParseBasicAuth(authHeader string) (username, password string, ok bool) { - if authHeader == "" { - return "", "", false - } - - // Check if it's a Basic auth header - const prefix = "Basic " - if !strings.HasPrefix(authHeader, prefix) { - return "", "", false - } - - // Decode base64 credentials - encoded := authHeader[len(prefix):] - decoded, err := base64.StdEncoding.DecodeString(encoded) - if err != nil { - return "", "", false - } - - // Split username:password - credentials := string(decoded) - parts := strings.SplitN(credentials, ":", 2) - if len(parts) != 2 { - return "", "", false - } - - return parts[0], parts[1], true -} - // GetAuthorizationURL returns the OIDC authorization URL for login func (a *Service) GetAuthorizationURL(state string) (string, error) { if a.oauth2Config == nil { @@ -213,6 +185,8 @@ func (a *Service) GetUserInfo(ctx context.Context, token *oauth2.Token) (*UserIn return nil, fmt.Errorf("failed to parse user info claims: %w", err) } + logger.Debug().Interface("claims", claims).Msg("Extracted user info claims") + // Build user info userInfo := &UserInfo{ Username: extractClaim(claims, a.authConfig.OIDC.UsernameAttribute), diff --git a/backend/internal/auth/auth_test.go b/backend/internal/auth/auth_test.go index 542ab68..a923eca 100644 --- a/backend/internal/auth/auth_test.go +++ b/backend/internal/auth/auth_test.go @@ -3,6 +3,9 @@ package auth import ( "encoding/base64" "encoding/json" + "net/http" + "net/http/httptest" + "strings" "testing" "Noooste/garage-ui/internal/config" @@ -96,3 +99,458 @@ func TestGenerateSessionToken_ZeroSessionMaxAge_IsNotImmediatelyExpired(t *testi t.Fatalf("freshly issued admin session token failed validation: %v", err) } } + +// --------------------------------------------------------------------------- +// Task 5: ValidateBasicAuth +// (ParseBasicAuth was removed from production in commit d0040be; nothing to test.) +// --------------------------------------------------------------------------- + +func TestValidateBasicAuth(t *testing.T) { + svc := &Service{ + authConfig: &config.AuthConfig{ + Admin: config.AdminAuthConfig{ + Enabled: true, + Username: "admin", + Password: "correct-horse", + }, + }, + } + + tests := []struct { + name string + user string + pass string + want bool + }{ + {"correct credentials", "admin", "correct-horse", true}, + {"wrong password", "admin", "nope", false}, + {"wrong username", "root", "correct-horse", false}, + {"both wrong", "x", "y", false}, + {"empty username", "", "correct-horse", false}, + {"empty password", "admin", "", false}, + {"both empty", "", "", false}, + {"username prefix attack", "admi", "correct-horse", false}, + {"password prefix attack", "admin", "correct-hors", false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := svc.ValidateBasicAuth(tc.user, tc.pass); got != tc.want { + t.Errorf("ValidateBasicAuth(%q,%q) = %v, want %v", tc.user, tc.pass, got, tc.want) + } + }) + } +} + +func TestValidateBasicAuth_AdminDisabledStillComparesAgainstEmpty(t *testing.T) { + // When admin is disabled, the configured username/password are typically + // empty strings. ValidateBasicAuth itself does not gate on Enabled (that + // happens in middleware). Pin that behavior so a future refactor can't + // silently change semantics. + svc := &Service{ + authConfig: &config.AuthConfig{ + Admin: config.AdminAuthConfig{Enabled: false}, + }, + } + if !svc.ValidateBasicAuth("", "") { + t.Error("empty creds should match empty configured creds") + } + if svc.ValidateBasicAuth("anything", "") { + t.Error("non-empty user should not match empty configured user") + } +} + +// --------------------------------------------------------------------------- +// Task 6: OIDC initOIDC cases +// --------------------------------------------------------------------------- + +// newDiscoveryServer returns an httptest.Server that serves a minimal but +// valid OIDC discovery document and a JWKS endpoint (empty key set is fine +// for init — we are not verifying any token here). The discovery document's +// `issuer` field MUST equal the server's URL or oidc.NewProvider rejects it. +func newDiscoveryServer(t *testing.T) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + var srv *httptest.Server + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) { + doc := map[string]any{ + "issuer": srv.URL, + "authorization_endpoint": srv.URL + "/auth", + "token_endpoint": srv.URL + "/token", + "jwks_uri": srv.URL + "/jwks", + "userinfo_endpoint": srv.URL + "/userinfo", + "id_token_signing_alg_values_supported": []string{"RS256", "EdDSA"}, + "response_types_supported": []string{"code"}, + "subject_types_supported": []string{"public"}, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(doc) + }) + mux.HandleFunc("/jwks", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"keys":[]}`)) + }) + srv = httptest.NewServer(mux) + t.Cleanup(srv.Close) + return srv +} + +func TestNewAuthService_OIDCDisabled_DoesNotInitProvider(t *testing.T) { + svc, err := NewAuthService( + &config.AuthConfig{ + Admin: config.AdminAuthConfig{Enabled: true, Username: "u", Password: "p"}, + OIDC: config.OIDCConfig{Enabled: false}, + }, + &config.ServerConfig{}, + ) + if err != nil { + t.Fatalf("NewAuthService: %v", err) + } + if svc.oidcProvider != nil { + t.Error("oidcProvider should be nil when OIDC disabled") + } + if svc.oidcVerifier != nil { + t.Error("oidcVerifier should be nil when OIDC disabled") + } + if svc.oauth2Config != nil { + t.Error("oauth2Config should be nil when OIDC disabled") + } + if svc.jwtService == nil { + t.Error("jwtService must always be initialized") + } +} + +func TestNewAuthService_OIDCEnabled_DiscoversProvider(t *testing.T) { + disco := newDiscoveryServer(t) + + authCfg := &config.AuthConfig{ + OIDC: config.OIDCConfig{ + Enabled: true, + ClientID: "test-client", + IssuerURL: disco.URL, + Scopes: []string{"openid", "profile"}, + AdminRole: "admin", + }, + } + srvCfg := &config.ServerConfig{ + RootURL: "https://garage-ui.example", + } + + svc, err := NewAuthService(authCfg, srvCfg) + if err != nil { + t.Fatalf("NewAuthService: %v", err) + } + if svc.oidcProvider == nil { + t.Fatal("oidcProvider not initialized") + } + if svc.oidcVerifier == nil { + t.Fatal("oidcVerifier not initialized") + } + if svc.oauth2Config == nil { + t.Fatal("oauth2Config not initialized") + } + if svc.oauth2Config.ClientID != "test-client" { + t.Errorf("ClientID = %q, want test-client", svc.oauth2Config.ClientID) + } + wantRedirect := "https://garage-ui.example/auth/oidc/callback" + if svc.oauth2Config.RedirectURL != wantRedirect { + t.Errorf("RedirectURL = %q, want %q", svc.oauth2Config.RedirectURL, wantRedirect) + } + if len(svc.oauth2Config.Scopes) != 2 { + t.Errorf("Scopes length = %d, want 2", len(svc.oauth2Config.Scopes)) + } + // Endpoint should be wired from the discovery doc. + if svc.oauth2Config.Endpoint.AuthURL != disco.URL+"/auth" { + t.Errorf("Endpoint.AuthURL = %q", svc.oauth2Config.Endpoint.AuthURL) + } + if svc.oauth2Config.Endpoint.TokenURL != disco.URL+"/token" { + t.Errorf("Endpoint.TokenURL = %q", svc.oauth2Config.Endpoint.TokenURL) + } +} + +func TestNewAuthService_OIDCEnabled_BadIssuerURLReturnsError(t *testing.T) { + authCfg := &config.AuthConfig{ + OIDC: config.OIDCConfig{ + Enabled: true, + ClientID: "test-client", + IssuerURL: "http://127.0.0.1:1", // refused — nothing listens on port 1 + Scopes: []string{"openid"}, + AdminRole: "admin", + }, + } + srvCfg := &config.ServerConfig{RootURL: "https://garage-ui.example"} + + _, err := NewAuthService(authCfg, srvCfg) + if err == nil { + t.Fatal("expected error for unreachable issuer, got nil") + } + if !strings.Contains(err.Error(), "failed to initialize OIDC") { + t.Errorf("expected wrapping error, got %v", err) + } +} + +func TestGetAuthorizationURL_OIDCDisabledReturnsError(t *testing.T) { + svc := &Service{authConfig: &config.AuthConfig{}, serverConfig: &config.ServerConfig{}} + if _, err := svc.GetAuthorizationURL("state-x"); err == nil { + t.Error("expected error when OIDC not initialized") + } +} + +func TestGetAuthorizationURL_OIDCEnabledIncludesState(t *testing.T) { + disco := newDiscoveryServer(t) + svc, err := NewAuthService( + &config.AuthConfig{ + OIDC: config.OIDCConfig{ + Enabled: true, + ClientID: "test-client", + IssuerURL: disco.URL, + Scopes: []string{"openid"}, + AdminRole: "admin", + }, + }, + &config.ServerConfig{RootURL: "https://garage-ui.example"}, + ) + if err != nil { + t.Fatalf("NewAuthService: %v", err) + } + + url, err := svc.GetAuthorizationURL("my-state-token") + if err != nil { + t.Fatalf("GetAuthorizationURL: %v", err) + } + if !strings.Contains(url, "state=my-state-token") { + t.Errorf("URL missing state param: %s", url) + } + if !strings.Contains(url, "client_id=test-client") { + t.Errorf("URL missing client_id: %s", url) + } + if !strings.Contains(url, "redirect_uri=") { + t.Errorf("URL missing redirect_uri: %s", url) + } +} + +// --------------------------------------------------------------------------- +// Task 7: ValidateSessionToken and expanded ExtractRolesFromAccessToken +// --------------------------------------------------------------------------- + +// newServiceWithJWT wires a Service with a real JWTService so the session +// helpers can be exercised end-to-end. OIDC is left disabled. +func newServiceWithJWT(t *testing.T) *Service { + t.Helper() + jwtSvc, err := NewJWTService() + if err != nil { + t.Fatalf("NewJWTService: %v", err) + } + return &Service{ + authConfig: &config.AuthConfig{}, + serverConfig: &config.ServerConfig{}, + jwtService: jwtSvc, + } +} + +func TestValidateSessionToken_HappyPath(t *testing.T) { + svc := newServiceWithJWT(t) + user := &UserInfo{ + Username: "alice", + Email: "alice@example.com", + Name: "Alice", + Roles: []string{"admin"}, + } + tok, err := svc.GenerateSessionToken(user) + if err != nil { + t.Fatalf("GenerateSessionToken: %v", err) + } + got, err := svc.ValidateSessionToken(tok) + if err != nil { + t.Fatalf("ValidateSessionToken: %v", err) + } + if got.Username != user.Username || got.Email != user.Email || got.Name != user.Name { + t.Errorf("got %+v, want %+v", got, user) + } + if len(got.Roles) != 1 || got.Roles[0] != "admin" { + t.Errorf("Roles = %v, want [admin]", got.Roles) + } +} + +func TestValidateSessionToken_Expired(t *testing.T) { + svc := newServiceWithJWT(t) + // Bypass GenerateSessionToken's "fall back to 24h on non-positive" guard + // by going straight through the JWT service with a negative TTL. + tok, err := svc.jwtService.GenerateToken(&UserInfo{Username: "a"}, -1) + if err != nil { + t.Fatalf("GenerateToken: %v", err) + } + if _, err := svc.ValidateSessionToken(tok); err == nil { + t.Error("expected expired-token error, got nil") + } +} + +func TestValidateSessionToken_BadSignatureRejected(t *testing.T) { + signer := newServiceWithJWT(t) + verifier := newServiceWithJWT(t) + tok, err := signer.GenerateSessionToken(&UserInfo{Username: "a"}) + if err != nil { + t.Fatalf("GenerateSessionToken: %v", err) + } + if _, err := verifier.ValidateSessionToken(tok); err == nil { + t.Error("expected signature-mismatch error, got nil") + } +} + +func TestValidateSessionToken_EmptyTokenRejected(t *testing.T) { + svc := newServiceWithJWT(t) + if _, err := svc.ValidateSessionToken(""); err == nil { + t.Error("expected error for empty token, got nil") + } +} + +// makeAccessToken builds a JWT-shaped string with arbitrary claims. The +// signature segment is junk because ExtractRolesFromAccessToken does NOT +// verify the signature (per the doc-comment: "obtained via a verified code +// exchange, so parsing without re-verifying is safe"). +func makeAccessToken(t *testing.T, claims map[string]any) string { + t.Helper() + raw, err := json.Marshal(claims) + if err != nil { + t.Fatalf("marshal: %v", err) + } + return "header." + base64.RawURLEncoding.EncodeToString(raw) + ".sig" +} + +func TestExtractRolesFromAccessToken_DeeplyNestedPath(t *testing.T) { + tok := makeAccessToken(t, map[string]any{ + "a": map[string]any{ + "b": map[string]any{ + "c": map[string]any{ + "roles": []any{"r1", "r2", "r3"}, + }, + }, + }, + }) + svc := &Service{ + authConfig: &config.AuthConfig{ + OIDC: config.OIDCConfig{RoleAttributePath: "a.b.c.roles"}, + }, + } + got := svc.ExtractRolesFromAccessToken(tok) + if len(got) != 3 || got[0] != "r1" || got[2] != "r3" { + t.Errorf("got %v, want [r1 r2 r3]", got) + } +} + +func TestExtractRolesFromAccessToken_MixedTypeArrayDropsNonStrings(t *testing.T) { + tok := makeAccessToken(t, map[string]any{ + "roles": []any{"admin", 42, "viewer", true, nil, "writer"}, + }) + svc := &Service{ + authConfig: &config.AuthConfig{ + OIDC: config.OIDCConfig{RoleAttributePath: "roles"}, + }, + } + got := svc.ExtractRolesFromAccessToken(tok) + want := []string{"admin", "viewer", "writer"} + if len(got) != len(want) { + t.Fatalf("got %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("got[%d] = %q, want %q", i, got[i], want[i]) + } + } +} + +func TestExtractRolesFromAccessToken_EmptyPathReturnsNil(t *testing.T) { + tok := makeAccessToken(t, map[string]any{"roles": []any{"admin"}}) + svc := &Service{ + authConfig: &config.AuthConfig{OIDC: config.OIDCConfig{RoleAttributePath: ""}}, + } + if got := svc.ExtractRolesFromAccessToken(tok); got != nil { + t.Errorf("expected nil for empty path, got %v", got) + } +} + +func TestExtractRolesFromAccessToken_IntermediateNodeNotMap(t *testing.T) { + // Path tries to descend through a string — extractRoles must bail with nil. + tok := makeAccessToken(t, map[string]any{ + "resource_access": "this-should-be-a-map", + }) + svc := &Service{ + authConfig: &config.AuthConfig{ + OIDC: config.OIDCConfig{RoleAttributePath: "resource_access.client.roles"}, + }, + } + if got := svc.ExtractRolesFromAccessToken(tok); got != nil { + t.Errorf("expected nil when path traverses non-map, got %v", got) + } +} + +func TestExtractRolesFromAccessToken_FinalValueWrongType(t *testing.T) { + // Final value is a plain string, not an array — extractStringArray returns nil. + tok := makeAccessToken(t, map[string]any{ + "roles": "admin", + }) + svc := &Service{ + authConfig: &config.AuthConfig{ + OIDC: config.OIDCConfig{RoleAttributePath: "roles"}, + }, + } + if got := svc.ExtractRolesFromAccessToken(tok); got != nil { + t.Errorf("expected nil for non-array roles, got %v", got) + } +} + +func TestExtractRolesFromAccessToken_BadBase64InPayload(t *testing.T) { + svc := &Service{ + authConfig: &config.AuthConfig{ + OIDC: config.OIDCConfig{RoleAttributePath: "roles"}, + }, + } + if got := svc.ExtractRolesFromAccessToken("hdr.!!!not-base64!!!.sig"); got != nil { + t.Errorf("expected nil for bad base64, got %v", got) + } +} + +func TestExtractRolesFromAccessToken_BadJSONInPayload(t *testing.T) { + svc := &Service{ + authConfig: &config.AuthConfig{ + OIDC: config.OIDCConfig{RoleAttributePath: "roles"}, + }, + } + // Valid base64 of "not json" + tok := "hdr." + base64.RawURLEncoding.EncodeToString([]byte("not json")) + ".sig" + if got := svc.ExtractRolesFromAccessToken(tok); got != nil { + t.Errorf("expected nil for non-JSON payload, got %v", got) + } +} + +// --------------------------------------------------------------------------- +// Task 8: IsAdmin coverage +// --------------------------------------------------------------------------- + +func TestIsAdmin(t *testing.T) { + tests := []struct { + name string + adminRole string + userRoles []string + want bool + }{ + {"empty admin role config returns false", "", []string{"admin"}, false}, + {"user has admin role", "admin", []string{"viewer", "admin"}, true}, + {"user lacks admin role", "admin", []string{"viewer"}, false}, + {"user has no roles", "admin", nil, false}, + {"role match is exact (case-sensitive)", "admin", []string{"Admin"}, false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + svc := &Service{ + authConfig: &config.AuthConfig{ + OIDC: config.OIDCConfig{AdminRole: tc.adminRole}, + }, + } + if got := svc.IsAdmin(&UserInfo{Roles: tc.userRoles}); got != tc.want { + t.Errorf("IsAdmin = %v, want %v", got, tc.want) + } + }) + } +} diff --git a/backend/internal/auth/jwt_test.go b/backend/internal/auth/jwt_test.go new file mode 100644 index 0000000..38d5851 --- /dev/null +++ b/backend/internal/auth/jwt_test.go @@ -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) +} diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 0ee4df1..77baad6 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -64,9 +64,6 @@ type OIDCConfig struct { ClientSecret string `mapstructure:"client_secret"` Scopes []string `mapstructure:"scopes"` IssuerURL string `mapstructure:"issuer_url"` - AuthURL string `mapstructure:"auth_url"` - TokenURL string `mapstructure:"token_url"` - UserinfoURL string `mapstructure:"userinfo_url"` SkipIssuerCheck bool `mapstructure:"skip_issuer_check"` SkipExpiryCheck bool `mapstructure:"skip_expiry_check"` EmailAttribute string `mapstructure:"email_attribute"` @@ -175,9 +172,6 @@ func bindEnvVars() { viper.BindEnv("auth.oidc.client_secret", "GARAGE_UI_AUTH_OIDC_CLIENT_SECRET") viper.BindEnv("auth.oidc.scopes", "GARAGE_UI_AUTH_OIDC_SCOPES") viper.BindEnv("auth.oidc.issuer_url", "GARAGE_UI_AUTH_OIDC_ISSUER_URL") - viper.BindEnv("auth.oidc.auth_url", "GARAGE_UI_AUTH_OIDC_AUTH_URL") - viper.BindEnv("auth.oidc.token_url", "GARAGE_UI_AUTH_OIDC_TOKEN_URL") - viper.BindEnv("auth.oidc.userinfo_url", "GARAGE_UI_AUTH_OIDC_USERINFO_URL") viper.BindEnv("auth.oidc.skip_issuer_check", "GARAGE_UI_AUTH_OIDC_SKIP_ISSUER_CHECK") viper.BindEnv("auth.oidc.skip_expiry_check", "GARAGE_UI_AUTH_OIDC_SKIP_EXPIRY_CHECK") viper.BindEnv("auth.oidc.email_attribute", "GARAGE_UI_AUTH_OIDC_EMAIL_ATTRIBUTE") @@ -244,6 +238,13 @@ func (c *Config) Validate() error { if len(c.Auth.OIDC.Scopes) == 0 { return fmt.Errorf("oidc scopes are required when oidc is enabled") } + // Every authenticated route on this service grants full admin + // access — there is no separate authorization layer. An empty + // admin_role would therefore promote every user in the IdP realm + // to cluster admin. Require operators to opt in explicitly. + if c.Auth.OIDC.AdminRole == "" { + return fmt.Errorf("oidc admin_role is required when oidc is enabled: leaving it empty would grant cluster-admin access to any authenticated IdP user") + } } return nil diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go new file mode 100644 index 0000000..dfa3b35 --- /dev/null +++ b/backend/internal/config/config_test.go @@ -0,0 +1,396 @@ +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/spf13/viper" +) + +// writeConfigFile writes yaml content to a temp path and returns it. +func writeConfigFile(t *testing.T, yaml string) string { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "config.yaml") + if err := os.WriteFile(path, []byte(yaml), 0600); err != nil { + t.Fatalf("write config: %v", err) + } + return path +} + +// resetViper clears all global viper state between tests. +func resetViper(t *testing.T) { + t.Helper() + viper.Reset() +} + +// minimalValidYAML is the smallest configuration that passes Validate. +const minimalValidYAML = ` +server: + host: "0.0.0.0" + port: 8080 + environment: development +garage: + endpoint: http://garage:3900 + admin_endpoint: http://garage:3903 + admin_token: supersecret +` + +func TestLoad_YAMLOnly(t *testing.T) { + resetViper(t) + path := writeConfigFile(t, minimalValidYAML) + + cfg, err := Load(path) + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.Server.Host != "0.0.0.0" { + t.Errorf("Server.Host = %q, want 0.0.0.0", cfg.Server.Host) + } + if cfg.Server.Port != 8080 { + t.Errorf("Server.Port = %d, want 8080", cfg.Server.Port) + } + if cfg.Server.Environment != "development" { + t.Errorf("Server.Environment = %q, want development", cfg.Server.Environment) + } + if cfg.Garage.Endpoint != "http://garage:3900" { + t.Errorf("Garage.Endpoint = %q", cfg.Garage.Endpoint) + } + if cfg.Garage.AdminToken != "supersecret" { + t.Errorf("Garage.AdminToken = %q", cfg.Garage.AdminToken) + } +} + +func TestLoad_EnvOnly_MissingFile(t *testing.T) { + resetViper(t) + // Point at a path that definitely does not exist. Load tolerates missing + // files and falls back to env + viper defaults. + missing := filepath.Join(t.TempDir(), "does-not-exist.yaml") + + // Every required field provided via env. + t.Setenv("GARAGE_UI_SERVER_PORT", "9090") + t.Setenv("GARAGE_UI_GARAGE_ENDPOINT", "http://g:3900") + t.Setenv("GARAGE_UI_GARAGE_ADMIN_ENDPOINT", "http://g:3903") + t.Setenv("GARAGE_UI_GARAGE_ADMIN_TOKEN", "env-token") + + cfg, err := Load(missing) + if err != nil { + t.Fatalf("Load with env-only: %v", err) + } + if cfg.Server.Port != 9090 { + t.Errorf("Server.Port = %d, want 9090 (from env)", cfg.Server.Port) + } + if cfg.Garage.AdminToken != "env-token" { + t.Errorf("Garage.AdminToken = %q, want env-token", cfg.Garage.AdminToken) + } +} + +func TestLoad_EnvOverridesYAML(t *testing.T) { + resetViper(t) + path := writeConfigFile(t, minimalValidYAML) + + // YAML has port=8080; env should win. + t.Setenv("GARAGE_UI_SERVER_PORT", "9090") + t.Setenv("GARAGE_UI_GARAGE_ADMIN_TOKEN", "env-wins") + + cfg, err := Load(path) + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.Server.Port != 9090 { + t.Errorf("Server.Port = %d, want 9090 (env override)", cfg.Server.Port) + } + if cfg.Garage.AdminToken != "env-wins" { + t.Errorf("Garage.AdminToken = %q, want env-wins", cfg.Garage.AdminToken) + } + // Host was not overridden; YAML value should persist. + if cfg.Server.Host != "0.0.0.0" { + t.Errorf("Server.Host = %q, want 0.0.0.0 (from YAML)", cfg.Server.Host) + } +} + +func TestLoad_MalformedYAMLReturnsError(t *testing.T) { + resetViper(t) + // Deliberately broken YAML: unindented key after a mapping start. + path := writeConfigFile(t, "server:\n port: 8080\n:: not: valid ::\n") + + _, err := Load(path) + if err == nil { + t.Fatal("expected error for malformed YAML, got nil") + } + if !strings.Contains(err.Error(), "error reading config file") { + t.Errorf("unexpected error: %v", err) + } +} + +func TestLoad_ValidationFailurePropagates(t *testing.T) { + resetViper(t) + // Valid YAML syntax but Garage.Endpoint is blank → Validate must fail. + path := writeConfigFile(t, ` +server: + port: 8080 +garage: + endpoint: "" + admin_endpoint: http://g:3903 + admin_token: t +`) + + _, err := Load(path) + if err == nil { + t.Fatal("expected validation error, got nil") + } + if !strings.Contains(err.Error(), "invalid configuration") { + t.Errorf("expected wrapped invalid-config error, got %v", err) + } + if !strings.Contains(err.Error(), "garage endpoint is required") { + t.Errorf("expected endpoint-required message, got %v", err) + } +} + +// validBaseConfig returns a deep copy of a minimal Config that passes Validate. +func validBaseConfig() Config { + return Config{ + Server: ServerConfig{Port: 8080}, + Garage: GarageConfig{ + Endpoint: "http://g:3900", + AdminEndpoint: "http://g:3903", + AdminToken: "t", + }, + } +} + +// applyValidOIDC fills OIDC with all required fields. +func applyValidOIDC(c *Config) { + c.Auth.OIDC.Enabled = true + c.Auth.OIDC.ClientID = "client-xyz" + c.Auth.OIDC.IssuerURL = "https://idp.example/realms/test" + c.Auth.OIDC.Scopes = []string{"openid"} + c.Auth.OIDC.AdminRole = "admin" + c.Server.RootURL = "https://garage-ui.example" +} + +// Note on spec coverage: spec/2026-04-17-backend-test-suite-design.md lists +// "invalid log level/format" as a Validate case, but the current Validate does +// not check Logging.Level or Logging.Format. That's a code-vs-spec gap to +// resolve in a follow-up plan; Stage 2 tests the current behavior only. +func TestValidate(t *testing.T) { + tests := []struct { + name string + mutate func(*Config) + wantErrContains string // empty = expect no error + }{ + { + name: "valid minimal config", + mutate: func(c *Config) {}, + }, + { + name: "port zero is invalid", + mutate: func(c *Config) { c.Server.Port = 0 }, + wantErrContains: "invalid server port", + }, + { + name: "port negative is invalid", + mutate: func(c *Config) { c.Server.Port = -1 }, + wantErrContains: "invalid server port", + }, + { + name: "port above 65535 is invalid", + mutate: func(c *Config) { c.Server.Port = 70000 }, + wantErrContains: "invalid server port", + }, + { + name: "port at 65535 is valid", + mutate: func(c *Config) { c.Server.Port = 65535 }, + wantErrContains: "", + }, + { + name: "missing garage endpoint", + mutate: func(c *Config) { c.Garage.Endpoint = "" }, + wantErrContains: "garage endpoint is required", + }, + { + name: "missing garage admin_endpoint", + mutate: func(c *Config) { c.Garage.AdminEndpoint = "" }, + wantErrContains: "admin_endpoint is required", + }, + { + name: "missing garage admin_token", + mutate: func(c *Config) { c.Garage.AdminToken = "" }, + wantErrContains: "admin_token is required", + }, + { + name: "admin auth enabled without username", + mutate: func(c *Config) { + c.Auth.Admin.Enabled = true + c.Auth.Admin.Password = "p" + }, + wantErrContains: "admin auth username and password are required", + }, + { + name: "admin auth enabled without password", + mutate: func(c *Config) { + c.Auth.Admin.Enabled = true + c.Auth.Admin.Username = "u" + }, + wantErrContains: "admin auth username and password are required", + }, + { + name: "admin auth enabled with both set is valid", + mutate: func(c *Config) { + c.Auth.Admin.Enabled = true + c.Auth.Admin.Username = "u" + c.Auth.Admin.Password = "p" + }, + wantErrContains: "", + }, + { + name: "admin auth disabled ignores missing credentials", + mutate: func(c *Config) { + c.Auth.Admin.Enabled = false + c.Auth.Admin.Username = "" + c.Auth.Admin.Password = "" + }, + wantErrContains: "", + }, + { + name: "oidc enabled without client_id", + mutate: func(c *Config) { + applyValidOIDC(c) + c.Auth.OIDC.ClientID = "" + }, + wantErrContains: "oidc client_id is required", + }, + { + name: "oidc enabled without issuer_url", + mutate: func(c *Config) { + applyValidOIDC(c) + c.Auth.OIDC.IssuerURL = "" + }, + wantErrContains: "oidc issuer_url is required", + }, + { + name: "oidc enabled without server.root_url", + mutate: func(c *Config) { + applyValidOIDC(c) + c.Server.RootURL = "" + }, + wantErrContains: "server.root_url is required", + }, + { + name: "oidc enabled without scopes", + mutate: func(c *Config) { + applyValidOIDC(c) + c.Auth.OIDC.Scopes = nil + }, + wantErrContains: "oidc scopes are required", + }, + { + name: "oidc enabled without admin_role rejected for safety", + mutate: func(c *Config) { + applyValidOIDC(c) + c.Auth.OIDC.AdminRole = "" + }, + wantErrContains: "oidc admin_role is required", + }, + { + name: "oidc fully configured is valid", + mutate: applyValidOIDC, + wantErrContains: "", + }, + { + name: "oidc disabled ignores missing client_id", + mutate: func(c *Config) { + c.Auth.OIDC.Enabled = false + c.Auth.OIDC.ClientID = "" + }, + wantErrContains: "", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + cfg := validBaseConfig() + tc.mutate(&cfg) + err := cfg.Validate() + + if tc.wantErrContains == "" { + if err != nil { + t.Errorf("expected no error, got %v", err) + } + return + } + + if err == nil { + t.Fatalf("expected error containing %q, got nil", tc.wantErrContains) + } + if !strings.Contains(err.Error(), tc.wantErrContains) { + t.Errorf("error %q does not contain %q", err.Error(), tc.wantErrContains) + } + }) + } +} + +func TestGetAddress(t *testing.T) { + tests := []struct { + host string + port int + want string + }{ + {"localhost", 8080, "localhost:8080"}, + {"0.0.0.0", 80, "0.0.0.0:80"}, + {"", 443, ":443"}, + } + for _, tc := range tests { + t.Run(tc.want, func(t *testing.T) { + cfg := &Config{Server: ServerConfig{Host: tc.host, Port: tc.port}} + if got := cfg.GetAddress(); got != tc.want { + t.Errorf("GetAddress() = %q, want %q", got, tc.want) + } + }) + } +} + +func TestIsDevelopment(t *testing.T) { + tests := []struct { + env string + want bool + }{ + {"development", true}, + {"production", false}, + {"", false}, + // Case-sensitive per current impl; lock in that behavior. + {"Development", false}, + {"DEV", false}, + } + for _, tc := range tests { + t.Run(tc.env, func(t *testing.T) { + cfg := &Config{Server: ServerConfig{Environment: tc.env}} + if got := cfg.IsDevelopment(); got != tc.want { + t.Errorf("IsDevelopment(%q) = %v, want %v", tc.env, got, tc.want) + } + }) + } +} + +func TestIsProduction(t *testing.T) { + tests := []struct { + env string + want bool + }{ + {"production", true}, + {"development", false}, + {"", false}, + {"Production", false}, + {"PROD", false}, + } + for _, tc := range tests { + t.Run(tc.env, func(t *testing.T) { + cfg := &Config{Server: ServerConfig{Environment: tc.env}} + if got := cfg.IsProduction(); got != tc.want { + t.Errorf("IsProduction(%q) = %v, want %v", tc.env, got, tc.want) + } + }) + } +} diff --git a/backend/internal/handlers/auth_test.go b/backend/internal/handlers/auth_test.go new file mode 100644 index 0000000..66b0188 --- /dev/null +++ b/backend/internal/handlers/auth_test.go @@ -0,0 +1,332 @@ +package handlers + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "Noooste/garage-ui/internal/auth" + "Noooste/garage-ui/internal/config" + + "github.com/gofiber/fiber/v3" +) + +// newAuthTestService builds a real auth.Service with OIDC disabled. The JWT +// key is auto-generated, matching the production default. +func newAuthTestService(t *testing.T, admin config.AdminAuthConfig) *auth.Service { + t.Helper() + svc, err := auth.NewAuthService( + &config.AuthConfig{ + Admin: admin, + OIDC: config.OIDCConfig{Enabled: false}, + }, + &config.ServerConfig{}, + ) + if err != nil { + t.Fatalf("NewAuthService: %v", err) + } + return svc +} + +// newAuthTestApp builds a bare Fiber app with the auth handler mounted. +// The admin and OIDC config are reflected both in cfg (for the handler) and +// in the auth service. +func newAuthTestApp(t *testing.T, cfg *config.Config) (*fiber.App, *AuthHandler) { + t.Helper() + svc := newAuthTestService(t, cfg.Auth.Admin) + h := NewAuthHandler(cfg, svc) + app := fiber.New() + app.Get("/auth/config", h.GetAuthConfig) + app.Post("/auth/login", h.LoginAdmin) + app.Get("/auth/me", h.GetMe) + return app, h +} + +func TestGetAuthConfig_AdminOnly(t *testing.T) { + cfg := &config.Config{ + Auth: config.AuthConfig{ + Admin: config.AdminAuthConfig{Enabled: true, Username: "admin", Password: "p"}, + OIDC: config.OIDCConfig{Enabled: false}, + }, + } + app, _ := newAuthTestApp(t, cfg) + req := httptest.NewRequest(http.MethodGet, "/auth/config", nil) + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200", resp.StatusCode) + } + var body struct { + Admin struct { + Enabled bool `json:"enabled"` + } `json:"admin"` + OIDC struct { + Enabled bool `json:"enabled"` + Provider string `json:"provider"` + } `json:"oidc"` + } + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + t.Fatalf("decode: %v", err) + } + if !body.Admin.Enabled { + t.Error("admin.enabled = false, want true") + } + if body.OIDC.Enabled { + t.Error("oidc.enabled = true, want false") + } + if body.OIDC.Provider != "" { + t.Errorf("oidc.provider = %q, want empty", body.OIDC.Provider) + } +} + +func TestGetAuthConfig_OIDCOnly_WithExplicitProvider(t *testing.T) { + cfg := &config.Config{ + Auth: config.AuthConfig{ + Admin: config.AdminAuthConfig{Enabled: false}, + OIDC: config.OIDCConfig{ + Enabled: false, // service init skipped (newAuthTestService disables OIDC); handler only reads flags + ProviderName: "Keycloak", + }, + }, + } + // Re-enable OIDC only on the cfg the handler sees — the service is still + // constructed with OIDC disabled above, which is fine because + // GetAuthConfig does not touch the service at all. + cfg.Auth.OIDC.Enabled = true + app, _ := newAuthTestApp(t, cfg) + + req := httptest.NewRequest(http.MethodGet, "/auth/config", nil) + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200", resp.StatusCode) + } + var body struct { + OIDC struct { + Enabled bool `json:"enabled"` + Provider string `json:"provider"` + } `json:"oidc"` + } + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + t.Fatalf("decode: %v", err) + } + if !body.OIDC.Enabled { + t.Error("oidc.enabled = false, want true") + } + if body.OIDC.Provider != "Keycloak" { + t.Errorf("oidc.provider = %q, want Keycloak", body.OIDC.Provider) + } +} + +func TestGetAuthConfig_OIDCEnabled_DefaultProviderName(t *testing.T) { + cfg := &config.Config{ + Auth: config.AuthConfig{ + Admin: config.AdminAuthConfig{Enabled: false}, + OIDC: config.OIDCConfig{Enabled: true, ProviderName: ""}, + }, + } + app, _ := newAuthTestApp(t, cfg) + req := httptest.NewRequest(http.MethodGet, "/auth/config", nil) + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + var body struct { + OIDC struct { + Provider string `json:"provider"` + } `json:"oidc"` + } + _ = json.NewDecoder(resp.Body).Decode(&body) + if body.OIDC.Provider != "OIDC Provider" { + t.Errorf("provider = %q, want default 'OIDC Provider'", body.OIDC.Provider) + } +} + +func TestLoginAdmin_HappyPath(t *testing.T) { + cfg := &config.Config{ + Auth: config.AuthConfig{ + Admin: config.AdminAuthConfig{Enabled: true, Username: "admin", Password: "s3cret"}, + }, + } + app, _ := newAuthTestApp(t, cfg) + + body, _ := json.Marshal(map[string]string{"username": "admin", "password": "s3cret"}) + req := httptest.NewRequest(http.MethodPost, "/auth/login", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + raw, _ := io.ReadAll(resp.Body) + t.Fatalf("status = %d, want 200\nbody: %s", resp.StatusCode, raw) + } + + var decoded struct { + Success bool `json:"success"` + Token string `json:"token"` + User struct { + Username string `json:"username"` + } `json:"user"` + } + if err := json.NewDecoder(resp.Body).Decode(&decoded); err != nil { + t.Fatalf("decode: %v", err) + } + if !decoded.Success { + t.Error("success = false") + } + if decoded.Token == "" { + t.Error("token empty") + } + if decoded.User.Username != "admin" { + t.Errorf("username = %q, want admin", decoded.User.Username) + } +} + +func TestLoginAdmin_WrongPasswordReturns401(t *testing.T) { + cfg := &config.Config{ + Auth: config.AuthConfig{Admin: config.AdminAuthConfig{Enabled: true, Username: "admin", Password: "s3cret"}}, + } + app, _ := newAuthTestApp(t, cfg) + body, _ := json.Marshal(map[string]string{"username": "admin", "password": "WRONG"}) + req := httptest.NewRequest(http.MethodPost, "/auth/login", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", resp.StatusCode) + } +} + +func TestLoginAdmin_WrongUsernameReturns401(t *testing.T) { + cfg := &config.Config{ + Auth: config.AuthConfig{Admin: config.AdminAuthConfig{Enabled: true, Username: "admin", Password: "s3cret"}}, + } + app, _ := newAuthTestApp(t, cfg) + body, _ := json.Marshal(map[string]string{"username": "root", "password": "s3cret"}) + req := httptest.NewRequest(http.MethodPost, "/auth/login", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", resp.StatusCode) + } +} + +func TestLoginAdmin_MalformedJSONReturns400(t *testing.T) { + cfg := &config.Config{ + Auth: config.AuthConfig{Admin: config.AdminAuthConfig{Enabled: true, Username: "admin", Password: "p"}}, + } + app, _ := newAuthTestApp(t, cfg) + req := httptest.NewRequest(http.MethodPost, "/auth/login", strings.NewReader("{not-json")) + req.Header.Set("Content-Type", "application/json") + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", resp.StatusCode) + } +} + +func TestGetMe_OIDCUserInfoLocal(t *testing.T) { + cfg := &config.Config{Auth: config.AuthConfig{}} + app, h := newAuthTestApp(t, cfg) + // Re-register /auth/me with a pre-handler that seeds c.Locals("userInfo"). + // The default registration in newAuthTestApp lacks Locals; we mount a + // second path that does. + app.Get("/me-oidc", func(c fiber.Ctx) error { + c.Locals("userInfo", &auth.UserInfo{ + Username: "alice", + Email: "alice@example.com", + Name: "Alice Example", + }) + return h.GetMe(c) + }) + + req := httptest.NewRequest(http.MethodGet, "/me-oidc", nil) + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200", resp.StatusCode) + } + var decoded struct { + Success bool `json:"success"` + User struct { + Username string `json:"username"` + Email string `json:"email"` + Name string `json:"name"` + } `json:"user"` + } + if err := json.NewDecoder(resp.Body).Decode(&decoded); err != nil { + t.Fatalf("decode: %v", err) + } + if decoded.User.Username != "alice" || decoded.User.Email != "alice@example.com" || decoded.User.Name != "Alice Example" { + t.Errorf("got %+v", decoded.User) + } +} + +func TestGetMe_BasicAuthUsernameLocal(t *testing.T) { + cfg := &config.Config{Auth: config.AuthConfig{}} + app, h := newAuthTestApp(t, cfg) + app.Get("/me-basic", func(c fiber.Ctx) error { + c.Locals("username", "admin") + return h.GetMe(c) + }) + req := httptest.NewRequest(http.MethodGet, "/me-basic", nil) + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200", resp.StatusCode) + } + var decoded struct { + User struct { + Username string `json:"username"` + } `json:"user"` + } + _ = json.NewDecoder(resp.Body).Decode(&decoded) + if decoded.User.Username != "admin" { + t.Errorf("username = %q, want admin", decoded.User.Username) + } +} + +func TestGetMe_NoLocalsReturns401(t *testing.T) { + cfg := &config.Config{Auth: config.AuthConfig{}} + app, _ := newAuthTestApp(t, cfg) + req := httptest.NewRequest(http.MethodGet, "/auth/me", nil) + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", resp.StatusCode) + } +} diff --git a/backend/internal/handlers/buckets.go b/backend/internal/handlers/buckets.go index 3947a71..1765594 100644 --- a/backend/internal/handlers/buckets.go +++ b/backend/internal/handlers/buckets.go @@ -7,14 +7,14 @@ import ( "github.com/gofiber/fiber/v3" ) -// BucketHandler handles bucket-related operations +// BucketHandler handles bucket-related HTTP requests. type BucketHandler struct { - adminService *services.GarageAdminService - s3Service *services.S3Service + adminService services.AdminService + s3Service services.S3Storage } -// NewBucketHandler creates a new bucket handler -func NewBucketHandler(adminService *services.GarageAdminService, s3Service *services.S3Service) *BucketHandler { +// NewBucketHandler creates a new bucket handler. +func NewBucketHandler(adminService services.AdminService, s3Service services.S3Storage) *BucketHandler { return &BucketHandler{ adminService: adminService, s3Service: s3Service, diff --git a/backend/internal/handlers/buckets_test.go b/backend/internal/handlers/buckets_test.go new file mode 100644 index 0000000..7ff3c37 --- /dev/null +++ b/backend/internal/handlers/buckets_test.go @@ -0,0 +1,419 @@ +package handlers + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "Noooste/garage-ui/internal/models" + "Noooste/garage-ui/internal/services/mocks" + + "github.com/gofiber/fiber/v3" +) + +func newBucketsTestApp(t *testing.T) (*fiber.App, *mocks.AdminMock) { + t.Helper() + admin := &mocks.AdminMock{} + h := NewBucketHandler(admin, nil) // s3 unused in this handler + app := fiber.New() + app.Get("/buckets", h.ListBuckets) + app.Post("/buckets", h.CreateBucket) + app.Get("/buckets/:name", h.GetBucketInfo) + app.Delete("/buckets/:name", h.DeleteBucket) + app.Post("/buckets/:name/permissions", h.GrantBucketPermission) + app.Put("/buckets/:name/website", h.UpdateBucketWebsite) + return app, admin +} + +func decodeJSON(t *testing.T, r io.Reader, v any) { + t.Helper() + if err := json.NewDecoder(r).Decode(v); err != nil { + t.Fatalf("decode: %v", err) + } +} + +// --- ListBuckets --- + +func TestListBuckets_MapsAliasesAndStats(t *testing.T) { + app, admin := newBucketsTestApp(t) + admin.ListBucketsFn = func(_ context.Context) ([]models.ListBucketsResponseItem, error) { + return []models.ListBucketsResponseItem{ + {ID: "id-1", Created: time.Unix(0, 0), GlobalAliases: []string{"alpha"}}, + {ID: "id-2", Created: time.Unix(0, 0), GlobalAliases: []string{}}, // skipped: no global alias + {ID: "id-3", Created: time.Unix(0, 0), GlobalAliases: []string{"gamma"}}, + }, nil + } + admin.GetBucketInfoByAliasFn = func(_ context.Context, alias string) (*models.GarageBucketInfo, error) { + switch alias { + case "alpha": + return &models.GarageBucketInfo{ID: "id-1", Objects: 10, Bytes: 100, WebsiteAccess: true}, nil + case "gamma": + return nil, errors.New("detail fetch failed") // degraded path + } + return nil, errors.New("unexpected alias: " + alias) + } + req := httptest.NewRequest(http.MethodGet, "/buckets", nil) + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d", resp.StatusCode) + } + var body struct { + Data models.BucketListResponse `json:"data"` + } + decodeJSON(t, resp.Body, &body) + if body.Data.Count != 2 { + t.Errorf("count = %d, want 2 (id-2 skipped)", body.Data.Count) + } + // alpha has stats; gamma degraded to no stats (ObjectCount/Size nil). + var foundAlpha, foundGamma bool + for _, b := range body.Data.Buckets { + if b.Name == "alpha" { + foundAlpha = true + if b.ObjectCount == nil || *b.ObjectCount != 10 { + t.Errorf("alpha.ObjectCount = %v, want 10", b.ObjectCount) + } + if !b.WebsiteAccess { + t.Error("alpha.WebsiteAccess false") + } + } + if b.Name == "gamma" { + foundGamma = true + if b.ObjectCount != nil { + t.Errorf("gamma.ObjectCount = %v, want nil (degraded)", *b.ObjectCount) + } + } + } + if !foundAlpha || !foundGamma { + t.Errorf("missing buckets: alpha=%v gamma=%v", foundAlpha, foundGamma) + } +} + +func TestListBuckets_AdminErrorReturns500(t *testing.T) { + app, admin := newBucketsTestApp(t) + admin.ListBucketsFn = func(_ context.Context) ([]models.ListBucketsResponseItem, error) { + return nil, errors.New("boom") + } + resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets", nil)) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500", resp.StatusCode) + } +} + +// --- CreateBucket --- + +func TestCreateBucket_Success201(t *testing.T) { + app, admin := newBucketsTestApp(t) + admin.CreateBucketFn = func(_ context.Context, r models.CreateBucketAdminRequest) (*models.GarageBucketInfo, error) { + if r.GlobalAlias == nil || *r.GlobalAlias != "new-bucket" { + t.Errorf("GlobalAlias = %v, want 'new-bucket'", r.GlobalAlias) + } + return &models.GarageBucketInfo{ID: "id-new"}, nil + } + body, _ := json.Marshal(map[string]string{"name": "new-bucket"}) + req := httptest.NewRequest(http.MethodPost, "/buckets", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusCreated { + t.Fatalf("status = %d, want 201", resp.StatusCode) + } +} + +func TestCreateBucket_MissingNameReturns400(t *testing.T) { + app, _ := newBucketsTestApp(t) + body, _ := json.Marshal(map[string]string{}) + req := httptest.NewRequest(http.MethodPost, "/buckets", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", resp.StatusCode) + } +} + +func TestCreateBucket_MalformedJSONReturns400(t *testing.T) { + app, _ := newBucketsTestApp(t) + req := httptest.NewRequest(http.MethodPost, "/buckets", strings.NewReader("{not-json")) + req.Header.Set("Content-Type", "application/json") + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", resp.StatusCode) + } +} + +func TestCreateBucket_AdminErrorReturns500(t *testing.T) { + app, admin := newBucketsTestApp(t) + admin.CreateBucketFn = func(_ context.Context, _ models.CreateBucketAdminRequest) (*models.GarageBucketInfo, error) { + return nil, errors.New("boom") + } + body, _ := json.Marshal(map[string]string{"name": "x"}) + req := httptest.NewRequest(http.MethodPost, "/buckets", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500", resp.StatusCode) + } +} + +// --- GetBucketInfo --- + +func TestGetBucketInfo_Success(t *testing.T) { + app, admin := newBucketsTestApp(t) + admin.GetBucketInfoByAliasFn = func(_ context.Context, alias string) (*models.GarageBucketInfo, error) { + return &models.GarageBucketInfo{ID: "id-1", Bytes: 1, Objects: 1}, nil + } + resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/alpha", nil)) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200", resp.StatusCode) + } +} + +func TestGetBucketInfo_NotFound404(t *testing.T) { + app, admin := newBucketsTestApp(t) + admin.GetBucketInfoByAliasFn = func(_ context.Context, _ string) (*models.GarageBucketInfo, error) { + return nil, nil // nil pointer, nil error → 404 + } + resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/missing", nil)) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("status = %d, want 404", resp.StatusCode) + } +} + +func TestGetBucketInfo_ServiceErrorReturns500(t *testing.T) { + app, admin := newBucketsTestApp(t) + admin.GetBucketInfoByAliasFn = func(_ context.Context, _ string) (*models.GarageBucketInfo, error) { + return nil, errors.New("boom") + } + resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/alpha", nil)) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500", resp.StatusCode) + } +} + +// --- DeleteBucket --- + +func TestDeleteBucket_Success(t *testing.T) { + app, admin := newBucketsTestApp(t) + admin.GetBucketInfoByAliasFn = func(_ context.Context, _ string) (*models.GarageBucketInfo, error) { + return &models.GarageBucketInfo{ID: "id-1"}, nil + } + admin.DeleteBucketFn = func(_ context.Context, id string) error { + if id != "id-1" { + t.Errorf("DeleteBucket id = %q, want id-1", id) + } + return nil + } + resp, err := app.Test(httptest.NewRequest(http.MethodDelete, "/buckets/alpha", nil)) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200", resp.StatusCode) + } +} + +func TestDeleteBucket_NotFound(t *testing.T) { + app, admin := newBucketsTestApp(t) + admin.GetBucketInfoByAliasFn = func(_ context.Context, _ string) (*models.GarageBucketInfo, error) { + return nil, nil + } + resp, err := app.Test(httptest.NewRequest(http.MethodDelete, "/buckets/missing", nil)) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("status = %d, want 404", resp.StatusCode) + } +} + +func TestDeleteBucket_AdminDeleteErrorReturns500(t *testing.T) { + app, admin := newBucketsTestApp(t) + admin.GetBucketInfoByAliasFn = func(_ context.Context, _ string) (*models.GarageBucketInfo, error) { + return &models.GarageBucketInfo{ID: "id-1"}, nil + } + admin.DeleteBucketFn = func(_ context.Context, _ string) error { return errors.New("boom") } + resp, err := app.Test(httptest.NewRequest(http.MethodDelete, "/buckets/alpha", nil)) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500", resp.StatusCode) + } +} + +// --- GrantBucketPermission --- + +func TestGrantBucketPermission_Success(t *testing.T) { + app, admin := newBucketsTestApp(t) + admin.GetBucketInfoByAliasFn = func(_ context.Context, _ string) (*models.GarageBucketInfo, error) { + return &models.GarageBucketInfo{ID: "id-1"}, nil + } + admin.AllowBucketKeyFn = func(_ context.Context, req models.BucketKeyPermRequest) (*models.GarageBucketInfo, error) { + if req.BucketID != "id-1" || req.AccessKeyID != "AKIA" { + t.Errorf("req = %+v", req) + } + if !req.Permissions.Read || !req.Permissions.Write || req.Permissions.Owner { + t.Errorf("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) + } +} diff --git a/backend/internal/handlers/cluster.go b/backend/internal/handlers/cluster.go index 3ea6371..6c13b44 100644 --- a/backend/internal/handlers/cluster.go +++ b/backend/internal/handlers/cluster.go @@ -7,13 +7,13 @@ import ( "github.com/gofiber/fiber/v3" ) -// ClusterHandler handles cluster management operations +// ClusterHandler handles cluster-status HTTP requests. type ClusterHandler struct { - adminService *services.GarageAdminService + adminService services.AdminService } -// NewClusterHandler creates a new cluster handler -func NewClusterHandler(adminService *services.GarageAdminService) *ClusterHandler { +// NewClusterHandler creates a new cluster handler. +func NewClusterHandler(adminService services.AdminService) *ClusterHandler { return &ClusterHandler{ adminService: adminService, } diff --git a/backend/internal/handlers/cluster_test.go b/backend/internal/handlers/cluster_test.go new file mode 100644 index 0000000..5e04ff6 --- /dev/null +++ b/backend/internal/handlers/cluster_test.go @@ -0,0 +1,176 @@ +package handlers + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "Noooste/garage-ui/internal/models" + "Noooste/garage-ui/internal/services/mocks" + + "github.com/gofiber/fiber/v3" +) + +func newClusterTestApp(t *testing.T) (*fiber.App, *mocks.AdminMock) { + t.Helper() + admin := &mocks.AdminMock{} + h := NewClusterHandler(admin) + app := fiber.New() + app.Get("/cluster/health", h.GetHealth) + app.Get("/cluster/status", h.GetStatus) + app.Get("/cluster/statistics", h.GetStatistics) + app.Get("/cluster/nodes/:node_id", h.GetNodeInfo) + app.Get("/cluster/nodes/:node_id/statistics", h.GetNodeStatistics) + // Extra routes without a node_id param so we can exercise the empty-id gate. + // Fiber requires a bound param value, so we mount a path with an empty + // trailing segment explicitly via Locals. + app.Get("/cluster/nodes-empty", func(c fiber.Ctx) error { + // Set empty node_id in locals via a route that doesn't capture it. + return h.GetNodeInfo(c) + }) + return app, admin +} + +func doGet(t *testing.T, app *fiber.App, path string) *http.Response { + t.Helper() + req := httptest.NewRequest(http.MethodGet, path, nil) + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test %s: %v", path, err) + } + return resp +} + +func TestCluster_GetHealth_Success(t *testing.T) { + app, admin := newClusterTestApp(t) + admin.GetClusterHealthFn = func(_ context.Context) (*models.ClusterHealth, error) { + return &models.ClusterHealth{Status: "healthy"}, nil + } + resp := doGet(t, app, "/cluster/health") + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200", resp.StatusCode) + } + var body struct { + Success bool `json:"success"` + Data models.ClusterHealth `json:"data"` + } + _ = json.NewDecoder(resp.Body).Decode(&body) + if !body.Success || body.Data.Status != "healthy" { + t.Errorf("body = %+v", body) + } +} + +func TestCluster_GetHealth_ServiceErrorReturns500(t *testing.T) { + app, admin := newClusterTestApp(t) + admin.GetClusterHealthFn = func(_ context.Context) (*models.ClusterHealth, error) { + return nil, errors.New("upstream down") + } + resp := doGet(t, app, "/cluster/health") + defer resp.Body.Close() + if resp.StatusCode != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500", resp.StatusCode) + } +} + +func TestCluster_GetStatus_Success(t *testing.T) { + app, admin := newClusterTestApp(t) + admin.GetClusterStatusFn = func(_ context.Context) (*models.ClusterStatus, error) { + return &models.ClusterStatus{}, nil + } + resp := doGet(t, app, "/cluster/status") + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d", resp.StatusCode) + } +} + +func TestCluster_GetStatus_ServiceErrorReturns500(t *testing.T) { + app, admin := newClusterTestApp(t) + admin.GetClusterStatusFn = func(_ context.Context) (*models.ClusterStatus, error) { + return nil, errors.New("boom") + } + resp := doGet(t, app, "/cluster/status") + defer resp.Body.Close() + if resp.StatusCode != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500", resp.StatusCode) + } +} + +func TestCluster_GetStatistics_Success(t *testing.T) { + app, admin := newClusterTestApp(t) + admin.GetClusterStatisticsFn = func(_ context.Context) (*models.ClusterStatistics, error) { + return &models.ClusterStatistics{}, nil + } + resp := doGet(t, app, "/cluster/statistics") + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d", resp.StatusCode) + } +} + +func TestCluster_GetStatistics_ServiceErrorReturns500(t *testing.T) { + app, admin := newClusterTestApp(t) + admin.GetClusterStatisticsFn = func(_ context.Context) (*models.ClusterStatistics, error) { + return nil, errors.New("boom") + } + resp := doGet(t, app, "/cluster/statistics") + defer resp.Body.Close() + if resp.StatusCode != http.StatusInternalServerError { + t.Fatalf("status = %d", resp.StatusCode) + } +} + +func TestCluster_GetNodeInfo_Success(t *testing.T) { + app, admin := newClusterTestApp(t) + admin.GetNodeInfoFn = func(_ context.Context, nodeID string) (*models.MultiNodeResponse, error) { + if nodeID != "node-1" { + t.Errorf("nodeID = %q, want node-1", nodeID) + } + return &models.MultiNodeResponse{}, nil + } + resp := doGet(t, app, "/cluster/nodes/node-1") + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d", resp.StatusCode) + } +} + +func TestCluster_GetNodeInfo_ServiceErrorReturns500(t *testing.T) { + app, admin := newClusterTestApp(t) + admin.GetNodeInfoFn = func(_ context.Context, _ string) (*models.MultiNodeResponse, error) { + return nil, errors.New("boom") + } + resp := doGet(t, app, "/cluster/nodes/n1") + defer resp.Body.Close() + if resp.StatusCode != http.StatusInternalServerError { + t.Fatalf("status = %d", resp.StatusCode) + } +} + +func TestCluster_GetNodeStatistics_Success(t *testing.T) { + app, admin := newClusterTestApp(t) + admin.GetNodeStatisticsFn = func(_ context.Context, nodeID string) (*models.MultiNodeResponse, error) { + return &models.MultiNodeResponse{}, nil + } + resp := doGet(t, app, "/cluster/nodes/n1/statistics") + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d", resp.StatusCode) + } +} + +func TestCluster_GetNodeStatistics_ServiceErrorReturns500(t *testing.T) { + app, admin := newClusterTestApp(t) + admin.GetNodeStatisticsFn = func(_ context.Context, _ string) (*models.MultiNodeResponse, error) { + return nil, errors.New("boom") + } + resp := doGet(t, app, "/cluster/nodes/n1/statistics") + defer resp.Body.Close() + if resp.StatusCode != http.StatusInternalServerError { + t.Fatalf("status = %d", resp.StatusCode) + } +} diff --git a/backend/internal/handlers/health_test.go b/backend/internal/handlers/health_test.go new file mode 100644 index 0000000..425853d --- /dev/null +++ b/backend/internal/handlers/health_test.go @@ -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) + } +} diff --git a/backend/internal/handlers/monitoring.go b/backend/internal/handlers/monitoring.go index 4f793ae..cc5d352 100644 --- a/backend/internal/handlers/monitoring.go +++ b/backend/internal/handlers/monitoring.go @@ -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, diff --git a/backend/internal/handlers/monitoring_test.go b/backend/internal/handlers/monitoring_test.go new file mode 100644 index 0000000..7aa35a2 --- /dev/null +++ b/backend/internal/handlers/monitoring_test.go @@ -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) + } +} diff --git a/backend/internal/handlers/objects.go b/backend/internal/handlers/objects.go index 9aa3562..8375089 100644 --- a/backend/internal/handlers/objects.go +++ b/backend/internal/handlers/objects.go @@ -3,7 +3,10 @@ package handlers import ( "bufio" "io" + "net/url" + "path" "strconv" + "strings" "time" "Noooste/garage-ui/internal/models" @@ -12,13 +15,66 @@ import ( "github.com/gofiber/fiber/v3" ) -// ObjectHandler handles object-related operations -type ObjectHandler struct { - s3Service *services.S3Service +// unsafeInlineContentTypes are MIME types that a browser can execute as +// JavaScript in the response's origin when rendered inline. Since the SPA is +// served from the same origin as the API, any uploader could otherwise plant +// stored XSS by uploading a file with one of these Content-Types. +var unsafeInlineContentTypes = map[string]struct{}{ + "text/html": {}, + "application/xhtml+xml": {}, + "image/svg+xml": {}, + "application/xml": {}, + "text/xml": {}, + "application/javascript": {}, + "text/javascript": {}, } -// NewObjectHandler creates a new object handler -func NewObjectHandler(s3Service *services.S3Service) *ObjectHandler { +// safeContentType rewrites Content-Types that the browser would treat as +// executable to application/octet-stream. +func safeContentType(ct string) string { + base := strings.TrimSpace(strings.ToLower(ct)) + if i := strings.IndexByte(base, ';'); i >= 0 { + base = strings.TrimSpace(base[:i]) + } + if _, bad := unsafeInlineContentTypes[base]; bad { + return "application/octet-stream" + } + return ct +} + +// contentDispositionHeader builds an RFC 6266 / RFC 5987 Content-Disposition +// header value with the user-controlled object key safely encoded. Strips +// path components and control characters before emitting the ASCII fallback, +// then appends the percent-encoded UTF-8 filename*= for full fidelity. +func contentDispositionHeader(disposition, key string) string { + name := path.Base(key) + if name == "." || name == "/" || name == "" { + name = "download" + } + // ASCII-safe fallback: drop anything that could break the quoted value. + var asciiFallback strings.Builder + for _, r := range name { + if r < 0x20 || r == 0x7f || r == '"' || r == '\\' || r > 0x7e { + asciiFallback.WriteByte('_') + continue + } + asciiFallback.WriteRune(r) + } + fallback := asciiFallback.String() + if fallback == "" { + fallback = "download" + } + encoded := url.PathEscape(name) + return disposition + "; filename=\"" + fallback + "\"; filename*=UTF-8''" + encoded +} + +// ObjectHandler handles object-related HTTP requests. +type ObjectHandler struct { + s3Service services.S3Storage +} + +// NewObjectHandler creates a new object handler. +func NewObjectHandler(s3Service services.S3Storage) *ObjectHandler { return &ObjectHandler{ s3Service: s3Service, } @@ -178,16 +234,22 @@ 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("Content-Length", strconv.FormatInt(objectInfo.Size, 10)) c.Set("ETag", objectInfo.ETag) c.Set("Last-Modified", objectInfo.LastModified.Format(time.RFC1123)) - // Check if client wants to download or view inline + // The object key is attacker-controlled — build the header via the safe + // RFC 6266 helper to avoid quote/semicolon injection into filename=. + disposition := "inline" if c.Query("download") == "true" { - c.Set("Content-Disposition", "attachment; filename=\""+key+"\"") + disposition = "attachment" } + c.Set("Content-Disposition", contentDispositionHeader(disposition, key)) // Stream the object body to the client without buffering the entire file return c.SendStreamWriter(func(w *bufio.Writer) { diff --git a/backend/internal/handlers/objects_helpers_test.go b/backend/internal/handlers/objects_helpers_test.go new file mode 100644 index 0000000..4811c36 --- /dev/null +++ b/backend/internal/handlers/objects_helpers_test.go @@ -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) + } + } + }) + } +} diff --git a/backend/internal/handlers/objects_test.go b/backend/internal/handlers/objects_test.go new file mode 100644 index 0000000..296c4a1 --- /dev/null +++ b/backend/internal/handlers/objects_test.go @@ -0,0 +1,768 @@ +package handlers + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "mime/multipart" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "Noooste/garage-ui/internal/models" + "Noooste/garage-ui/internal/services" + "Noooste/garage-ui/internal/services/mocks" + + "github.com/gofiber/fiber/v3" +) + +func newObjectsTestApp(t *testing.T) (*fiber.App, *mocks.S3Mock) { + t.Helper() + s3 := &mocks.S3Mock{} + h := NewObjectHandler(s3) + app := fiber.New() + app.Get("/buckets/:bucket/objects", h.ListObjects) + app.Post("/buckets/:bucket/objects", h.UploadObject) + app.Post("/buckets/:bucket/objects/upload-multiple", h.UploadMultipleObjects) + app.Post("/buckets/:bucket/objects/delete-multiple", h.DeleteMultipleObjects) + // Wildcard endpoints — mount under :key for tests. Handlers prefer + // c.Locals("objectKey") but fall back to c.Params("key"), so :key works. + app.Get("/buckets/:bucket/objects/:key", h.GetObject) + app.Get("/buckets/:bucket/objects/:key/metadata", h.GetObjectMetadata) + app.Get("/buckets/:bucket/objects/:key/presigned", h.GetPresignedURL) + app.Delete("/buckets/:bucket/objects/:key", h.DeleteObject) + return app, s3 +} + +// --- ListObjects --- + +func TestListObjects_Success(t *testing.T) { + app, s3 := newObjectsTestApp(t) + s3.ListObjectsFn = func(_ context.Context, bucket, prefix string, max int, tok string) (*models.ObjectListResponse, error) { + if bucket != "b1" || prefix != "p/" || max != 50 || tok != "T" { + t.Errorf("args = (%q, %q, %d, %q)", bucket, prefix, max, tok) + } + return &models.ObjectListResponse{ + Bucket: bucket, Count: 1, + Objects: []models.ObjectInfo{{Key: "k1", Size: 1}}, + }, nil + } + req := httptest.NewRequest(http.MethodGet, "/buckets/b1/objects?prefix=p/&max_keys=50&continuation_token=T", nil) + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d", resp.StatusCode) + } + var body struct { + Data models.ObjectListResponse `json:"data"` + } + decodeJSON(t, resp.Body, &body) + if body.Data.Count != 1 { + t.Errorf("count = %d", body.Data.Count) + } +} + +func TestListObjects_DefaultMaxKeys(t *testing.T) { + app, s3 := newObjectsTestApp(t) + s3.ListObjectsFn = func(_ context.Context, _, _ string, max int, _ string) (*models.ObjectListResponse, error) { + if max != 100 { + t.Errorf("max = %d, want default 100", max) + } + return &models.ObjectListResponse{}, nil + } + resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/objects", nil)) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d", resp.StatusCode) + } +} + +func TestListObjects_InvalidMaxKeys400(t *testing.T) { + app, _ := newObjectsTestApp(t) + cases := []string{"0", "-1", "abc"} + for _, mk := range cases { + t.Run(mk, func(t *testing.T) { + resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/objects?max_keys="+mk, nil)) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", resp.StatusCode) + } + }) + } +} + +func TestListObjects_ServiceError500(t *testing.T) { + app, s3 := newObjectsTestApp(t) + s3.ListObjectsFn = func(_ context.Context, _, _ string, _ int, _ string) (*models.ObjectListResponse, error) { + return nil, errors.New("boom") + } + resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/objects", nil)) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500", resp.StatusCode) + } +} + +// --- GetObjectMetadata --- + +func TestGetObjectMetadata_Success(t *testing.T) { + app, s3 := newObjectsTestApp(t) + s3.GetObjectMetadataFn = func(_ context.Context, b, k string) (*models.ObjectInfo, error) { + return &models.ObjectInfo{Key: k, Size: 42, ContentType: "image/png"}, nil + } + resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/objects/k1/metadata", nil)) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d", resp.StatusCode) + } + var body struct { + Data models.ObjectInfo `json:"data"` + } + decodeJSON(t, resp.Body, &body) + if body.Data.Size != 42 { + t.Errorf("size = %d", body.Data.Size) + } +} + +func TestGetObjectMetadata_NotFound404(t *testing.T) { + app, s3 := newObjectsTestApp(t) + s3.GetObjectMetadataFn = func(_ context.Context, _, _ string) (*models.ObjectInfo, error) { + return nil, errors.New("not found") + } + resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/objects/nope/metadata", nil)) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("status = %d, want 404", resp.StatusCode) + } +} + +// --- DeleteObject --- + +func TestDeleteObject_Success(t *testing.T) { + app, s3 := newObjectsTestApp(t) + s3.ObjectExistsFn = func(_ context.Context, _, _ string) (bool, error) { return true, nil } + s3.DeleteObjectFn = func(_ context.Context, b, k string) error { + if b != "b1" || k != "k1" { + t.Errorf("args = (%q, %q)", b, k) + } + return nil + } + resp, err := app.Test(httptest.NewRequest(http.MethodDelete, "/buckets/b1/objects/k1", nil)) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d", resp.StatusCode) + } +} + +func TestDeleteObject_NotExists404(t *testing.T) { + app, s3 := newObjectsTestApp(t) + s3.ObjectExistsFn = func(_ context.Context, _, _ string) (bool, error) { return false, nil } + resp, err := app.Test(httptest.NewRequest(http.MethodDelete, "/buckets/b1/objects/nope", nil)) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("status = %d, want 404", resp.StatusCode) + } +} + +func TestDeleteObject_ExistsCheckError500(t *testing.T) { + app, s3 := newObjectsTestApp(t) + s3.ObjectExistsFn = func(_ context.Context, _, _ string) (bool, error) { return false, errors.New("boom") } + resp, err := app.Test(httptest.NewRequest(http.MethodDelete, "/buckets/b1/objects/k1", nil)) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500", resp.StatusCode) + } +} + +func TestDeleteObject_DeleteError500(t *testing.T) { + app, s3 := newObjectsTestApp(t) + s3.ObjectExistsFn = func(_ context.Context, _, _ string) (bool, error) { return true, nil } + s3.DeleteObjectFn = func(_ context.Context, _, _ string) error { return errors.New("boom") } + resp, err := app.Test(httptest.NewRequest(http.MethodDelete, "/buckets/b1/objects/k1", nil)) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500", resp.StatusCode) + } +} + +// --- GetPresignedURL --- + +func TestGetPresignedURL_DefaultExpiration(t *testing.T) { + app, s3 := newObjectsTestApp(t) + s3.ObjectExistsFn = func(_ context.Context, _, _ string) (bool, error) { return true, nil } + s3.GetPresignedURLFn = func(_ context.Context, b, k string, exp time.Duration) (string, error) { + if exp != 3600*time.Second { + t.Errorf("exp = %v, want 1h", exp) + } + return "https://example/signed", nil + } + resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/objects/k1/presigned", nil)) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d", resp.StatusCode) + } + var body struct { + Data models.PresignedURLResponse `json:"data"` + } + decodeJSON(t, resp.Body, &body) + if body.Data.URL != "https://example/signed" || body.Data.ExpiresIn != 3600 { + t.Errorf("body = %+v", body.Data) + } +} + +func TestGetPresignedURL_CustomExpirationWithinRange(t *testing.T) { + app, s3 := newObjectsTestApp(t) + s3.ObjectExistsFn = func(_ context.Context, _, _ string) (bool, error) { return true, nil } + s3.GetPresignedURLFn = func(_ context.Context, _, _ string, exp time.Duration) (string, error) { + if exp != 60*time.Second { + t.Errorf("exp = %v, want 60s", exp) + } + return "u", nil + } + resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/objects/k1/presigned?expires_in=60", nil)) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d", resp.StatusCode) + } +} + +func TestGetPresignedURL_InvalidExpiration400(t *testing.T) { + app, _ := newObjectsTestApp(t) + cases := []string{"0", "-1", "604801", "abc"} + for _, val := range cases { + t.Run(val, func(t *testing.T) { + resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/objects/k1/presigned?expires_in="+val, nil)) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", resp.StatusCode) + } + }) + } +} + +func TestGetPresignedURL_ObjectMissing404(t *testing.T) { + app, s3 := newObjectsTestApp(t) + s3.ObjectExistsFn = func(_ context.Context, _, _ string) (bool, error) { return false, nil } + resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/objects/nope/presigned", nil)) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("status = %d, want 404", resp.StatusCode) + } +} + +// --- GetObject --- + +func TestGetObject_Success_StreamsBodyAndHeaders(t *testing.T) { + app, s3 := newObjectsTestApp(t) + content := []byte("hello world") + s3.GetObjectFn = func(_ context.Context, b, k string) (io.ReadCloser, *models.ObjectInfo, error) { + return io.NopCloser(bytes.NewReader(content)), &models.ObjectInfo{ + Key: k, Size: int64(len(content)), ETag: `"etag-1"`, + ContentType: "image/png", LastModified: time.Date(2026, 4, 1, 12, 0, 0, 0, time.UTC), + }, nil + } + resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/objects/k1", nil)) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d", resp.StatusCode) + } + if got := resp.Header.Get("Content-Type"); got != "image/png" { + t.Errorf("Content-Type = %q", got) + } + if got := resp.Header.Get("X-Content-Type-Options"); got != "nosniff" { + t.Errorf("X-Content-Type-Options = %q", got) + } + if !strings.Contains(resp.Header.Get("Content-Disposition"), `filename="k1"`) { + t.Errorf("Content-Disposition = %q", resp.Header.Get("Content-Disposition")) + } + body, _ := io.ReadAll(resp.Body) + if !bytes.Equal(body, content) { + t.Errorf("body = %q, want %q", body, content) + } +} + +func TestGetObject_RewritesExecutableContentType(t *testing.T) { + app, s3 := newObjectsTestApp(t) + s3.GetObjectFn = func(_ context.Context, _, _ string) (io.ReadCloser, *models.ObjectInfo, error) { + return io.NopCloser(strings.NewReader("")), + &models.ObjectInfo{Key: "evil.html", Size: 25, ContentType: "text/html"}, + nil + } + resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/objects/evil.html", nil)) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if got := resp.Header.Get("Content-Type"); got != "application/octet-stream" { + t.Errorf("Content-Type = %q, want application/octet-stream", got) + } +} + +func TestGetObject_DownloadQuerySetsAttachment(t *testing.T) { + app, s3 := newObjectsTestApp(t) + s3.GetObjectFn = func(_ context.Context, _, _ string) (io.ReadCloser, *models.ObjectInfo, error) { + return io.NopCloser(strings.NewReader("x")), &models.ObjectInfo{Key: "file.txt", Size: 1}, nil + } + resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/objects/file.txt?download=true", nil)) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if !strings.HasPrefix(resp.Header.Get("Content-Disposition"), "attachment") { + t.Errorf("Content-Disposition = %q, want attachment", resp.Header.Get("Content-Disposition")) + } +} + +func TestGetObject_ServiceErrorReturns404(t *testing.T) { + app, s3 := newObjectsTestApp(t) + s3.GetObjectFn = func(_ context.Context, _, _ string) (io.ReadCloser, *models.ObjectInfo, error) { + return nil, nil, errors.New("not found") + } + resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/objects/nope", nil)) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("status = %d, want 404", resp.StatusCode) + } +} + +// buildMultipart builds a multipart body with a single file field plus +// optional additional form fields. Returns body bytes and the Content-Type +// header value (which includes the boundary). +func buildMultipart(t *testing.T, fields map[string]string, files map[string]struct { + Filename string + Content []byte + ContentType string +}) ([]byte, string) { + t.Helper() + buf := &bytes.Buffer{} + w := multipart.NewWriter(buf) + for k, v := range fields { + if err := w.WriteField(k, v); err != nil { + t.Fatalf("WriteField: %v", err) + } + } + for name, f := range files { + h := make(map[string][]string) + h["Content-Disposition"] = []string{ + `form-data; name="` + name + `"; filename="` + f.Filename + `"`, + } + ct := f.ContentType + if ct == "" { + ct = "application/octet-stream" + } + h["Content-Type"] = []string{ct} + part, err := w.CreatePart(h) + if err != nil { + t.Fatalf("CreatePart: %v", err) + } + if _, err := part.Write(f.Content); err != nil { + t.Fatalf("part.Write: %v", err) + } + } + if err := w.Close(); err != nil { + t.Fatalf("writer.Close: %v", err) + } + return buf.Bytes(), w.FormDataContentType() +} + +func TestUploadObject_Success(t *testing.T) { + app, s3 := newObjectsTestApp(t) + s3.UploadObjectFn = func(_ context.Context, bucket, key string, body io.Reader, ct string) (*models.ObjectUploadResponse, error) { + if bucket != "b1" || key != "myfile.bin" { + t.Errorf("args = (%q, %q)", bucket, key) + } + if ct != "application/octet-stream" { + t.Errorf("contentType = %q", ct) + } + b, _ := io.ReadAll(body) + if string(b) != "payload" { + t.Errorf("body = %q, want 'payload'", b) + } + return &models.ObjectUploadResponse{Bucket: bucket, Key: key, Size: int64(len(b))}, nil + } + body, ct := buildMultipart(t, nil, map[string]struct { + Filename string + Content []byte + ContentType string + }{ + "file": {Filename: "myfile.bin", Content: []byte("payload")}, + }) + req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects", bytes.NewReader(body)) + req.Header.Set("Content-Type", ct) + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusCreated { + raw, _ := io.ReadAll(resp.Body) + t.Fatalf("status = %d, want 201\nbody: %s", resp.StatusCode, raw) + } +} + +func TestUploadObject_ExplicitKeyOverridesFilename(t *testing.T) { + app, s3 := newObjectsTestApp(t) + s3.UploadObjectFn = func(_ context.Context, _, key string, _ io.Reader, _ string) (*models.ObjectUploadResponse, error) { + if key != "custom/key.txt" { + t.Errorf("key = %q, want custom/key.txt", key) + } + return &models.ObjectUploadResponse{Key: key}, nil + } + body, ct := buildMultipart(t, map[string]string{"key": "custom/key.txt"}, map[string]struct { + Filename string + Content []byte + ContentType string + }{ + "file": {Filename: "whatever.txt", Content: []byte("x")}, + }) + req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects", bytes.NewReader(body)) + req.Header.Set("Content-Type", ct) + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusCreated { + t.Fatalf("status = %d", resp.StatusCode) + } +} + +func TestUploadObject_MissingFileReturns400(t *testing.T) { + app, _ := newObjectsTestApp(t) + body, ct := buildMultipart(t, map[string]string{"key": "k"}, nil) + req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects", bytes.NewReader(body)) + req.Header.Set("Content-Type", ct) + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", resp.StatusCode) + } +} + +func TestUploadObject_ServiceError500(t *testing.T) { + app, s3 := newObjectsTestApp(t) + s3.UploadObjectFn = func(_ context.Context, _, _ string, _ io.Reader, _ string) (*models.ObjectUploadResponse, error) { + return nil, errors.New("boom") + } + body, ct := buildMultipart(t, nil, map[string]struct { + Filename string + Content []byte + ContentType string + }{"file": {Filename: "f.bin", Content: []byte("x")}}) + req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects", bytes.NewReader(body)) + req.Header.Set("Content-Type", ct) + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500", resp.StatusCode) + } +} + +func TestDeleteMultipleObjects_Success(t *testing.T) { + app, s3 := newObjectsTestApp(t) + s3.DeleteMultipleObjectsFn = func(_ context.Context, bucket string, keys []string) error { + if bucket != "b1" || len(keys) != 3 { + t.Errorf("args = (%q, %v)", bucket, keys) + } + return nil + } + body, _ := json.Marshal(map[string]any{"keys": []string{"a", "b", "c"}}) + req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects/delete-multiple", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d", resp.StatusCode) + } + var out struct { + Data models.ObjectDeleteMultipleResponse `json:"data"` + } + decodeJSON(t, resp.Body, &out) + if out.Data.Deleted != 3 { + t.Errorf("Deleted = %d, want 3", out.Data.Deleted) + } +} + +func TestDeleteMultipleObjects_EmptyKeys400(t *testing.T) { + app, _ := newObjectsTestApp(t) + body, _ := json.Marshal(map[string]any{"keys": []string{}}) + req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects/delete-multiple", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", resp.StatusCode) + } +} + +func TestDeleteMultipleObjects_MalformedJSON400(t *testing.T) { + app, _ := newObjectsTestApp(t) + req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects/delete-multiple", strings.NewReader("{not-json")) + req.Header.Set("Content-Type", "application/json") + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("status = %d", resp.StatusCode) + } +} + +func TestDeleteMultipleObjects_ServiceError500(t *testing.T) { + app, s3 := newObjectsTestApp(t) + s3.DeleteMultipleObjectsFn = func(_ context.Context, _ string, _ []string) error { return errors.New("boom") } + body, _ := json.Marshal(map[string]any{"keys": []string{"a"}}) + req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects/delete-multiple", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusInternalServerError { + t.Fatalf("status = %d", resp.StatusCode) + } +} + +// buildMultipartMulti builds a body with N "files" parts. +func buildMultipartMulti(t *testing.T, files []struct { + Filename, ContentType string + Content []byte +}) ([]byte, string) { + t.Helper() + buf := &bytes.Buffer{} + w := multipart.NewWriter(buf) + for _, f := range files { + h := map[string][]string{ + "Content-Disposition": {`form-data; name="files"; filename="` + f.Filename + `"`}, + "Content-Type": {f.ContentType}, + } + part, err := w.CreatePart(h) + if err != nil { + t.Fatalf("CreatePart: %v", err) + } + _, _ = part.Write(f.Content) + } + _ = w.Close() + return buf.Bytes(), w.FormDataContentType() +} + +func TestUploadMultiple_AllSuccess201(t *testing.T) { + app, s3 := newObjectsTestApp(t) + s3.UploadMultipleObjectsFn = func(_ context.Context, bucket string, files []struct { + Key string + Body io.Reader + ContentType string + }) []services.UploadResult { + if bucket != "b1" || len(files) != 2 { + t.Errorf("got bucket=%q files=%d", bucket, len(files)) + } + out := make([]services.UploadResult, len(files)) + for i, f := range files { + out[i] = services.UploadResult{Key: f.Key, Success: true, ContentType: f.ContentType, Size: 1} + } + return out + } + body, ct := buildMultipartMulti(t, []struct { + Filename, ContentType string + Content []byte + }{ + {"a.txt", "text/plain", []byte("a")}, + {"b.txt", "text/plain", []byte("b")}, + }) + req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects/upload-multiple", bytes.NewReader(body)) + req.Header.Set("Content-Type", ct) + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusCreated { + raw, _ := io.ReadAll(resp.Body) + t.Fatalf("status = %d, want 201\nbody: %s", resp.StatusCode, raw) + } + var out struct { + Data models.ObjectUploadMultipleResponse `json:"data"` + } + decodeJSON(t, resp.Body, &out) + if out.Data.SuccessCount != 2 || out.Data.FailureCount != 0 { + t.Errorf("counts = (%d, %d)", out.Data.SuccessCount, out.Data.FailureCount) + } +} + +func TestUploadMultiple_PartialReturns207(t *testing.T) { + app, s3 := newObjectsTestApp(t) + s3.UploadMultipleObjectsFn = func(_ context.Context, _ string, files []struct { + Key string + Body io.Reader + ContentType string + }) []services.UploadResult { + return []services.UploadResult{ + {Key: files[0].Key, Success: true, Size: 1, ContentType: files[0].ContentType}, + {Key: files[1].Key, Success: false, Error: errors.New("upload failed"), ContentType: files[1].ContentType}, + } + } + body, ct := buildMultipartMulti(t, []struct { + Filename, ContentType string + Content []byte + }{ + {"a.txt", "text/plain", []byte("a")}, + {"b.txt", "text/plain", []byte("b")}, + }) + req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects/upload-multiple", bytes.NewReader(body)) + req.Header.Set("Content-Type", ct) + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusMultiStatus { + t.Fatalf("status = %d, want 207", resp.StatusCode) + } +} + +func TestUploadMultiple_AllFailReturns500(t *testing.T) { + app, s3 := newObjectsTestApp(t) + s3.UploadMultipleObjectsFn = func(_ context.Context, _ string, files []struct { + Key string + Body io.Reader + ContentType string + }) []services.UploadResult { + out := make([]services.UploadResult, len(files)) + for i, f := range files { + out[i] = services.UploadResult{Key: f.Key, Success: false, Error: errors.New("boom")} + } + return out + } + body, ct := buildMultipartMulti(t, []struct { + Filename, ContentType string + Content []byte + }{{"a.txt", "text/plain", []byte("a")}}) + req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects/upload-multiple", bytes.NewReader(body)) + req.Header.Set("Content-Type", ct) + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500", resp.StatusCode) + } +} + +func TestUploadMultiple_NoFiles400(t *testing.T) { + app, _ := newObjectsTestApp(t) + body, ct := buildMultipartMulti(t, nil) + req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects/upload-multiple", bytes.NewReader(body)) + req.Header.Set("Content-Type", ct) + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", resp.StatusCode) + } +} + +func TestUploadMultiple_DefaultsContentType(t *testing.T) { + app, s3 := newObjectsTestApp(t) + s3.UploadMultipleObjectsFn = func(_ context.Context, _ string, files []struct { + Key string + Body io.Reader + ContentType string + }) []services.UploadResult { + if files[0].ContentType != "application/octet-stream" { + t.Errorf("ContentType = %q, want default application/octet-stream", files[0].ContentType) + } + return []services.UploadResult{{Key: files[0].Key, Success: true}} + } + // Write a part with an empty Content-Type header explicitly. + buf := &bytes.Buffer{} + w := multipart.NewWriter(buf) + part, err := w.CreatePart(map[string][]string{ + "Content-Disposition": {`form-data; name="files"; filename="a.txt"`}, + "Content-Type": {""}, + }) + if err != nil { + t.Fatalf("CreatePart: %v", err) + } + _, _ = part.Write([]byte("x")) + _ = w.Close() + + req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects/upload-multiple", bytes.NewReader(buf.Bytes())) + req.Header.Set("Content-Type", w.FormDataContentType()) + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusCreated { + t.Fatalf("status = %d, want 201", resp.StatusCode) + } +} diff --git a/backend/internal/handlers/users.go b/backend/internal/handlers/users.go index e3e9cfb..7e87105 100644 --- a/backend/internal/handlers/users.go +++ b/backend/internal/handlers/users.go @@ -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, } diff --git a/backend/internal/handlers/users_test.go b/backend/internal/handlers/users_test.go new file mode 100644 index 0000000..30c9065 --- /dev/null +++ b/backend/internal/handlers/users_test.go @@ -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) + } +} diff --git a/backend/internal/middleware/auth.go b/backend/internal/middleware/auth.go index 989d917..7259cb5 100644 --- a/backend/internal/middleware/auth.go +++ b/backend/internal/middleware/auth.go @@ -4,60 +4,90 @@ import ( "Noooste/garage-ui/internal/auth" "Noooste/garage-ui/internal/config" "Noooste/garage-ui/internal/models" + logpkg "Noooste/garage-ui/pkg/logger" "github.com/gofiber/fiber/v3" ) -// AuthMiddleware supports admin and OIDC authentication +// AuthMiddleware supports admin and OIDC authentication. On success it +// enriches the per-request logger (stored in c.Context() by the Logging +// middleware) with user_id and auth_method so downstream service log lines +// carry user identity. On failure it emits a warn log with the auth_method +// tried and a reason — never the token value. func AuthMiddleware(cfg *config.AuthConfig, authService *auth.Service) fiber.Handler { return func(c fiber.Ctx) error { - // If no auth is enabled, allow all requests + // If no auth is enabled, allow all requests. if !cfg.Admin.Enabled && !cfg.OIDC.Enabled { return c.Next() } - // Get Authorization header authHeader := c.Get("Authorization") - // Try admin auth if enabled and header is present + // Try admin auth if enabled and header is present. if cfg.Admin.Enabled && authHeader != "" { - // Check if it's a Bearer token (JWT from admin login) if len(authHeader) > 7 && authHeader[:7] == "Bearer " { token := authHeader[7:] - - // Validate JWT session token userInfo, err := authService.ValidateSessionToken(token) if err == nil { - // Valid admin token c.Locals("userInfo", userInfo) c.Locals("username", userInfo.Username) if userInfo.Email != "" { c.Locals("email", userInfo.Email) } + enrichRequestLogger(c, userInfo.Username, "admin") return c.Next() } } } - // Try OIDC auth if enabled + // Try OIDC auth if enabled. if cfg.OIDC.Enabled { sessionCookie := c.Cookies(cfg.OIDC.CookieName) if sessionCookie != "" { - // Validate JWT session token from cookie userInfo, err := authService.ValidateSessionToken(sessionCookie) if err == nil { - // Valid OIDC token c.Locals("userInfo", userInfo) c.Locals("username", userInfo.Username) c.Locals("email", userInfo.Email) + enrichRequestLogger(c, userInfo.Username, "oidc") return c.Next() } } } - // No valid authentication found + // Auth failed — log at warn without exposing token material. + logpkg.FromCtx(c.Context()).Warn(). + Str("auth_method", authMethodsEnabled(cfg)). + Str("reason", "no_valid_credentials"). + Msg("authentication_failed") + return c.Status(fiber.StatusUnauthorized).JSON( models.ErrorResponse(models.ErrCodeUnauthorized, "Authentication required"), ) } } + +// enrichRequestLogger rebinds the per-request logger in c.Context() with +// user_id and auth_method. Subsequent logpkg.FromCtx(c.Context()) calls +// return the enriched logger. +func enrichRequestLogger(c fiber.Ctx, userID, authMethod string) { + l := logpkg.FromCtx(c.Context()).With(). + Str("user_id", userID). + Str("auth_method", authMethod). + Logger() + c.Locals(LoggerLocalsKey, l) + c.SetContext(logpkg.IntoCtx(c.Context(), l)) +} + +func authMethodsEnabled(cfg *config.AuthConfig) string { + switch { + case cfg.Admin.Enabled && cfg.OIDC.Enabled: + return "admin+oidc" + case cfg.Admin.Enabled: + return "admin" + case cfg.OIDC.Enabled: + return "oidc" + default: + return "none" + } +} diff --git a/backend/internal/middleware/auth_test.go b/backend/internal/middleware/auth_test.go new file mode 100644 index 0000000..b4c94bd --- /dev/null +++ b/backend/internal/middleware/auth_test.go @@ -0,0 +1,403 @@ +package middleware + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "Noooste/garage-ui/internal/auth" + "Noooste/garage-ui/internal/config" + logpkg "Noooste/garage-ui/pkg/logger" + + "github.com/gofiber/fiber/v3" + "github.com/rs/zerolog" +) + +// newAuthTestApp builds a fiber.App with RequestID + Logging (buffer-backed) + +// AuthMiddleware + a trivial /protected handler that echoes username/auth +// method into the JSON body so tests can assert on locals. +func newAuthTestApp(t *testing.T, buf *bytes.Buffer, authCfg *config.AuthConfig, svc *auth.Service) *fiber.App { + t.Helper() + base := zerolog.New(buf) + app := fiber.New() + app.Use(RequestID()) + app.Use(Logging(base)) + app.Use(AuthMiddleware(authCfg, svc)) + app.Get("/protected", func(c fiber.Ctx) error { + uname, _ := c.Locals("username").(string) + email, _ := c.Locals("email").(string) + logpkg.FromCtx(c.Context()).Info().Msg("in_handler") + return c.JSON(fiber.Map{ + "ok": true, + "username": uname, + "email": email, + }) + }) + return app +} + +// newAuthSvc returns an *auth.Service with the given auth config, JWT +// service initialized, OIDC disabled unless the caller wires it. +func newAuthSvc(t *testing.T, authCfg *config.AuthConfig) *auth.Service { + t.Helper() + svc, err := auth.NewAuthService(authCfg, &config.ServerConfig{}) + if err != nil { + t.Fatalf("NewAuthService: %v", err) + } + return svc +} + +// findLine returns the first parsed log line whose "message" field equals msg, +// failing the test if no such line is present. +func findLine(t *testing.T, buf *bytes.Buffer, msg string) map[string]any { + t.Helper() + for _, line := range parseLines(t, buf) { + if line["message"] == msg { + return line + } + } + t.Fatalf("no %q log line: %s", msg, buf.String()) + return nil +} + +func TestAuthMiddleware_BothDisabled_AllowsRequest(t *testing.T) { + authCfg := &config.AuthConfig{ + Admin: config.AdminAuthConfig{Enabled: false}, + OIDC: config.OIDCConfig{Enabled: false}, + } + svc := newAuthSvc(t, authCfg) + + var buf bytes.Buffer + app := newAuthTestApp(t, &buf, authCfg, svc) + + req := httptest.NewRequest("GET", "/protected", nil) + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + if resp.StatusCode != 200 { + t.Fatalf("status = %d, want 200", resp.StatusCode) + } + var body map[string]any + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + t.Fatalf("decode: %v", err) + } + if body["ok"] != true { + t.Errorf("ok = %v, want true", body["ok"]) + } + if body["username"] != "" { + t.Errorf("username should be empty when auth disabled, got %q", body["username"]) + } +} + +func newAdminCfg() *config.AuthConfig { + return &config.AuthConfig{ + Admin: config.AdminAuthConfig{Enabled: true, Username: "admin", Password: "pw"}, + OIDC: config.OIDCConfig{Enabled: false}, + } +} + +func TestAuthMiddleware_Admin_BearerValid_AllowsAndEnrichesLogger(t *testing.T) { + authCfg := newAdminCfg() + svc := newAuthSvc(t, authCfg) + tok, err := svc.GenerateSessionToken(&auth.UserInfo{Username: "admin", Email: "a@b"}) + if err != nil { + t.Fatalf("GenerateSessionToken: %v", err) + } + + var buf bytes.Buffer + app := newAuthTestApp(t, &buf, authCfg, svc) + + req := httptest.NewRequest("GET", "/protected", nil) + req.Header.Set("Authorization", "Bearer "+tok) + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + if resp.StatusCode != 200 { + t.Fatalf("status = %d, want 200", resp.StatusCode) + } + var body map[string]any + _ = json.NewDecoder(resp.Body).Decode(&body) + if body["username"] != "admin" { + t.Errorf("username = %v, want admin", body["username"]) + } + if body["email"] != "a@b" { + t.Errorf("email = %v, want a@b", body["email"]) + } + + // Enriched handler log line should carry user_id and auth_method=admin. + access := findLine(t, &buf, "in_handler") + if access["user_id"] != "admin" { + t.Errorf("user_id = %v, want admin", access["user_id"]) + } + if access["auth_method"] != "admin" { + t.Errorf("auth_method = %v, want admin", access["auth_method"]) + } +} + +func TestAuthMiddleware_Admin_BearerInvalid_Returns401(t *testing.T) { + authCfg := newAdminCfg() + svc := newAuthSvc(t, authCfg) + + var buf bytes.Buffer + app := newAuthTestApp(t, &buf, authCfg, svc) + + req := httptest.NewRequest("GET", "/protected", nil) + req.Header.Set("Authorization", "Bearer not-a-real-token") + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + if resp.StatusCode != 401 { + t.Fatalf("status = %d, want 401", resp.StatusCode) + } + var env struct { + Success bool `json:"success"` + Error struct { + Code string `json:"code"` + } `json:"error"` + } + _ = json.NewDecoder(resp.Body).Decode(&env) + if env.Success { + t.Error("success should be false") + } + if env.Error.Code != "UNAUTHORIZED" { + t.Errorf("error.code = %q, want UNAUTHORIZED", env.Error.Code) + } + + // Warn log should carry reason=no_valid_credentials without leaking the token. + warn := findLine(t, &buf, "authentication_failed") + if warn["reason"] != "no_valid_credentials" { + t.Errorf("reason = %v", warn["reason"]) + } + if warn["level"] != "warn" { + t.Errorf("level = %v, want warn", warn["level"]) + } + if strings.Contains(buf.String(), "not-a-real-token") { + t.Error("token value must not appear in logs") + } +} + +func TestAuthMiddleware_Admin_NoAuthHeader_Returns401(t *testing.T) { + authCfg := newAdminCfg() + svc := newAuthSvc(t, authCfg) + + var buf bytes.Buffer + app := newAuthTestApp(t, &buf, authCfg, svc) + + req := httptest.NewRequest("GET", "/protected", nil) + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + if resp.StatusCode != 401 { + t.Fatalf("status = %d, want 401", resp.StatusCode) + } +} + +func TestAuthMiddleware_Admin_NonBearerScheme_Returns401(t *testing.T) { + authCfg := newAdminCfg() + svc := newAuthSvc(t, authCfg) + + var buf bytes.Buffer + app := newAuthTestApp(t, &buf, authCfg, svc) + + req := httptest.NewRequest("GET", "/protected", nil) + req.Header.Set("Authorization", "Basic dXNlcjpwdw==") + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + if resp.StatusCode != 401 { + t.Fatalf("status = %d, want 401", resp.StatusCode) + } +} + +func newOIDCCfg(cookieName string) *config.AuthConfig { + return &config.AuthConfig{ + Admin: config.AdminAuthConfig{Enabled: false}, + OIDC: config.OIDCConfig{ + Enabled: true, + CookieName: cookieName, + }, + } +} + +// newOIDCSvc returns a Service whose OIDC is *not* initialized (no dialing +// needed), which is fine because AuthMiddleware's OIDC branch only calls +// ValidateSessionToken — a pure JWT operation. +func newOIDCSvc(t *testing.T) *auth.Service { + t.Helper() + return newAuthSvc(t, &config.AuthConfig{OIDC: config.OIDCConfig{Enabled: false}}) +} + +func TestAuthMiddleware_OIDC_ValidCookie_Allows(t *testing.T) { + cfg := newOIDCCfg("session") + svc := newOIDCSvc(t) + + tok, err := svc.GenerateSessionToken(&auth.UserInfo{Username: "alice", Email: "a@x"}) + if err != nil { + t.Fatalf("GenerateSessionToken: %v", err) + } + + var buf bytes.Buffer + app := newAuthTestApp(t, &buf, cfg, svc) + + req := httptest.NewRequest("GET", "/protected", nil) + req.AddCookie(&http.Cookie{Name: "session", Value: tok}) + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + if resp.StatusCode != 200 { + t.Fatalf("status = %d, want 200", resp.StatusCode) + } + + access := findLine(t, &buf, "in_handler") + if access["auth_method"] != "oidc" { + t.Errorf("auth_method = %v, want oidc", access["auth_method"]) + } + if access["user_id"] != "alice" { + t.Errorf("user_id = %v, want alice", access["user_id"]) + } +} + +func TestAuthMiddleware_OIDC_InvalidCookie_Returns401(t *testing.T) { + cfg := newOIDCCfg("session") + svc := newOIDCSvc(t) + + var buf bytes.Buffer + app := newAuthTestApp(t, &buf, cfg, svc) + + req := httptest.NewRequest("GET", "/protected", nil) + req.AddCookie(&http.Cookie{Name: "session", Value: "garbage"}) + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + if resp.StatusCode != 401 { + t.Fatalf("status = %d, want 401", resp.StatusCode) + } +} + +func TestAuthMiddleware_OIDC_NoCookie_Returns401(t *testing.T) { + cfg := newOIDCCfg("session") + svc := newOIDCSvc(t) + + var buf bytes.Buffer + app := newAuthTestApp(t, &buf, cfg, svc) + + req := httptest.NewRequest("GET", "/protected", nil) + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + if resp.StatusCode != 401 { + t.Fatalf("status = %d, want 401", resp.StatusCode) + } +} + +func newBothCfg(cookieName string) *config.AuthConfig { + return &config.AuthConfig{ + Admin: config.AdminAuthConfig{Enabled: true, Username: "admin", Password: "pw"}, + OIDC: config.OIDCConfig{ + Enabled: true, + CookieName: cookieName, + }, + } +} + +func TestAuthMiddleware_Both_BearerValid_AdminPathWins(t *testing.T) { + cfg := newBothCfg("session") + // OIDC disabled on the Service is fine — see Task 3 rationale. + svc := newAuthSvc(t, &config.AuthConfig{Admin: cfg.Admin}) + + tok, err := svc.GenerateSessionToken(&auth.UserInfo{Username: "admin"}) + if err != nil { + t.Fatalf("GenerateSessionToken: %v", err) + } + + var buf bytes.Buffer + app := newAuthTestApp(t, &buf, cfg, svc) + + req := httptest.NewRequest("GET", "/protected", nil) + req.Header.Set("Authorization", "Bearer "+tok) + // Also set a valid OIDC cookie; admin should still win. + cookieTok, _ := svc.GenerateSessionToken(&auth.UserInfo{Username: "alice"}) + req.AddCookie(&http.Cookie{Name: "session", Value: cookieTok}) + + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + if resp.StatusCode != 200 { + t.Fatalf("status = %d, want 200", resp.StatusCode) + } + + access := findLine(t, &buf, "in_handler") + if access["auth_method"] != "admin" { + t.Errorf("auth_method = %v, want admin", access["auth_method"]) + } + if access["user_id"] != "admin" { + t.Errorf("user_id = %v, want admin", access["user_id"]) + } +} + +func TestAuthMiddleware_Both_BearerInvalid_FallsThroughToOIDCCookie(t *testing.T) { + cfg := newBothCfg("session") + svc := newAuthSvc(t, &config.AuthConfig{Admin: cfg.Admin}) + + cookieTok, err := svc.GenerateSessionToken(&auth.UserInfo{Username: "alice"}) + if err != nil { + t.Fatalf("GenerateSessionToken: %v", err) + } + + var buf bytes.Buffer + app := newAuthTestApp(t, &buf, cfg, svc) + + req := httptest.NewRequest("GET", "/protected", nil) + req.Header.Set("Authorization", "Bearer bogus") + req.AddCookie(&http.Cookie{Name: "session", Value: cookieTok}) + + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + if resp.StatusCode != 200 { + t.Fatalf("status = %d, want 200 (OIDC fallback)", resp.StatusCode) + } + + access := findLine(t, &buf, "in_handler") + if access["auth_method"] != "oidc" { + t.Errorf("auth_method = %v, want oidc", access["auth_method"]) + } +} + +func TestAuthMiddleware_Both_AllInvalid_Returns401WithCombinedMethodLabel(t *testing.T) { + cfg := newBothCfg("session") + svc := newAuthSvc(t, &config.AuthConfig{Admin: cfg.Admin}) + + var buf bytes.Buffer + app := newAuthTestApp(t, &buf, cfg, svc) + + req := httptest.NewRequest("GET", "/protected", nil) + req.Header.Set("Authorization", "Bearer bogus") + req.AddCookie(&http.Cookie{Name: "session", Value: "also-bogus"}) + + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + if resp.StatusCode != 401 { + t.Fatalf("status = %d, want 401", resp.StatusCode) + } + + warn := findLine(t, &buf, "authentication_failed") + if warn["auth_method"] != "admin+oidc" { + t.Errorf("auth_method = %v, want admin+oidc", warn["auth_method"]) + } +} diff --git a/backend/internal/middleware/cors.go b/backend/internal/middleware/cors.go index bac15f2..051accd 100644 --- a/backend/internal/middleware/cors.go +++ b/backend/internal/middleware/cors.go @@ -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 } } diff --git a/backend/internal/middleware/cors_test.go b/backend/internal/middleware/cors_test.go new file mode 100644 index 0000000..d9e057d --- /dev/null +++ b/backend/internal/middleware/cors_test.go @@ -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) + } + }) + } +} diff --git a/backend/internal/middleware/logging.go b/backend/internal/middleware/logging.go new file mode 100644 index 0000000..3f604f5 --- /dev/null +++ b/backend/internal/middleware/logging.go @@ -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() + } +} diff --git a/backend/internal/middleware/logging_test.go b/backend/internal/middleware/logging_test.go new file mode 100644 index 0000000..5ce8ba0 --- /dev/null +++ b/backend/internal/middleware/logging_test.go @@ -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) + } + }) + } +} diff --git a/backend/internal/middleware/requestid.go b/backend/internal/middleware/requestid.go new file mode 100644 index 0000000..70633df --- /dev/null +++ b/backend/internal/middleware/requestid.go @@ -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() + } +} diff --git a/backend/internal/middleware/requestid_test.go b/backend/internal/middleware/requestid_test.go new file mode 100644 index 0000000..1f32c16 --- /dev/null +++ b/backend/internal/middleware/requestid_test.go @@ -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) + } +} diff --git a/backend/internal/models/requests.go b/backend/internal/models/requests.go index 87f8eb5..73ee721 100644 --- a/backend/internal/models/requests.go +++ b/backend/internal/models/requests.go @@ -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" diff --git a/backend/internal/models/responses.go b/backend/internal/models/responses.go index aac5851..f6afeda 100644 --- a/backend/internal/models/responses.go +++ b/backend/internal/models/responses.go @@ -138,13 +138,6 @@ type BucketPermission struct { Owner bool `json:"owner"` } -// Permission represents a permission entry for access control (legacy/deprecated) -type Permission struct { - Resource string `json:"resource"` - Actions []string `json:"actions"` - Effect string `json:"effect"` // "Allow" or "Deny" -} - type PresignedURLResponse struct { URL string `json:"url"` ExpiresIn int64 `json:"expires_in"` // in seconds diff --git a/backend/internal/routes/routes.go b/backend/internal/routes/routes.go index 59358f2..67b1a96 100644 --- a/backend/internal/routes/routes.go +++ b/backend/internal/routes/routes.go @@ -72,64 +72,38 @@ func SetupRoutes( objects.Post("/delete-multiple", objectHandler.DeleteMultipleObjects) // Delete multiple objects } - // Object-specific routes with wildcard key parameter (supports paths with slashes) - // These need to be registered on the main app with auth middleware applied + // Fiber v3 does not auto-decode wildcard params; fall back to the raw + // value when QueryUnescape fails. + decodeObjectKey := func(c fiber.Ctx) string { + raw := c.Params("*") + if decoded, err := url.QueryUnescape(raw); err == nil { + return decoded + } + return raw + } + objectWildcardHandler := func(c fiber.Ctx) error { - // Get the full path from wildcard parameter - // Note: Fiber v3 does NOT automatically decode params, we need to do it manually - path := c.Params("*") - - // Decode the full path using QueryUnescape (handles %20, %2F, etc.) - decodedPath, err := url.QueryUnescape(path) - if err != nil { - // If decoding fails, use the original path - decodedPath = path - } - - // Check if it's a metadata request - if strings.HasSuffix(decodedPath, "/metadata") { - // Remove /metadata suffix to get the actual key - key := strings.TrimSuffix(decodedPath, "/metadata") - c.Locals("objectKey", key) + path := decodeObjectKey(c) + switch { + case strings.HasSuffix(path, "/metadata"): + c.Locals("objectKey", strings.TrimSuffix(path, "/metadata")) return objectHandler.GetObjectMetadata(c) - } - // Check if it's a presign request - if strings.HasSuffix(decodedPath, "/presign") { - // Remove /presign suffix to get the actual key - key := strings.TrimSuffix(decodedPath, "/presign") - c.Locals("objectKey", key) + case strings.HasSuffix(path, "/presign"): + c.Locals("objectKey", strings.TrimSuffix(path, "/presign")) return objectHandler.GetPresignedURL(c) + default: + c.Locals("objectKey", path) + return objectHandler.GetObject(c) } - // Otherwise, it's a regular object download - c.Locals("objectKey", decodedPath) - return objectHandler.GetObject(c) } objectDeleteHandler := func(c fiber.Ctx) error { - path := c.Params("*") - - // Decode the full path using QueryUnescape - key, err := url.QueryUnescape(path) - if err != nil { - // If decoding fails, use the original path - key = path - } - - c.Locals("objectKey", key) + c.Locals("objectKey", decodeObjectKey(c)) return objectHandler.DeleteObject(c) } objectHeadHandler := func(c fiber.Ctx) error { - path := c.Params("*") - - // Decode the full path using QueryUnescape - key, err := url.QueryUnescape(path) - if err != nil { - // If decoding fails, use the original path - key = path - } - - c.Locals("objectKey", key) + c.Locals("objectKey", decodeObjectKey(c)) return objectHandler.GetObjectMetadata(c) } @@ -226,6 +200,11 @@ func SetupRoutes( }) } + logger.Debug(). + Str("access_token", token.AccessToken). + Interface("token", token). + Msg("Exchanged authorization code for token") + // Extract ID token from OAuth2 token rawIDToken, ok := token.Extra("id_token").(string) if !ok { @@ -253,10 +232,8 @@ func SetupRoutes( } } if !authService.IsAdmin(userInfo) { - if uiFromUserinfo, uiErr := authService.GetUserInfo(ctx, token); uiErr == nil { - if len(uiFromUserinfo.Roles) > 0 { - userInfo.Roles = uiFromUserinfo.Roles - } + if ui, err := authService.GetUserInfo(ctx, token); err == nil && len(ui.Roles) > 0 { + userInfo.Roles = ui.Roles } } if !authService.IsAdmin(userInfo) { diff --git a/backend/internal/routes/routes_oidc_issuer_test.go b/backend/internal/routes/routes_oidc_issuer_test.go new file mode 100644 index 0000000..d29eae4 --- /dev/null +++ b/backend/internal/routes/routes_oidc_issuer_test.go @@ -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) +} diff --git a/backend/internal/routes/routes_test.go b/backend/internal/routes/routes_test.go new file mode 100644 index 0000000..4f56780 --- /dev/null +++ b/backend/internal/routes/routes_test.go @@ -0,0 +1,604 @@ +package routes + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "Noooste/garage-ui/internal/auth" + "Noooste/garage-ui/internal/config" + "Noooste/garage-ui/internal/handlers" + "Noooste/garage-ui/internal/services/mocks" + + "github.com/gofiber/fiber/v3" +) + +// routeFixture bundles everything a routes test needs. +type routeFixture struct { + App *fiber.App + Admin *mocks.AdminMock + S3 *mocks.S3Mock + Auth *auth.Service + Cfg *config.Config +} + +// newTestApp builds a fully-wired fiber.App via SetupRoutes. The cfgMutator +// lets each test flip Admin/OIDC/CORS flags before the auth.Service is +// constructed. If the mutator sets OIDC.Enabled=true it MUST set +// OIDC.IssuerURL + Scopes + AdminRole + ClientID so NewAuthService can dial +// the issuer — typically via the testIssuer fixture. +func newTestApp(t *testing.T, cfgMutator func(*config.Config)) *routeFixture { + t.Helper() + + cfg := &config.Config{ + Server: config.ServerConfig{ + Port: 8080, + Environment: "test", + }, + Auth: config.AuthConfig{}, + CORS: config.CORSConfig{}, + } + if cfgMutator != nil { + cfgMutator(cfg) + } + + svc, err := auth.NewAuthService(&cfg.Auth, &cfg.Server) + if err != nil { + t.Fatalf("NewAuthService: %v", err) + } + + admin := &mocks.AdminMock{} + s3 := &mocks.S3Mock{} + + app := fiber.New() + SetupRoutes( + app, + cfg, + svc, + handlers.NewHealthHandler("test"), + handlers.NewBucketHandler(admin, s3), + handlers.NewObjectHandler(s3), + handlers.NewUserHandler(admin), + handlers.NewClusterHandler(admin), + handlers.NewMonitoringHandler(admin, s3), + ) + + return &routeFixture{App: app, Admin: admin, S3: s3, Auth: svc, Cfg: cfg} +} + +// expectStatus sends req and asserts the status code. +func expectStatus(t *testing.T, app *fiber.App, req *http.Request, want int) *http.Response { + t.Helper() + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test(%s %s): %v", req.Method, req.URL.Path, err) + } + if resp.StatusCode != want { + t.Fatalf("%s %s: status = %d, want %d", req.Method, req.URL.Path, resp.StatusCode, want) + } + return resp +} + +func TestRoutes_Registered_NoAuth(t *testing.T) { + // No auth: every route resolves; auth-specific routes return 404. + f := newTestApp(t, func(c *config.Config) { + c.Auth.Admin.Enabled = false + c.Auth.OIDC.Enabled = false + }) + + // Public routes reachable → not 404. Status may be anything except 404. + for _, tc := range []struct { + method, path string + }{ + {"GET", "/health"}, + {"GET", "/api/v1/health"}, + {"GET", "/auth/config"}, + } { + req := httptest.NewRequest(tc.method, tc.path, nil) + resp, err := f.App.Test(req) + if err != nil { + t.Fatalf("%s %s: %v", tc.method, tc.path, err) + } + if resp.StatusCode == 404 { + t.Errorf("%s %s returned 404 — route not registered", tc.method, tc.path) + } + } + + // Auth-specific routes must 404 when both auth methods disabled. + for _, tc := range []struct { + method, path string + }{ + {"POST", "/auth/login"}, + {"GET", "/auth/me"}, + {"GET", "/auth/oidc/login"}, + {"GET", "/auth/oidc/callback"}, + {"POST", "/auth/oidc/logout"}, + } { + req := httptest.NewRequest(tc.method, tc.path, nil) + expectStatus(t, f.App, req, 404) + } +} + +func TestRoutes_Registered_AdminOnly(t *testing.T) { + f := newTestApp(t, func(c *config.Config) { + c.Auth.Admin.Enabled = true + c.Auth.Admin.Username = "admin" + c.Auth.Admin.Password = "pw" + }) + + // /auth/login and /auth/me present; /auth/oidc/* not. + for _, tc := range []struct { + method, path string + }{ + {"POST", "/auth/login"}, + {"GET", "/auth/me"}, + } { + req := httptest.NewRequest(tc.method, tc.path, nil) + resp, err := f.App.Test(req) + if err != nil { + t.Fatalf("%v", err) + } + if resp.StatusCode == 404 { + t.Errorf("%s %s returned 404", tc.method, tc.path) + } + } + for _, tc := range []struct { + method, path string + }{ + {"GET", "/auth/oidc/login"}, + {"GET", "/auth/oidc/callback"}, + {"POST", "/auth/oidc/logout"}, + } { + req := httptest.NewRequest(tc.method, tc.path, nil) + expectStatus(t, f.App, req, 404) + } +} + +func TestRoutes_UnknownPath_Returns404(t *testing.T) { + f := newTestApp(t, nil) + req := httptest.NewRequest("GET", "/this/does/not/exist", nil) + expectStatus(t, f.App, req, 404) +} + +func TestRoutes_AllAPIRoutesRegistered(t *testing.T) { + // With Admin enabled, hit every path/method declared in SetupRoutes. + // A route being "registered" = status != 404. Specific behavior depends + // on auth (401) or mock setup (500 from errNotConfigured) — we only + // care that fiber routed the request. + f := newTestApp(t, func(c *config.Config) { + c.Auth.Admin.Enabled = true + c.Auth.Admin.Username = "admin" + c.Auth.Admin.Password = "pw" + }) + + cases := []struct { + method, path string + }{ + // Buckets + {"GET", "/api/v1/buckets/"}, + {"POST", "/api/v1/buckets/"}, + {"GET", "/api/v1/buckets/b1"}, + {"DELETE", "/api/v1/buckets/b1"}, + {"POST", "/api/v1/buckets/b1/permissions"}, + {"PUT", "/api/v1/buckets/b1/website"}, + // Objects (listing + uploads) + {"GET", "/api/v1/buckets/b1/objects/"}, + {"POST", "/api/v1/buckets/b1/objects/"}, + {"POST", "/api/v1/buckets/b1/objects/upload-multiple"}, + {"POST", "/api/v1/buckets/b1/objects/delete-multiple"}, + // Object wildcard routes + {"GET", "/api/v1/buckets/b1/objects/folder/file.txt"}, + {"GET", "/api/v1/buckets/b1/objects/folder/file.txt/metadata"}, + {"GET", "/api/v1/buckets/b1/objects/folder/file.txt/presign"}, + {"DELETE", "/api/v1/buckets/b1/objects/folder/file.txt"}, + {"HEAD", "/api/v1/buckets/b1/objects/folder/file.txt"}, + // Users + {"GET", "/api/v1/users/"}, + {"POST", "/api/v1/users/"}, + {"GET", "/api/v1/users/AKIA"}, + {"GET", "/api/v1/users/AKIA/secret"}, + {"DELETE", "/api/v1/users/AKIA"}, + {"PATCH", "/api/v1/users/AKIA"}, + // Cluster + {"GET", "/api/v1/cluster/health"}, + {"GET", "/api/v1/cluster/status"}, + {"GET", "/api/v1/cluster/statistics"}, + {"GET", "/api/v1/cluster/nodes/n1"}, + {"GET", "/api/v1/cluster/nodes/n1/statistics"}, + // Monitoring + {"GET", "/api/v1/monitoring/metrics"}, + {"GET", "/api/v1/monitoring/admin-health"}, + {"GET", "/api/v1/monitoring/dashboard"}, + } + for _, tc := range cases { + tc := tc + t.Run(tc.method+" "+tc.path, func(t *testing.T) { + req := httptest.NewRequest(tc.method, tc.path, nil) + resp, err := f.App.Test(req) + if err != nil { + t.Fatalf("%v", err) + } + if resp.StatusCode == 404 { + t.Errorf("route not registered (404): %s %s", tc.method, tc.path) + } + }) + } +} + +func TestRoutes_AuthRequired_For_APIRoutes(t *testing.T) { + f := newTestApp(t, func(c *config.Config) { + c.Auth.Admin.Enabled = true + c.Auth.Admin.Username = "admin" + c.Auth.Admin.Password = "pw" + }) + + // Unauthenticated requests to /api/v1/* must return 401 — auth + // middleware must run before the handler. + for _, tc := range []struct{ method, path string }{ + {"GET", "/api/v1/buckets/"}, + {"GET", "/api/v1/users/"}, + {"GET", "/api/v1/cluster/health"}, + {"GET", "/api/v1/monitoring/metrics"}, + // Object wildcard routes register AuthMiddleware separately — prove + // that wiring isn't missed for any of GET/DELETE/HEAD. + {"GET", "/api/v1/buckets/b/objects/k"}, + {"DELETE", "/api/v1/buckets/b/objects/k"}, + {"HEAD", "/api/v1/buckets/b/objects/k"}, + } { + tc := tc + t.Run(tc.method+" "+tc.path, func(t *testing.T) { + req := httptest.NewRequest(tc.method, tc.path, nil) + expectStatus(t, f.App, req, 401) + }) + } +} + +func TestRoutes_NoAuth_On_PublicRoutes(t *testing.T) { + f := newTestApp(t, func(c *config.Config) { + c.Auth.Admin.Enabled = true + c.Auth.Admin.Username = "admin" + c.Auth.Admin.Password = "pw" + }) + for _, tc := range []struct{ method, path string }{ + {"GET", "/health"}, + {"GET", "/api/v1/health"}, + {"GET", "/auth/config"}, + } { + tc := tc + t.Run(tc.path, func(t *testing.T) { + req := httptest.NewRequest(tc.method, tc.path, nil) + resp, err := f.App.Test(req) + if err != nil { + t.Fatalf("%v", err) + } + if resp.StatusCode == 401 { + t.Errorf("public route unexpectedly returned 401: %s", tc.path) + } + }) + } +} + +func TestRoutes_CORS_Preflight_PassesBeforeAuth(t *testing.T) { + f := newTestApp(t, func(c *config.Config) { + c.Auth.Admin.Enabled = true + c.Auth.Admin.Username = "admin" + c.Auth.Admin.Password = "pw" + c.CORS = config.CORSConfig{ + Enabled: true, + AllowedOrigins: []string{"https://ui.example"}, + AllowedMethods: []string{"GET", "POST"}, + } + }) + + req := httptest.NewRequest("OPTIONS", "/api/v1/buckets/", nil) + req.Header.Set("Origin", "https://ui.example") + resp := expectStatus(t, f.App, req, 204) + if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "https://ui.example" { + t.Errorf("Allow-Origin = %q — CORS should have run before auth", got) + } +} + +// newOIDCFixture builds a route fixture with OIDC enabled pointing at a +// running testIssuer. The `adminRole` is applied to cfg.Auth.OIDC.AdminRole +// (pass empty to disable the role gate). +func newOIDCFixture(t *testing.T, adminRole string) (*routeFixture, *testIssuer) { + t.Helper() + iss := newTestIssuer(t) + f := newTestApp(t, func(c *config.Config) { + c.Server.RootURL = "https://app.example" + c.Auth.OIDC = config.OIDCConfig{ + Enabled: true, + ClientID: iss.ClientID, + ClientSecret: "secret", + IssuerURL: iss.Server.URL, + Scopes: []string{"openid", "profile", "email"}, + AdminRole: adminRole, + UsernameAttribute: "preferred_username", + EmailAttribute: "email", + NameAttribute: "name", + RoleAttributePath: "resource_access.test-client.roles", + CookieName: "session", + CookieSecure: false, + CookieHTTPOnly: true, + CookieSameSite: "Lax", + SessionMaxAge: 3600, + } + }) + return f, iss +} + +func TestRoutes_OIDCLogin_RedirectsToAuthorizeEndpoint(t *testing.T) { + f, iss := newOIDCFixture(t, "admin") + + req := httptest.NewRequest("GET", "/auth/oidc/login", nil) + resp, err := f.App.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + if resp.StatusCode != 303 { + t.Fatalf("status = %d, want 303", resp.StatusCode) + } + loc := resp.Header.Get("Location") + if !strings.HasPrefix(loc, iss.Server.URL+"/authorize") { + t.Errorf("Location = %q, want prefix %s/authorize", loc, iss.Server.URL) + } + if !strings.Contains(loc, "state=") { + t.Errorf("Location missing state param: %s", loc) + } + if !strings.Contains(loc, "client_id=test-client") { + t.Errorf("Location missing client_id: %s", loc) + } +} + +func TestRoutes_Registered_OIDCOnly(t *testing.T) { + f, _ := newOIDCFixture(t, "admin") + + // /auth/oidc/* registered + for _, tc := range []struct{ method, path string }{ + {"GET", "/auth/oidc/login"}, + {"GET", "/auth/oidc/callback"}, + {"POST", "/auth/oidc/logout"}, + } { + req := httptest.NewRequest(tc.method, tc.path, nil) + resp, err := f.App.Test(req) + if err != nil { + t.Fatalf("%v", err) + } + if resp.StatusCode == 404 { + t.Errorf("%s %s not registered", tc.method, tc.path) + } + } + + // /auth/login must be 404 — admin disabled. + req := httptest.NewRequest("POST", "/auth/login", nil) + expectStatus(t, f.App, req, 404) +} + +// oidcState returns a fresh state token minted by the fixture's auth service. +func oidcState(t *testing.T, f *routeFixture) string { + t.Helper() + s, err := f.Auth.GenerateStateToken() + if err != nil { + t.Fatalf("GenerateStateToken: %v", err) + } + return s +} + +func TestRoutes_OIDCCallback_MissingState_Returns400(t *testing.T) { + f, _ := newOIDCFixture(t, "admin") + req := httptest.NewRequest("GET", "/auth/oidc/callback", nil) + expectStatus(t, f.App, req, 400) +} + +func TestRoutes_OIDCCallback_InvalidState_Returns400(t *testing.T) { + f, _ := newOIDCFixture(t, "admin") + req := httptest.NewRequest("GET", "/auth/oidc/callback?state=not-a-valid-state&code=c", nil) + expectStatus(t, f.App, req, 400) +} + +func TestRoutes_OIDCCallback_MissingCode_Returns400(t *testing.T) { + f, _ := newOIDCFixture(t, "admin") + state := oidcState(t, f) + req := httptest.NewRequest("GET", "/auth/oidc/callback?state="+state, nil) + expectStatus(t, f.App, req, 400) +} + +func TestRoutes_OIDCCallback_TokenExchangeFails_Returns401(t *testing.T) { + f, iss := newOIDCFixture(t, "admin") + iss.TokenError = "invalid_grant" + defer func() { iss.TokenError = "" }() + + state := oidcState(t, f) + req := httptest.NewRequest("GET", "/auth/oidc/callback?state="+state+"&code=c", nil) + expectStatus(t, f.App, req, 401) +} + +func TestRoutes_OIDCCallback_MissingIDToken_Returns401(t *testing.T) { + f, iss := newOIDCFixture(t, "admin") + iss.OmitIDToken = true + defer func() { iss.OmitIDToken = false }() + + state := oidcState(t, f) + req := httptest.NewRequest("GET", "/auth/oidc/callback?state="+state+"&code=c", nil) + expectStatus(t, f.App, req, 401) +} + +func TestRoutes_OIDCCallback_BadIDTokenSignature_Returns401(t *testing.T) { + f, iss := newOIDCFixture(t, "admin") + iss.SignIDTokenWithWrongKey = true + defer func() { iss.SignIDTokenWithWrongKey = false }() + + state := oidcState(t, f) + req := httptest.NewRequest("GET", "/auth/oidc/callback?state="+state+"&code=c", nil) + expectStatus(t, f.App, req, 401) +} + +func TestRoutes_OIDCCallback_RoleGateDenies_Returns403(t *testing.T) { + f, _ := newOIDCFixture(t, "admin") // AdminRole set; no roles anywhere + state := oidcState(t, f) + req := httptest.NewRequest("GET", "/auth/oidc/callback?state="+state+"&code=c", nil) + expectStatus(t, f.App, req, 403) +} + +func TestRoutes_OIDCCallback_NoRoleGate_HappyPathSetsCookieAndRedirects(t *testing.T) { + f, _ := newOIDCFixture(t, "") // AdminRole empty → role gate skipped + state := oidcState(t, f) + + req := httptest.NewRequest("GET", "/auth/oidc/callback?state="+state+"&code=c", nil) + resp, err := f.App.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + if resp.StatusCode != 303 { + t.Fatalf("status = %d, want 303", resp.StatusCode) + } + if loc := resp.Header.Get("Location"); loc != "/login?login=success" { + t.Errorf("Location = %q", loc) + } + + cookies := resp.Cookies() + var sess *http.Cookie + for _, c := range cookies { + if c.Name == "session" { + sess = c + break + } + } + if sess == nil { + t.Fatalf("no session cookie set: %+v", cookies) + } + if sess.Value == "" { + t.Error("session cookie value empty") + } + if !sess.HttpOnly { + t.Error("session cookie should be HttpOnly") + } + if sess.MaxAge != 3600 { + t.Errorf("MaxAge = %d, want 3600", sess.MaxAge) + } +} + +func TestRoutes_OIDCCallback_RoleMatchedViaAccessTokenFallback_Succeeds(t *testing.T) { + f, iss := newOIDCFixture(t, "admin") + // Inject role into the access token so ExtractRolesFromAccessToken returns [admin]. + iss.DefaultAccessClaims = map[string]any{ + "iss": iss.Server.URL, + "sub": "user-1", + "exp": time.Now().Add(10 * time.Minute).Unix(), + "resource_access": map[string]any{ + "test-client": map[string]any{"roles": []any{"admin"}}, + }, + } + + state := oidcState(t, f) + req := httptest.NewRequest("GET", "/auth/oidc/callback?state="+state+"&code=c", nil) + resp, err := f.App.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + if resp.StatusCode != 303 { + t.Fatalf("status = %d, want 303 (access-token role fallback should match)", resp.StatusCode) + } + if loc := resp.Header.Get("Location"); loc != "/login?login=success" { + t.Errorf("Location = %q", loc) + } + var sess *http.Cookie + for _, c := range resp.Cookies() { + if c.Name == "session" { + sess = c + } + } + if sess == nil || sess.Value == "" { + t.Fatalf("expected session cookie with value, got %+v", sess) + } +} + +func TestRoutes_OIDCLogout_ClearsCookieAndReturns200(t *testing.T) { + f, _ := newOIDCFixture(t, "admin") + + req := httptest.NewRequest("POST", "/auth/oidc/logout", nil) + resp, err := f.App.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + if resp.StatusCode != 200 { + t.Fatalf("status = %d, want 200", resp.StatusCode) + } + + var body map[string]any + _ = json.NewDecoder(resp.Body).Decode(&body) + if body["success"] != true { + t.Errorf("success = %v, want true", body["success"]) + } + if body["message"] != "Logged out successfully" { + t.Errorf("message = %v", body["message"]) + } + + var sess *http.Cookie + for _, c := range resp.Cookies() { + if c.Name == "session" { + sess = c + } + } + if sess == nil { + t.Fatalf("expected session cookie in response") + } + if sess.MaxAge != -1 { + t.Errorf("MaxAge = %d, want -1 (cookie cleared)", sess.MaxAge) + } + if sess.Value != "" { + t.Errorf("cookie Value = %q, want empty", sess.Value) + } +} + +func TestRoutes_SPAFallback_NoFrontendDir_DoesNotMount(t *testing.T) { + // Chdir into an empty temp dir — frontend/dist does not exist, so the + // SPA fallback is not registered. + t.Chdir(t.TempDir()) + + f := newTestApp(t, nil) + + req := httptest.NewRequest("GET", "/random/spa/path", nil) + expectStatus(t, f.App, req, 404) +} + +func TestRoutes_SPAFallback_WithFrontend_ServesIndexForUnknownPath(t *testing.T) { + if runtime.GOOS == "windows" { + // Fiber's c.SendFile holds a handle on the served file; Windows refuses + // t.TempDir RemoveAll cleanup. Behavior itself is platform-agnostic and + // covered on Linux in CI. + t.Skip("SPA fallback test skipped on Windows due to file-handle cleanup race") + } + dir := t.TempDir() + t.Chdir(dir) + + // Create ./frontend/dist/index.html + if err := os.MkdirAll(filepath.Join(dir, "frontend", "dist"), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + index := filepath.Join(dir, "frontend", "dist", "index.html") + if err := os.WriteFile(index, []byte("spa"), 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) +} diff --git a/backend/internal/services/admin.go b/backend/internal/services/admin.go index c9cd46d..97e12fd 100644 --- a/backend/internal/services/admin.go +++ b/backend/internal/services/admin.go @@ -4,11 +4,13 @@ import ( "Noooste/garage-ui/internal/config" "Noooste/garage-ui/internal/models" "Noooste/garage-ui/pkg/utils" + logpkg "Noooste/garage-ui/pkg/logger" "context" "encoding/json" "fmt" "io" "net/http" + "time" "github.com/Noooste/azuretls-client" ) @@ -177,100 +179,177 @@ func (s *GarageAdminService) ImportKey(ctx context.Context, req models.ImportKey return &result, nil } -// ListBuckets returns all buckets in the cluster +// ListBuckets returns all buckets in the cluster. func (s *GarageAdminService) ListBuckets(ctx context.Context) ([]models.ListBucketsResponseItem, error) { + log := logpkg.FromCtx(ctx).With(). + Str("component", "admin"). + Str("operation", "list_buckets"). + Logger() + + log.Debug().Msg("listing buckets") + start := time.Now() + resp, err := s.doRequest(ctx, http.MethodGet, "/v2/ListBuckets", nil) if err != nil { + log.Error().Err(err). + Float64("duration_ms", msSince(start)). + Str("outcome", "failure"). + Msg("garage list_buckets request failed") return nil, fmt.Errorf("request failed: %w", err) } var result []models.ListBucketsResponseItem if err := decodeResponse(resp, &result); err != nil { + log.Error().Err(err). + Float64("duration_ms", msSince(start)). + Str("outcome", "failure"). + Msg("garage list_buckets decode failed") return nil, fmt.Errorf("failed to decode response: %w", err) } + log.Debug(). + Float64("duration_ms", msSince(start)). + Str("outcome", "success"). + Int("count", len(result)). + Msg("listed buckets") return result, nil } -// GetBucketInfo returns detailed information about a bucket by ID +// GetBucketInfo returns detailed information about a bucket by ID. func (s *GarageAdminService) GetBucketInfo(ctx context.Context, bucketID string) (*models.GarageBucketInfo, error) { - path := fmt.Sprintf("/v2/GetBucketInfo?id=%s", bucketID) + log := logpkg.FromCtx(ctx).With(). + Str("component", "admin"). + Str("operation", "get_bucket_info"). + Str("bucket_id", bucketID). + Logger() - resp, err := s.doRequest(ctx, http.MethodGet, path, nil) + log.Debug().Msg("getting bucket info") + start := time.Now() + + resp, err := s.doRequest(ctx, http.MethodGet, fmt.Sprintf("/v2/GetBucketInfo?id=%s", bucketID), nil) if err != nil { + log.Error().Err(err).Float64("duration_ms", msSince(start)).Str("outcome", "failure").Msg("garage get_bucket_info request failed") return nil, fmt.Errorf("request failed: %w", err) } var result models.GarageBucketInfo if err := decodeResponse(resp, &result); err != nil { + log.Error().Err(err).Float64("duration_ms", msSince(start)).Str("outcome", "failure").Msg("garage get_bucket_info decode failed") return nil, fmt.Errorf("failed to decode response: %w", err) } + log.Debug().Float64("duration_ms", msSince(start)).Str("outcome", "success").Msg("got bucket info") return &result, nil } -// GetBucketInfoByAlias returns detailed information about a bucket by its global alias +// GetBucketInfoByAlias returns detailed information about a bucket by its global alias. func (s *GarageAdminService) GetBucketInfoByAlias(ctx context.Context, globalAlias string) (*models.GarageBucketInfo, error) { - path := fmt.Sprintf("/v2/GetBucketInfo?globalAlias=%s", globalAlias) + log := logpkg.FromCtx(ctx).With(). + Str("component", "admin"). + Str("operation", "get_bucket_info_by_alias"). + Str("bucket", globalAlias). + Logger() - resp, err := s.doRequest(ctx, http.MethodGet, path, nil) + log.Debug().Msg("getting bucket info by alias") + start := time.Now() + + resp, err := s.doRequest(ctx, http.MethodGet, fmt.Sprintf("/v2/GetBucketInfo?globalAlias=%s", globalAlias), nil) if err != nil { + log.Error().Err(err).Float64("duration_ms", msSince(start)).Str("outcome", "failure").Msg("garage get_bucket_info_by_alias request failed") return nil, fmt.Errorf("request failed: %w", err) } var result models.GarageBucketInfo if err = decodeResponse(resp, &result); err != nil { + log.Error().Err(err).Float64("duration_ms", msSince(start)).Str("outcome", "failure").Msg("garage get_bucket_info_by_alias decode failed") return nil, fmt.Errorf("failed to decode response: %w", err) } + log.Debug().Float64("duration_ms", msSince(start)).Str("outcome", "success").Str("bucket_id", result.ID).Msg("got bucket info by alias") return &result, nil } -// CreateBucket creates a new bucket via the Admin API +// CreateBucket creates a new bucket via the Admin API. func (s *GarageAdminService) CreateBucket(ctx context.Context, req models.CreateBucketAdminRequest) (*models.GarageBucketInfo, error) { + var alias string + if req.GlobalAlias != nil { + alias = *req.GlobalAlias + } + log := logpkg.FromCtx(ctx).With(). + Str("component", "admin"). + Str("operation", "create_bucket"). + Str("bucket", alias). + Logger() + + log.Info().Msg("creating bucket") + start := time.Now() + resp, err := s.doRequest(ctx, http.MethodPost, "/v2/CreateBucket", req) if err != nil { + log.Error().Err(err).Float64("duration_ms", msSince(start)).Str("outcome", "failure").Msg("garage create_bucket request failed") return nil, fmt.Errorf("request failed: %w", err) } var result models.GarageBucketInfo if err := decodeResponse(resp, &result); err != nil { + log.Error().Err(err).Float64("duration_ms", msSince(start)).Str("outcome", "failure").Msg("garage create_bucket decode failed") return nil, fmt.Errorf("failed to decode response: %w", err) } + log.Info().Float64("duration_ms", msSince(start)).Str("outcome", "success").Str("bucket_id", result.ID).Msg("bucket created") return &result, nil } -// UpdateBucket updates bucket settings +// UpdateBucket updates bucket settings. func (s *GarageAdminService) UpdateBucket(ctx context.Context, bucketID string, req models.UpdateBucketRequest) (*models.GarageBucketInfo, error) { - path := fmt.Sprintf("/v2/UpdateBucket?id=%s", bucketID) + log := logpkg.FromCtx(ctx).With(). + Str("component", "admin"). + Str("operation", "update_bucket"). + Str("bucket_id", bucketID). + Logger() - resp, err := s.doRequest(ctx, http.MethodPost, path, req) + log.Info().Msg("updating bucket") + start := time.Now() + + resp, err := s.doRequest(ctx, http.MethodPost, fmt.Sprintf("/v2/UpdateBucket?id=%s", bucketID), req) if err != nil { + log.Error().Err(err).Float64("duration_ms", msSince(start)).Str("outcome", "failure").Msg("garage update_bucket request failed") return nil, fmt.Errorf("request failed: %w", err) } var result models.GarageBucketInfo if err := decodeResponse(resp, &result); err != nil { + log.Error().Err(err).Float64("duration_ms", msSince(start)).Str("outcome", "failure").Msg("garage update_bucket decode failed") return nil, fmt.Errorf("failed to decode response: %w", err) } + log.Info().Float64("duration_ms", msSince(start)).Str("outcome", "success").Msg("bucket updated") return &result, nil } -// DeleteBucket deletes a bucket +// DeleteBucket deletes a bucket. func (s *GarageAdminService) DeleteBucket(ctx context.Context, bucketID string) error { - path := fmt.Sprintf("/v2/DeleteBucket?id=%s", bucketID) + log := logpkg.FromCtx(ctx).With(). + Str("component", "admin"). + Str("operation", "delete_bucket"). + Str("bucket_id", bucketID). + Logger() - resp, err := s.doRequest(ctx, http.MethodPost, path, nil) + log.Info().Msg("deleting bucket") + start := time.Now() + + resp, err := s.doRequest(ctx, http.MethodPost, fmt.Sprintf("/v2/DeleteBucket?id=%s", bucketID), nil) if err != nil { + log.Error().Err(err).Float64("duration_ms", msSince(start)).Str("outcome", "failure").Msg("garage delete_bucket request failed") return fmt.Errorf("request failed: %w", err) } if err := decodeResponse(resp, nil); err != nil { + log.Error().Err(err).Float64("duration_ms", msSince(start)).Str("outcome", "failure").Msg("garage delete_bucket decode failed") return fmt.Errorf("failed to process response: %w", err) } + log.Info().Float64("duration_ms", msSince(start)).Str("outcome", "success").Msg("bucket deleted") return nil } @@ -304,18 +383,34 @@ func (s *GarageAdminService) RemoveBucketAlias(ctx context.Context, req models.R return &result, nil } -// AllowBucketKey grants permissions for a key on a bucket +// AllowBucketKey grants permissions for a key on a bucket. func (s *GarageAdminService) AllowBucketKey(ctx context.Context, req models.BucketKeyPermRequest) (*models.GarageBucketInfo, error) { + log := logpkg.FromCtx(ctx).With(). + Str("component", "admin"). + Str("operation", "allow_bucket_key"). + Str("bucket_id", req.BucketID). + Str("access_key_id", logpkg.RedactKey(req.AccessKeyID)). + Bool("perm_read", req.Permissions.Read). + Bool("perm_write", req.Permissions.Write). + Bool("perm_owner", req.Permissions.Owner). + Logger() + + log.Info().Msg("granting bucket key permissions") + start := time.Now() + resp, err := s.doRequest(ctx, http.MethodPost, "/v2/AllowBucketKey", req) if err != nil { + log.Error().Err(err).Float64("duration_ms", msSince(start)).Str("outcome", "failure").Msg("garage allow_bucket_key request failed") return nil, fmt.Errorf("request failed: %w", err) } var result models.GarageBucketInfo if err := decodeResponse(resp, &result); err != nil { + log.Error().Err(err).Float64("duration_ms", msSince(start)).Str("outcome", "failure").Msg("garage allow_bucket_key decode failed") return nil, fmt.Errorf("failed to decode response: %w", err) } + log.Info().Float64("duration_ms", msSince(start)).Str("outcome", "success").Msg("bucket key permissions granted") return &result, nil } @@ -427,6 +522,11 @@ func (s *GarageAdminService) HealthCheck(ctx context.Context) error { return nil } +// msSince returns duration since t in milliseconds as a float64. +func msSince(t time.Time) float64 { + return float64(time.Since(t).Microseconds()) / 1000.0 +} + // GetMetrics returns Prometheus metrics from the Admin API func (s *GarageAdminService) GetMetrics(ctx context.Context) (string, error) { resp, err := s.doRequest(ctx, http.MethodGet, "/metrics", nil) diff --git a/backend/internal/services/admin_buckets_logging_test.go b/backend/internal/services/admin_buckets_logging_test.go new file mode 100644 index 0000000..45e6d64 --- /dev/null +++ b/backend/internal/services/admin_buckets_logging_test.go @@ -0,0 +1,134 @@ +package services + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "Noooste/garage-ui/internal/config" + "Noooste/garage-ui/internal/models" + logpkg "Noooste/garage-ui/pkg/logger" + + "github.com/rs/zerolog" +) + +// newAdminWithServer creates a GarageAdminService pointed at a test server +// and returns it with a cleanup hook. +func newAdminWithServer(t *testing.T, handler http.HandlerFunc) *GarageAdminService { + t.Helper() + srv := httptest.NewServer(handler) + t.Cleanup(srv.Close) + cfg := &config.GarageConfig{ + AdminEndpoint: srv.URL, + AdminToken: "test-token", + } + return NewGarageAdminService(cfg, "info") +} + +// ctxWithBufferLogger attaches a zerolog.Logger writing to buf onto ctx. +func ctxWithBufferLogger(buf *bytes.Buffer) context.Context { + l := zerolog.New(buf) + return logpkg.IntoCtx(context.Background(), l) +} + +func parseJSONLines(t *testing.T, s string) []map[string]any { + t.Helper() + out := []map[string]any{} + for _, line := range strings.Split(s, "\n") { + if strings.TrimSpace(line) == "" { + continue + } + var m map[string]any + if err := json.Unmarshal([]byte(line), &m); err != nil { + t.Fatalf("not JSON: %v — %s", err, line) + } + out = append(out, m) + } + return out +} + +func TestCreateBucket_SuccessLogsInfoWithFields(t *testing.T) { + admin := newAdminWithServer(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(models.GarageBucketInfo{ID: "bkt-123"}) + }) + + var buf bytes.Buffer + ctx := ctxWithBufferLogger(&buf) + + alias := "my-bucket" + if _, err := admin.CreateBucket(ctx, models.CreateBucketAdminRequest{GlobalAlias: &alias}); err != nil { + t.Fatalf("CreateBucket: %v", err) + } + + lines := parseJSONLines(t, buf.String()) + if len(lines) < 2 { + t.Fatalf("expected >=2 lines (start + success), got %d: %s", len(lines), buf.String()) + } + + final := lines[len(lines)-1] + if final["component"] != "admin" { + t.Errorf("component = %v, want admin", final["component"]) + } + if final["operation"] != "create_bucket" { + t.Errorf("operation = %v, want create_bucket", final["operation"]) + } + if final["outcome"] != "success" { + t.Errorf("outcome = %v, want success", final["outcome"]) + } + if final["level"] != "info" { + t.Errorf("level = %v, want info", final["level"]) + } + if _, ok := final["duration_ms"].(float64); !ok { + t.Errorf("missing duration_ms: %v", final) + } + if final["bucket_id"] != "bkt-123" { + t.Errorf("bucket_id = %v, want bkt-123", final["bucket_id"]) + } +} + +func TestCreateBucket_FailureLogsError(t *testing.T) { + admin := newAdminWithServer(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusConflict) + _, _ = w.Write([]byte(`{"error":"already exists"}`)) + }) + + var buf bytes.Buffer + ctx := ctxWithBufferLogger(&buf) + + alias := "duplicate" + _, err := admin.CreateBucket(ctx, models.CreateBucketAdminRequest{GlobalAlias: &alias}) + if err == nil { + t.Fatal("expected error, got nil") + } + + lines := parseJSONLines(t, buf.String()) + var failure map[string]any + for _, line := range lines { + if line["level"] == "error" { + failure = line + break + } + } + if failure == nil { + t.Fatalf("no error-level log line: %s", buf.String()) + } + + if failure["outcome"] != "failure" { + t.Errorf("outcome = %v, want failure", failure["outcome"]) + } + if failure["operation"] != "create_bucket" { + t.Errorf("operation = %v, want create_bucket", failure["operation"]) + } + if _, ok := failure["error"].(string); !ok { + t.Errorf("missing error field: %v", failure) + } + if _, ok := failure["duration_ms"].(float64); !ok { + t.Errorf("missing duration_ms: %v", failure) + } +} diff --git a/backend/internal/services/admin_test.go b/backend/internal/services/admin_test.go new file mode 100644 index 0000000..b21fc07 --- /dev/null +++ b/backend/internal/services/admin_test.go @@ -0,0 +1,565 @@ +package services + +import ( + "context" + "encoding/json" + "io" + "net" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "Noooste/garage-ui/internal/config" + "Noooste/garage-ui/internal/models" +) + +// newAdminTestServer wires an httptest.Server (with the supplied handler) to a +// fresh *GarageAdminService configured with a known bearer token. +func newAdminTestServer(t *testing.T, handler http.Handler) (*GarageAdminService, *httptest.Server) { + t.Helper() + srv := httptest.NewServer(handler) + t.Cleanup(srv.Close) + + svc := NewGarageAdminService(&config.GarageConfig{ + AdminEndpoint: srv.URL, + AdminToken: "test-token-xyz", + }, "") + return svc, srv +} + +// jsonHandler returns an http.Handler that writes the supplied status code and +// JSON-encoded body. +func jsonHandler(t *testing.T, status int, body any) http.Handler { + t.Helper() + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if body == nil { + return + } + if err := json.NewEncoder(w).Encode(body); err != nil { + t.Errorf("server: encode response: %v", err) + } + }) +} + +type recordedRequest struct { + method string + path string + rawURL string + auth string + body []byte +} + +func recordingHandler(t *testing.T, status int, respBody any) (http.Handler, *recordedRequest) { + t.Helper() + rec := &recordedRequest{} + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + rec.method = r.Method + rec.path = r.URL.Path + rec.rawURL = r.URL.RequestURI() + rec.auth = r.Header.Get("Authorization") + if r.Body != nil { + b, err := io.ReadAll(r.Body) + if err != nil { + t.Errorf("server: read body: %v", err) + } + rec.body = b + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if respBody != nil { + _ = json.NewEncoder(w).Encode(respBody) + } + }) + return h, rec +} + +func TestHealthCheck_Success(t *testing.T) { + h, rec := recordingHandler(t, http.StatusOK, nil) + svc, _ := newAdminTestServer(t, h) + + if err := svc.HealthCheck(context.Background()); err != nil { + t.Fatalf("HealthCheck: %v", err) + } + if rec.method != http.MethodGet { + t.Errorf("method = %q, want GET", rec.method) + } + if rec.path != "/health" { + t.Errorf("path = %q, want /health", rec.path) + } + if rec.auth != "Bearer test-token-xyz" { + t.Errorf("Authorization = %q, want Bearer test-token-xyz", rec.auth) + } +} + +func TestHealthCheck_Non2xxReturnsError(t *testing.T) { + h := jsonHandler(t, http.StatusServiceUnavailable, map[string]string{"error": "down"}) + svc, _ := newAdminTestServer(t, h) + + err := svc.HealthCheck(context.Background()) + if err == nil { + t.Fatal("expected error for 503, got nil") + } + if !strings.Contains(err.Error(), "503") { + t.Errorf("error should mention status code, got %v", err) + } +} + +func TestListKeys_Success(t *testing.T) { + want := []models.ListKeysResponseItem{ + {ID: "k1", Name: "alice", Expired: false}, + {ID: "k2", Name: "bob", Expired: true}, + } + h, rec := recordingHandler(t, http.StatusOK, want) + svc, _ := newAdminTestServer(t, h) + + got, err := svc.ListKeys(context.Background()) + if err != nil { + t.Fatalf("ListKeys: %v", err) + } + if rec.method != http.MethodGet || rec.path != "/v2/ListKeys" { + t.Errorf("request = %s %s, want GET /v2/ListKeys", rec.method, rec.path) + } + if len(got) != 2 || got[0].ID != "k1" || got[1].Name != "bob" { + t.Errorf("got %+v, want %+v", got, want) + } +} + +func TestCreateKey_PostsBodyAndDecodesResponse(t *testing.T) { + name := "service-account" + want := &models.GarageKeyInfo{ + AccessKeyID: "GK123", + Name: name, + } + h, rec := recordingHandler(t, http.StatusOK, want) + svc, _ := newAdminTestServer(t, h) + + req := models.CreateKeyRequest{Name: &name, NeverExpires: true} + got, err := svc.CreateKey(context.Background(), req) + if err != nil { + t.Fatalf("CreateKey: %v", err) + } + if rec.method != http.MethodPost || rec.path != "/v2/CreateKey" { + t.Errorf("request = %s %s", rec.method, rec.path) + } + var sent models.CreateKeyRequest + if err := json.Unmarshal(rec.body, &sent); err != nil { + t.Fatalf("unmarshal sent body: %v (raw=%q)", err, rec.body) + } + if sent.Name == nil || *sent.Name != name { + t.Errorf("sent name = %v, want %q", sent.Name, name) + } + if !sent.NeverExpires { + t.Errorf("NeverExpires should round-trip true") + } + if got.AccessKeyID != want.AccessKeyID { + t.Errorf("AccessKeyID = %q, want %q", got.AccessKeyID, want.AccessKeyID) + } +} + +func TestGetKeyInfo_PassesShowSecretQueryParam(t *testing.T) { + cases := []struct { + name string + showSecret bool + wantInRawURL string + }{ + {"without secret", false, "?id=ABC"}, + {"with secret", true, "?id=ABC&showSecretKey=true"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + h, rec := recordingHandler(t, http.StatusOK, &models.GarageKeyInfo{AccessKeyID: "ABC"}) + svc, _ := newAdminTestServer(t, h) + + _, err := svc.GetKeyInfo(context.Background(), "ABC", tc.showSecret) + if err != nil { + t.Fatalf("GetKeyInfo: %v", err) + } + if rec.method != http.MethodGet { + t.Errorf("method = %q, want GET", rec.method) + } + if !strings.Contains(rec.rawURL, tc.wantInRawURL) { + t.Errorf("raw URL = %q, want substring %q", rec.rawURL, tc.wantInRawURL) + } + }) + } +} + +func TestUpdateKey_UsesIDInQuery(t *testing.T) { + h, rec := recordingHandler(t, http.StatusOK, &models.GarageKeyInfo{AccessKeyID: "K"}) + svc, _ := newAdminTestServer(t, h) + + if _, err := svc.UpdateKey(context.Background(), "K", models.UpdateKeyRequest{}); err != nil { + t.Fatalf("UpdateKey: %v", err) + } + if rec.method != http.MethodPost { + t.Errorf("method = %q, want POST", rec.method) + } + if !strings.Contains(rec.rawURL, "id=K") { + t.Errorf("raw URL = %q, want substring id=K", rec.rawURL) + } +} + +func TestDeleteKey_NoBodyOnSuccess(t *testing.T) { + h, rec := recordingHandler(t, http.StatusOK, nil) + svc, _ := newAdminTestServer(t, h) + + if err := svc.DeleteKey(context.Background(), "K"); err != nil { + t.Fatalf("DeleteKey: %v", err) + } + if rec.method != http.MethodPost { + t.Errorf("method = %q, want POST", rec.method) + } + if !strings.Contains(rec.rawURL, "id=K") { + t.Errorf("raw URL = %q", rec.rawURL) + } +} + +func TestImportKey_PostsToImportEndpoint(t *testing.T) { + want := &models.GarageKeyInfo{AccessKeyID: "imported"} + h, rec := recordingHandler(t, http.StatusOK, want) + svc, _ := newAdminTestServer(t, h) + + req := models.ImportKeyRequest{ + AccessKeyID: "imported", + SecretAccessKey: "shhh", + } + got, err := svc.ImportKey(context.Background(), req) + if err != nil { + t.Fatalf("ImportKey: %v", err) + } + if rec.path != "/v2/ImportKey" { + t.Errorf("path = %q", rec.path) + } + if got.AccessKeyID != "imported" { + t.Errorf("AccessKeyID = %q, want imported", got.AccessKeyID) + } +} + +func TestListBuckets_Success(t *testing.T) { + want := []models.ListBucketsResponseItem{{ID: "b1"}, {ID: "b2"}} + h, rec := recordingHandler(t, http.StatusOK, want) + svc, _ := newAdminTestServer(t, h) + + got, err := svc.ListBuckets(context.Background()) + if err != nil { + t.Fatalf("ListBuckets: %v", err) + } + if rec.path != "/v2/ListBuckets" { + t.Errorf("path = %q", rec.path) + } + if len(got) != 2 { + t.Errorf("len = %d, want 2", len(got)) + } +} + +func TestGetBucketInfo_UsesIDQuery(t *testing.T) { + h, rec := recordingHandler(t, http.StatusOK, &models.GarageBucketInfo{ID: "B"}) + svc, _ := newAdminTestServer(t, h) + if _, err := svc.GetBucketInfo(context.Background(), "B"); err != nil { + t.Fatalf("GetBucketInfo: %v", err) + } + if !strings.Contains(rec.rawURL, "id=B") { + t.Errorf("raw URL = %q", rec.rawURL) + } +} + +func TestGetBucketInfoByAlias_UsesGlobalAliasQuery(t *testing.T) { + h, rec := recordingHandler(t, http.StatusOK, &models.GarageBucketInfo{ID: "B", GlobalAliases: []string{"my-bucket"}}) + svc, _ := newAdminTestServer(t, h) + got, err := svc.GetBucketInfoByAlias(context.Background(), "my-bucket") + if err != nil { + t.Fatalf("GetBucketInfoByAlias: %v", err) + } + if !strings.Contains(rec.rawURL, "globalAlias=my-bucket") { + t.Errorf("raw URL = %q", rec.rawURL) + } + if got.ID != "B" { + t.Errorf("ID = %q, want B", got.ID) + } +} + +func TestCreateBucket_PostsBody(t *testing.T) { + want := &models.GarageBucketInfo{ID: "new"} + h, rec := recordingHandler(t, http.StatusOK, want) + svc, _ := newAdminTestServer(t, h) + + if _, err := svc.CreateBucket(context.Background(), models.CreateBucketAdminRequest{}); err != nil { + t.Fatalf("CreateBucket: %v", err) + } + if rec.method != http.MethodPost || rec.path != "/v2/CreateBucket" { + t.Errorf("request = %s %s", rec.method, rec.path) + } +} + +func TestUpdateBucket_UsesIDQueryAndPostsBody(t *testing.T) { + h, rec := recordingHandler(t, http.StatusOK, &models.GarageBucketInfo{ID: "B"}) + svc, _ := newAdminTestServer(t, h) + if _, err := svc.UpdateBucket(context.Background(), "B", models.UpdateBucketRequest{}); err != nil { + t.Fatalf("UpdateBucket: %v", err) + } + if rec.method != http.MethodPost { + t.Errorf("method = %q", rec.method) + } + if !strings.Contains(rec.rawURL, "id=B") { + t.Errorf("raw URL = %q", rec.rawURL) + } +} + +func TestDeleteBucket_PostWithIDQuery(t *testing.T) { + h, rec := recordingHandler(t, http.StatusOK, nil) + svc, _ := newAdminTestServer(t, h) + if err := svc.DeleteBucket(context.Background(), "B"); err != nil { + t.Fatalf("DeleteBucket: %v", err) + } + if rec.method != http.MethodPost { + t.Errorf("method = %q", rec.method) + } + if !strings.Contains(rec.rawURL, "id=B") { + t.Errorf("raw URL = %q", rec.rawURL) + } +} + +func TestBucketAliasAndPermissionEndpoints(t *testing.T) { + cases := []struct { + name string + fn func(s *GarageAdminService) error + path string + }{ + { + name: "AddBucketAlias", + fn: func(s *GarageAdminService) error { + _, err := s.AddBucketAlias(context.Background(), models.AddBucketAliasRequest{}) + return err + }, + path: "/v2/AddBucketAlias", + }, + { + name: "RemoveBucketAlias", + fn: func(s *GarageAdminService) error { + _, err := s.RemoveBucketAlias(context.Background(), models.RemoveBucketAliasRequest{}) + return err + }, + path: "/v2/RemoveBucketAlias", + }, + { + name: "AllowBucketKey", + fn: func(s *GarageAdminService) error { + _, err := s.AllowBucketKey(context.Background(), models.BucketKeyPermRequest{}) + return err + }, + path: "/v2/AllowBucketKey", + }, + { + name: "DenyBucketKey", + fn: func(s *GarageAdminService) error { + _, err := s.DenyBucketKey(context.Background(), models.BucketKeyPermRequest{}) + return err + }, + path: "/v2/DenyBucketKey", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + h, rec := recordingHandler(t, http.StatusOK, &models.GarageBucketInfo{ID: "B"}) + svc, _ := newAdminTestServer(t, h) + if err := tc.fn(svc); err != nil { + t.Fatalf("%s: %v", tc.name, err) + } + if rec.path != tc.path || rec.method != http.MethodPost { + t.Errorf("request = %s %s, want POST %s", rec.method, rec.path, tc.path) + } + }) + } +} + +func TestClusterEndpoints(t *testing.T) { + cases := []struct { + name string + fn func(s *GarageAdminService) error + path string + }{ + { + name: "GetClusterHealth", + fn: func(s *GarageAdminService) error { + _, err := s.GetClusterHealth(context.Background()) + return err + }, + path: "/v2/GetClusterHealth", + }, + { + name: "GetClusterStatus", + fn: func(s *GarageAdminService) error { + _, err := s.GetClusterStatus(context.Background()) + return err + }, + path: "/v2/GetClusterStatus", + }, + { + name: "GetClusterStatistics", + fn: func(s *GarageAdminService) error { + _, err := s.GetClusterStatistics(context.Background()) + return err + }, + path: "/v2/GetClusterStatistics", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + h, rec := recordingHandler(t, http.StatusOK, struct{}{}) + svc, _ := newAdminTestServer(t, h) + if err := tc.fn(svc); err != nil { + t.Fatalf("%s: %v", tc.name, err) + } + if rec.method != http.MethodGet { + t.Errorf("method = %q, want GET", rec.method) + } + if rec.path != tc.path { + t.Errorf("path = %q, want %q", rec.path, tc.path) + } + }) + } +} + +func TestNodeEndpoints_NodeIDInQuery(t *testing.T) { + cases := []struct { + name string + fn func(s *GarageAdminService, id string) error + path string + }{ + { + name: "GetNodeInfo", + fn: func(s *GarageAdminService, id string) error { + _, err := s.GetNodeInfo(context.Background(), id) + return err + }, + path: "/v2/GetNodeInfo", + }, + { + name: "GetNodeStatistics", + fn: func(s *GarageAdminService, id string) error { + _, err := s.GetNodeStatistics(context.Background(), id) + return err + }, + path: "/v2/GetNodeStatistics", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + h, rec := recordingHandler(t, http.StatusOK, struct{}{}) + svc, _ := newAdminTestServer(t, h) + if err := tc.fn(svc, "node-7"); err != nil { + t.Fatalf("%s: %v", tc.name, err) + } + if rec.path != tc.path { + t.Errorf("path = %q, want %q", rec.path, tc.path) + } + if !strings.Contains(rec.rawURL, "node=node-7") { + t.Errorf("raw URL = %q, want substring node=node-7", rec.rawURL) + } + }) + } +} + +func TestGetMetrics_ReturnsRawBodyOn2xx(t *testing.T) { + body := "# HELP garage_up Number of running nodes\ngarage_up 3\n" + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusOK) + _, _ = io.WriteString(w, body) + }) + svc, _ := newAdminTestServer(t, h) + + got, err := svc.GetMetrics(context.Background()) + if err != nil { + t.Fatalf("GetMetrics: %v", err) + } + if got != body { + t.Errorf("GetMetrics body mismatch:\n got %q\nwant %q", got, body) + } +} + +func TestGetMetrics_Non2xxReturnsErrorWithStatus(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + _, _ = io.WriteString(w, "forbidden") + }) + svc, _ := newAdminTestServer(t, h) + + _, err := svc.GetMetrics(context.Background()) + if err == nil { + t.Fatal("expected error for 403, got nil") + } + if !strings.Contains(err.Error(), "403") { + t.Errorf("error should mention 403, got %v", err) + } +} + +func TestDoRequest_Non2xxBodyEchoedInError(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _, _ = io.WriteString(w, `{"error":"bad request"}`) + }) + svc, _ := newAdminTestServer(t, h) + + _, err := svc.ListKeys(context.Background()) + if err == nil { + t.Fatal("expected error for 400, got nil") + } + if !strings.Contains(err.Error(), "400") || !strings.Contains(err.Error(), "bad request") { + t.Errorf("error %v should contain 400 and the response body", err) + } +} + +func TestDoRequest_MalformedJSONReturnsDecodeError(t *testing.T) { + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = io.WriteString(w, "not-json-at-all") + }) + svc, _ := newAdminTestServer(t, h) + + _, err := svc.ListKeys(context.Background()) + if err == nil { + t.Fatal("expected decode error, got nil") + } + if !strings.Contains(err.Error(), "decode") { + t.Errorf("error %v should mention decoding", err) + } +} + +func TestDoRequest_RetriesExhaustOnConnectionRefused(t *testing.T) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + addr := listener.Addr().String() + if err := listener.Close(); err != nil { + t.Fatalf("close listener: %v", err) + } + + svc := NewGarageAdminService(&config.GarageConfig{ + AdminEndpoint: "http://" + addr, + AdminToken: "irrelevant", + }, "") + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + start := time.Now() + _, err = svc.ListKeys(ctx) + elapsed := time.Since(start) + + if err == nil { + t.Fatal("expected error after retries exhaust, got nil") + } + if !strings.Contains(err.Error(), "max retries") { + t.Errorf("expected max-retries error, got %v", err) + } + if elapsed < 200*time.Millisecond { + t.Errorf("retries returned in %v — backoff loop not engaged", elapsed) + } +} diff --git a/backend/internal/services/interfaces.go b/backend/internal/services/interfaces.go new file mode 100644 index 0000000..f8a7856 --- /dev/null +++ b/backend/internal/services/interfaces.go @@ -0,0 +1,68 @@ +package services + +import ( + "context" + "io" + "time" + + "Noooste/garage-ui/internal/models" +) + +// AdminService is the set of Garage Admin API operations used by HTTP handlers. +// It is implemented by *GarageAdminService in admin.go. Kept narrow so that +// hand-rolled mocks in tests don't need to cover admin methods the handlers +// never call. +type AdminService interface { + // Access keys + ListKeys(ctx context.Context) ([]models.ListKeysResponseItem, error) + CreateKey(ctx context.Context, req models.CreateKeyRequest) (*models.GarageKeyInfo, error) + GetKeyInfo(ctx context.Context, keyID string, showSecret bool) (*models.GarageKeyInfo, error) + UpdateKey(ctx context.Context, keyID string, req models.UpdateKeyRequest) (*models.GarageKeyInfo, error) + DeleteKey(ctx context.Context, keyID string) error + + // Buckets + ListBuckets(ctx context.Context) ([]models.ListBucketsResponseItem, error) + GetBucketInfo(ctx context.Context, bucketID string) (*models.GarageBucketInfo, error) + GetBucketInfoByAlias(ctx context.Context, globalAlias string) (*models.GarageBucketInfo, error) + CreateBucket(ctx context.Context, req models.CreateBucketAdminRequest) (*models.GarageBucketInfo, error) + UpdateBucket(ctx context.Context, bucketID string, req models.UpdateBucketRequest) (*models.GarageBucketInfo, error) + DeleteBucket(ctx context.Context, bucketID string) error + AllowBucketKey(ctx context.Context, req models.BucketKeyPermRequest) (*models.GarageBucketInfo, error) + + // Cluster + GetClusterHealth(ctx context.Context) (*models.ClusterHealth, error) + GetClusterStatus(ctx context.Context) (*models.ClusterStatus, error) + GetClusterStatistics(ctx context.Context) (*models.ClusterStatistics, error) + GetNodeInfo(ctx context.Context, nodeID string) (*models.MultiNodeResponse, error) + GetNodeStatistics(ctx context.Context, nodeID string) (*models.MultiNodeResponse, error) + + // Monitoring + HealthCheck(ctx context.Context) error + GetMetrics(ctx context.Context) (string, error) +} + +// S3Storage is the set of S3 operations used by HTTP handlers. It is +// implemented by *S3Service in s3.go. Methods on *S3Service that are not +// called by handlers (ListBuckets, CreateBucket, DeleteBucket, +// GetBucketStatistics) are intentionally excluded. +type S3Storage interface { + ListObjects(ctx context.Context, bucketName, prefix string, maxKeys int, continuationToken string) (*models.ObjectListResponse, error) + UploadObject(ctx context.Context, bucketName, key string, body io.Reader, contentType string) (*models.ObjectUploadResponse, error) + GetObject(ctx context.Context, bucketName, key string) (io.ReadCloser, *models.ObjectInfo, error) + ObjectExists(ctx context.Context, bucketName, key string) (bool, error) + DeleteObject(ctx context.Context, bucketName, key string) error + GetObjectMetadata(ctx context.Context, bucketName, key string) (*models.ObjectInfo, error) + GetPresignedURL(ctx context.Context, bucketName, key string, expiresIn time.Duration) (string, error) + DeleteMultipleObjects(ctx context.Context, bucketName string, keys []string) error + UploadMultipleObjects(ctx context.Context, bucketName string, files []struct { + Key string + Body io.Reader + ContentType string + }) []UploadResult +} + +// Compile-time guarantees that the concrete services implement the interfaces. +var ( + _ AdminService = (*GarageAdminService)(nil) + _ S3Storage = (*S3Service)(nil) +) diff --git a/backend/internal/services/mocks/admin_mock.go b/backend/internal/services/mocks/admin_mock.go new file mode 100644 index 0000000..3e05b16 --- /dev/null +++ b/backend/internal/services/mocks/admin_mock.go @@ -0,0 +1,229 @@ +// 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) + + // 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) +} + +// --- 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) +} diff --git a/backend/internal/services/mocks/s3_mock.go b/backend/internal/services/mocks/s3_mock.go new file mode 100644 index 0000000..4ddeaf8 --- /dev/null +++ b/backend/internal/services/mocks/s3_mock.go @@ -0,0 +1,134 @@ +package mocks + +import ( + "context" + "fmt" + "io" + "time" + + "Noooste/garage-ui/internal/models" + "Noooste/garage-ui/internal/services" +) + +// s3NotConfigured mirrors errNotConfigured but keys messages on S3Mock so +// failures from unset S3 methods are distinguishable from unset admin methods. +func s3NotConfigured(method string) error { + return fmt.Errorf("S3Mock.%s: not configured by test", method) +} + +// Compile-time guarantee that S3Mock satisfies services.S3Storage. +var _ services.S3Storage = (*S3Mock)(nil) + +// S3Mock is a hand-rolled mock of services.S3Storage. Tests assign the +// per-method function fields they care about; unset methods return +// s3NotConfigured. +type S3Mock struct { + ListObjectsFn func(ctx context.Context, bucketName, prefix string, maxKeys int, continuationToken string) (*models.ObjectListResponse, error) + UploadObjectFn func(ctx context.Context, bucketName, key string, body io.Reader, contentType string) (*models.ObjectUploadResponse, error) + GetObjectFn func(ctx context.Context, bucketName, key string) (io.ReadCloser, *models.ObjectInfo, error) + ObjectExistsFn func(ctx context.Context, bucketName, key string) (bool, error) + DeleteObjectFn func(ctx context.Context, bucketName, key string) error + GetObjectMetadataFn func(ctx context.Context, bucketName, key string) (*models.ObjectInfo, error) + GetPresignedURLFn func(ctx context.Context, bucketName, key string, expiresIn time.Duration) (string, error) + DeleteMultipleObjectsFn func(ctx context.Context, bucketName string, keys []string) error + UploadMultipleObjectsFn func(ctx context.Context, bucketName string, files []struct { + Key string + Body io.Reader + ContentType string + }) []services.UploadResult + + // Calls records every invocation in order. Shares the Call type with + // AdminMock via the same package. + Calls []Call +} + +func (m *S3Mock) record(method string, args ...any) { + m.Calls = append(m.Calls, Call{Method: method, Args: args}) +} + +func (m *S3Mock) ListObjects(ctx context.Context, bucketName, prefix string, maxKeys int, continuationToken string) (*models.ObjectListResponse, error) { + m.record("ListObjects", bucketName, prefix, maxKeys, continuationToken) + if m.ListObjectsFn == nil { + return nil, s3NotConfigured("ListObjects") + } + return m.ListObjectsFn(ctx, bucketName, prefix, maxKeys, continuationToken) +} + +func (m *S3Mock) UploadObject(ctx context.Context, bucketName, key string, body io.Reader, contentType string) (*models.ObjectUploadResponse, error) { + m.record("UploadObject", bucketName, key, contentType) + if m.UploadObjectFn == nil { + return nil, s3NotConfigured("UploadObject") + } + return m.UploadObjectFn(ctx, bucketName, key, body, contentType) +} + +func (m *S3Mock) GetObject(ctx context.Context, bucketName, key string) (io.ReadCloser, *models.ObjectInfo, error) { + m.record("GetObject", bucketName, key) + if m.GetObjectFn == nil { + return nil, nil, s3NotConfigured("GetObject") + } + return m.GetObjectFn(ctx, bucketName, key) +} + +func (m *S3Mock) ObjectExists(ctx context.Context, bucketName, key string) (bool, error) { + m.record("ObjectExists", bucketName, key) + if m.ObjectExistsFn == nil { + return false, s3NotConfigured("ObjectExists") + } + return m.ObjectExistsFn(ctx, bucketName, key) +} + +func (m *S3Mock) DeleteObject(ctx context.Context, bucketName, key string) error { + m.record("DeleteObject", bucketName, key) + if m.DeleteObjectFn == nil { + return s3NotConfigured("DeleteObject") + } + return m.DeleteObjectFn(ctx, bucketName, key) +} + +func (m *S3Mock) GetObjectMetadata(ctx context.Context, bucketName, key string) (*models.ObjectInfo, error) { + m.record("GetObjectMetadata", bucketName, key) + if m.GetObjectMetadataFn == nil { + return nil, s3NotConfigured("GetObjectMetadata") + } + return m.GetObjectMetadataFn(ctx, bucketName, key) +} + +func (m *S3Mock) GetPresignedURL(ctx context.Context, bucketName, key string, expiresIn time.Duration) (string, error) { + m.record("GetPresignedURL", bucketName, key, expiresIn) + if m.GetPresignedURLFn == nil { + return "", s3NotConfigured("GetPresignedURL") + } + return m.GetPresignedURLFn(ctx, bucketName, key, expiresIn) +} + +func (m *S3Mock) DeleteMultipleObjects(ctx context.Context, bucketName string, keys []string) error { + m.record("DeleteMultipleObjects", bucketName, keys) + if m.DeleteMultipleObjectsFn == nil { + return s3NotConfigured("DeleteMultipleObjects") + } + return m.DeleteMultipleObjectsFn(ctx, bucketName, keys) +} + +func (m *S3Mock) UploadMultipleObjects(ctx context.Context, bucketName string, files []struct { + Key string + Body io.Reader + ContentType string +}) []services.UploadResult { + m.record("UploadMultipleObjects", bucketName, len(files)) + if m.UploadMultipleObjectsFn == nil { + // Can't return an error here — the interface has no error channel. + // Return a single synthetic failure for every file so tests that + // forget to configure this method see a clear red flag. + results := make([]services.UploadResult, len(files)) + for i, f := range files { + results[i] = services.UploadResult{ + Key: f.Key, + Success: false, + Error: s3NotConfigured("UploadMultipleObjects"), + } + } + return results + } + return m.UploadMultipleObjectsFn(ctx, bucketName, files) +} diff --git a/backend/internal/services/s3.go b/backend/internal/services/s3.go index 925bac9..db51829 100644 --- a/backend/internal/services/s3.go +++ b/backend/internal/services/s3.go @@ -37,7 +37,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, }) diff --git a/backend/internal/services/s3_test.go b/backend/internal/services/s3_test.go new file mode 100644 index 0000000..8623102 --- /dev/null +++ b/backend/internal/services/s3_test.go @@ -0,0 +1,331 @@ +package services + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "Noooste/garage-ui/internal/config" + "Noooste/garage-ui/internal/models" + "Noooste/garage-ui/pkg/utils" +) + +func TestNewS3Service_StripsHTTPPrefix(t *testing.T) { + cfg := &config.GarageConfig{ + Endpoint: "http://garage:3900", + Region: "garage", + } + _ = NewS3Service(cfg, nil) + if cfg.Endpoint != "garage:3900" { + t.Errorf("Endpoint = %q, want %q", cfg.Endpoint, "garage:3900") + } + if cfg.UseSSL { + t.Error("UseSSL should remain false for http://") + } +} + +func TestNewS3Service_StripsHTTPSPrefixAndEnablesSSL(t *testing.T) { + cfg := &config.GarageConfig{ + Endpoint: "https://garage.example.com", + Region: "garage", + } + _ = NewS3Service(cfg, nil) + if cfg.Endpoint != "garage.example.com" { + t.Errorf("Endpoint = %q", cfg.Endpoint) + } + if !cfg.UseSSL { + t.Error("UseSSL should be flipped to true for https://") + } +} + +func TestNewS3Service_LeavesBareHostUnchanged(t *testing.T) { + cfg := &config.GarageConfig{ + Endpoint: "garage:3900", + Region: "garage", + UseSSL: true, // pre-set; should remain true + } + _ = NewS3Service(cfg, nil) + if cfg.Endpoint != "garage:3900" { + t.Errorf("Endpoint mutated unexpectedly: %q", cfg.Endpoint) + } + if !cfg.UseSSL { + t.Error("pre-set UseSSL must be preserved") + } +} + +// adminBackedS3 wires an S3Service to a fresh GarageAdminService that talks +// to the supplied http.Handler. +func adminBackedS3(t *testing.T, handler http.Handler) (*S3Service, *httptest.Server) { + t.Helper() + srv := httptest.NewServer(handler) + t.Cleanup(srv.Close) + + admin := NewGarageAdminService(&config.GarageConfig{ + AdminEndpoint: srv.URL, + AdminToken: "test-token", + }, "") + s3 := NewS3Service(&config.GarageConfig{ + Endpoint: "garage:3900", + Region: "garage", + }, admin) + return s3, srv +} + +// uniqueBucket returns a per-subtest bucket name so cached credentials from +// one test don't leak into another via utils.GlobalCache. +func uniqueBucket(t *testing.T) string { + t.Helper() + name := "test-bucket-" + t.Name() + t.Cleanup(func() { + utils.GlobalCache.Delete("key:" + name) + }) + return name +} + +func TestGetBucketCredentials_HappyPath(t *testing.T) { + bucket := uniqueBucket(t) + secret := "the-secret" + + mux := http.NewServeMux() + mux.HandleFunc("/v2/GetBucketInfo", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(&models.GarageBucketInfo{ + ID: "bid", + Keys: []models.BucketKeyInfo{ + { + AccessKeyID: "AK", + Permissions: models.BucketKeyPermission{Read: true, Write: true}, + }, + }, + }) + }) + mux.HandleFunc("/v2/GetKeyInfo", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(&models.GarageKeyInfo{ + AccessKeyID: "AK", + SecretAccessKey: &secret, + }) + }) + s3, _ := adminBackedS3(t, mux) + + creds, err := s3.getBucketCredentials(context.Background(), bucket) + if err != nil { + t.Fatalf("getBucketCredentials: %v", err) + } + if creds == nil { + t.Fatal("creds is nil") + } + v, err := creds.GetWithContext(nil) + if err != nil { + t.Fatalf("creds.GetWithContext: %v", err) + } + if v.AccessKeyID != "AK" || v.SecretAccessKey != secret { + t.Errorf("got AK=%q SK=%q, want AK SK=%q", v.AccessKeyID, v.SecretAccessKey, secret) + } +} + +func TestGetBucketCredentials_CachesAcrossCalls(t *testing.T) { + bucket := uniqueBucket(t) + secret := "cache-secret" + + var bucketCalls, keyCalls int + mux := http.NewServeMux() + mux.HandleFunc("/v2/GetBucketInfo", func(w http.ResponseWriter, r *http.Request) { + bucketCalls++ + _ = json.NewEncoder(w).Encode(&models.GarageBucketInfo{ + ID: "bid", + Keys: []models.BucketKeyInfo{ + {AccessKeyID: "AK", Permissions: models.BucketKeyPermission{Read: true, Write: true}}, + }, + }) + }) + mux.HandleFunc("/v2/GetKeyInfo", func(w http.ResponseWriter, r *http.Request) { + keyCalls++ + _ = json.NewEncoder(w).Encode(&models.GarageKeyInfo{ + AccessKeyID: "AK", + SecretAccessKey: &secret, + }) + }) + s3, _ := adminBackedS3(t, mux) + + for i := range 3 { + if _, err := s3.getBucketCredentials(context.Background(), bucket); err != nil { + t.Fatalf("call %d: %v", i, err) + } + } + if bucketCalls != 1 { + t.Errorf("GetBucketInfo called %d times, want 1 (cache should serve calls 2-3)", bucketCalls) + } + if keyCalls != 1 { + t.Errorf("GetKeyInfo called %d times, want 1", keyCalls) + } +} + +func TestGetBucketCredentials_SkipsKeysWithoutReadOrWrite(t *testing.T) { + bucket := uniqueBucket(t) + secret := "good-secret" + + mux := http.NewServeMux() + mux.HandleFunc("/v2/GetBucketInfo", func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(&models.GarageBucketInfo{ + ID: "bid", + Keys: []models.BucketKeyInfo{ + {AccessKeyID: "READ-ONLY", Permissions: models.BucketKeyPermission{Read: true, Write: false}}, + {AccessKeyID: "WRITE-ONLY", Permissions: models.BucketKeyPermission{Read: false, Write: true}}, + {AccessKeyID: "NO-PERMS", Permissions: models.BucketKeyPermission{}}, + {AccessKeyID: "RW", Permissions: models.BucketKeyPermission{Read: true, Write: true}}, + }, + }) + }) + mux.HandleFunc("/v2/GetKeyInfo", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("id") != "RW" { + t.Errorf("GetKeyInfo called with id=%q, want RW", r.URL.Query().Get("id")) + } + _ = json.NewEncoder(w).Encode(&models.GarageKeyInfo{ + AccessKeyID: "RW", + SecretAccessKey: &secret, + }) + }) + s3, _ := adminBackedS3(t, mux) + + creds, err := s3.getBucketCredentials(context.Background(), bucket) + if err != nil { + t.Fatalf("getBucketCredentials: %v", err) + } + v, err := creds.GetWithContext(nil) + if err != nil { + t.Fatalf("creds.GetWithContext: %v", err) + } + if v.AccessKeyID != "RW" { + t.Errorf("AccessKeyID = %q, want RW", v.AccessKeyID) + } +} + +func TestGetBucketCredentials_NoEligibleKeyReturnsError(t *testing.T) { + bucket := uniqueBucket(t) + + mux := http.NewServeMux() + mux.HandleFunc("/v2/GetBucketInfo", func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(&models.GarageBucketInfo{ + ID: "bid", + Keys: []models.BucketKeyInfo{}, // no keys at all + }) + }) + mux.HandleFunc("/v2/GetKeyInfo", func(w http.ResponseWriter, r *http.Request) { + t.Error("GetKeyInfo should not be called when no keys exist") + }) + s3, _ := adminBackedS3(t, mux) + + _, err := s3.getBucketCredentials(context.Background(), bucket) + if err == nil { + t.Fatal("expected error when bucket has no keys, got nil") + } + if !strings.Contains(err.Error(), "no valid credentials") { + t.Errorf("expected 'no valid credentials' in error, got %v", err) + } +} + +func TestGetBucketCredentials_KeyWithoutSecretIsSkipped(t *testing.T) { + bucket := uniqueBucket(t) + secret := "good" + + mux := http.NewServeMux() + mux.HandleFunc("/v2/GetBucketInfo", func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(&models.GarageBucketInfo{ + ID: "bid", + Keys: []models.BucketKeyInfo{ + {AccessKeyID: "FIRST-RW", Permissions: models.BucketKeyPermission{Read: true, Write: true}}, + {AccessKeyID: "SECOND-RW", Permissions: models.BucketKeyPermission{Read: true, Write: true}}, + }, + }) + }) + mux.HandleFunc("/v2/GetKeyInfo", func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Query().Get("id") { + case "FIRST-RW": + _ = json.NewEncoder(w).Encode(&models.GarageKeyInfo{AccessKeyID: "FIRST-RW", SecretAccessKey: nil}) + case "SECOND-RW": + _ = json.NewEncoder(w).Encode(&models.GarageKeyInfo{AccessKeyID: "SECOND-RW", SecretAccessKey: &secret}) + default: + t.Errorf("unexpected GetKeyInfo id: %q", r.URL.Query().Get("id")) + } + }) + s3, _ := adminBackedS3(t, mux) + + creds, err := s3.getBucketCredentials(context.Background(), bucket) + if err != nil { + t.Fatalf("getBucketCredentials: %v", err) + } + v, err := creds.GetWithContext(nil) + if err != nil { + t.Fatalf("creds.GetWithContext: %v", err) + } + if v.AccessKeyID != "SECOND-RW" { + t.Errorf("AccessKeyID = %q, want SECOND-RW (loop must skip FIRST-RW's nil secret)", v.AccessKeyID) + } +} + +func TestGetBucketCredentials_AdminErrorPropagates(t *testing.T) { + bucket := uniqueBucket(t) + + mux := http.NewServeMux() + mux.HandleFunc("/v2/GetBucketInfo", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"error":"boom"}`)) + }) + s3, _ := adminBackedS3(t, mux) + + _, err := s3.getBucketCredentials(context.Background(), bucket) + if err == nil { + t.Fatal("expected error when admin call fails, got nil") + } + if !strings.Contains(err.Error(), "failed to get bucket info") { + t.Errorf("expected wrapped 'failed to get bucket info' error, got %v", err) + } +} + +func TestGetBucketStatistics_HappyPath(t *testing.T) { + bucket := uniqueBucket(t) + + mux := http.NewServeMux() + mux.HandleFunc("/v2/GetBucketInfo", func(w http.ResponseWriter, r *http.Request) { + if !strings.Contains(r.URL.RawQuery, "globalAlias=") { + t.Errorf("expected globalAlias query, got %q", r.URL.RawQuery) + } + _ = json.NewEncoder(w).Encode(&models.GarageBucketInfo{ + ID: "bid", + Objects: 42, + Bytes: 123_456, + }) + }) + s3, _ := adminBackedS3(t, mux) + + stats, err := s3.GetBucketStatistics(context.Background(), bucket) + if err != nil { + t.Fatalf("GetBucketStatistics: %v", err) + } + if stats.ObjectCount != 42 || stats.TotalSize != 123_456 { + t.Errorf("got %+v, want ObjectCount=42 TotalSize=123456", stats) + } +} + +func TestGetBucketStatistics_AdminErrorPropagates(t *testing.T) { + bucket := uniqueBucket(t) + + mux := http.NewServeMux() + mux.HandleFunc("/v2/GetBucketInfo", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"error":"no such bucket"}`)) + }) + s3, _ := adminBackedS3(t, mux) + + _, err := s3.GetBucketStatistics(context.Background(), bucket) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "failed to get bucket info") { + t.Errorf("expected 'failed to get bucket info' wrap, got %v", err) + } +} diff --git a/backend/main.go b/backend/main.go index 300270f..d2a7f77 100644 --- a/backend/main.go +++ b/backend/main.go @@ -5,17 +5,22 @@ import ( "fmt" "os" "os/signal" + "runtime" + "runtime/debug" "syscall" + "time" "Noooste/garage-ui/internal/auth" "Noooste/garage-ui/internal/config" "Noooste/garage-ui/internal/handlers" + appmw "Noooste/garage-ui/internal/middleware" "Noooste/garage-ui/internal/routes" "Noooste/garage-ui/internal/services" "Noooste/garage-ui/pkg/logger" "github.com/gofiber/fiber/v3" "github.com/gofiber/fiber/v3/middleware/recover" + "github.com/rs/zerolog/log" ) // @title Garage UI API @@ -78,6 +83,7 @@ func main() { logger.Info(). Str("config_path", *configPath). Str("version", version). + Str("go_version", runtime.Version()). Str("environment", cfg.Server.Environment). Msg("Starting Garage UI Backend") @@ -140,15 +146,29 @@ func main() { // Create Fiber app with configuration app := fiber.New(fiber.Config{ - AppName: "Garage UI Backend v" + version, + AppName: "Garage UI Backend | Version: " + version, BodyLimit: int(maxBodySize), ReadBufferSize: readBufferSize, WriteBufferSize: writeBufferSize, ErrorHandler: customErrorHandler, }) - // Apply global middleware - app.Use(recover.New()) // Panic recovery + // Apply global middleware (order matters): + // 1. recover — must be outermost so panics become 500s. + // 2. RequestID — mints/reads X-Request-ID before any logger needs it. + // 3. Logging — builds per-request zerolog logger + emits access log. + // Auth middleware is installed per-route inside routes.SetupRoutes. + app.Use(recover.New(recover.Config{ + EnableStackTrace: true, + StackTraceHandler: func(c fiber.Ctx, e interface{}) { + logger.FromCtx(c.Context()).Error(). + Interface("panic", e). + Bytes("stack", debug.Stack()). + Msg("panic_recovered") + }, + })) + app.Use(appmw.RequestID()) + app.Use(appmw.Logging(log.Logger)) // Setup routes logger.Info().Msg("Setting up routes") @@ -178,38 +198,38 @@ func main() { } }() - // Wait for interrupt signal to gracefully shutdown the server + // Wait for interrupt signal to gracefully shutdown the server. quit := make(chan os.Signal, 1) signal.Notify(quit, os.Interrupt, syscall.SIGTERM) - <-quit + sig := <-quit - logger.Info().Msg("Shutting down server") + logger.Info().Str("signal", sig.String()).Msg("Shutting down server") + shutdownStart := time.Now() if err := app.Shutdown(); err != nil { logger.Fatal().Err(err).Msg("Server shutdown failed") } - logger.Info().Msg("Server stopped gracefully") + logger.Info(). + Dur("shutdown_duration", time.Since(shutdownStart)). + Msg("Server stopped gracefully") } -// customErrorHandler handles errors globally +// customErrorHandler handles errors globally. It uses the per-request logger +// from c.Context() so request_id / user_id attach automatically, and it +// demotes expected 4xx responses to warn (5xx stays at error). func customErrorHandler(c fiber.Ctx, err error) error { - // Default to 500 Internal Server Error code := fiber.StatusInternalServerError - - // Check if it's a Fiber error if e, ok := err.(*fiber.Error); ok { code = e.Code } - // Log the error - logger.Error(). - Err(err). - Int("status_code", code). - Str("method", c.Method()). - Str("path", c.Path()). - Msg("Request error") + l := logger.FromCtx(c.Context()) + evt := l.Error() + if code >= 400 && code < 500 { + evt = l.Warn() + } + evt.Err(err).Int("status_code", code).Msg("request_error") - // Return JSON error response return c.Status(code).JSON(fiber.Map{ "success": false, "error": fiber.Map{ diff --git a/backend/pkg/logger/ctx.go b/backend/pkg/logger/ctx.go new file mode 100644 index 0000000..9a643e3 --- /dev/null +++ b/backend/pkg/logger/ctx.go @@ -0,0 +1,30 @@ +package logger + +import ( + "context" + + "github.com/rs/zerolog" +) + +// ctxKey is unexported so other packages can't collide. +type ctxKey struct{} + +// IntoCtx returns a new context carrying the given logger. Retrieve it later +// with FromCtx. Middleware that builds a per-request logger should call this +// once per request. +func IntoCtx(ctx context.Context, l zerolog.Logger) context.Context { + return context.WithValue(ctx, ctxKey{}, l) +} + +// FromCtx returns the logger bound to ctx. If no logger is bound (e.g. the +// call is outside any middleware, or ctx is nil), it returns the global +// logger. Never returns nil. +func FromCtx(ctx context.Context) *zerolog.Logger { + if ctx != nil { + if l, ok := ctx.Value(ctxKey{}).(zerolog.Logger); ok { + return &l + } + } + l := Get().Logger + return &l +} diff --git a/backend/pkg/logger/ctx_test.go b/backend/pkg/logger/ctx_test.go new file mode 100644 index 0000000..4d12032 --- /dev/null +++ b/backend/pkg/logger/ctx_test.go @@ -0,0 +1,42 @@ +package logger + +import ( + "bytes" + "context" + "encoding/json" + "strings" + "testing" + + "github.com/rs/zerolog" +) + +func TestFromCtx_ReturnsBoundLogger(t *testing.T) { + var buf bytes.Buffer + l := zerolog.New(&buf).With().Str("request_id", "req-1").Logger() + + ctx := IntoCtx(context.Background(), l) + got := FromCtx(ctx) + got.Info().Msg("hello") + + var parsed map[string]any + line := strings.TrimSpace(buf.String()) + if err := json.Unmarshal([]byte(line), &parsed); err != nil { + t.Fatalf("not JSON: %v — %s", err, line) + } + if parsed["request_id"] != "req-1" { + t.Errorf("request_id = %v, want req-1", parsed["request_id"]) + } + if parsed["message"] != "hello" { + t.Errorf("message = %v, want hello", parsed["message"]) + } +} + +func TestFromCtx_NoLoggerFallsBackToDisabled(t *testing.T) { + defer func() { + if r := recover(); r != nil { + t.Fatalf("FromCtx panicked: %v", r) + } + }() + l := FromCtx(context.Background()) + l.Info().Msg("should not panic") +} diff --git a/backend/pkg/logger/logger_test.go b/backend/pkg/logger/logger_test.go new file mode 100644 index 0000000..aea314b --- /dev/null +++ b/backend/pkg/logger/logger_test.go @@ -0,0 +1,239 @@ +package logger + +import ( + "bufio" + "encoding/json" + "io" + "os" + "strings" + "sync" + "testing" +) + +// serializeLoggerTests guards the global mutations (os.Stdout, globalLogger, +// zerolog global). These tests cannot run in parallel with each other. +var serializeLoggerTests sync.Mutex + +// captureStdout swaps os.Stdout for a pipe, calls fn, restores stdout, and +// returns everything written during fn. +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("os.Pipe: %v", err) + } + + old := os.Stdout + os.Stdout = w + t.Cleanup(func() { os.Stdout = old }) + + // Run fn and close writer so the reader unblocks. + doneWrite := make(chan struct{}) + go func() { + fn() + _ = w.Close() + close(doneWrite) + }() + + var buf strings.Builder + scanner := bufio.NewScanner(r) + scanner.Buffer(make([]byte, 64*1024), 1024*1024) + for scanner.Scan() { + buf.WriteString(scanner.Text()) + buf.WriteByte('\n') + } + // Drain any residual (shouldn't happen after Close, but safe): + _, _ = io.Copy(io.Discard, r) + <-doneWrite + return buf.String() +} + +func TestInit_JSONFormatProducesParseableOutput(t *testing.T) { + serializeLoggerTests.Lock() + defer serializeLoggerTests.Unlock() + + out := captureStdout(t, func() { + Init(Config{Level: "info", Format: "json"}) + Info().Str("user", "alice").Msg("hello") + }) + + // Find the first non-empty line; parse as JSON. + var line string + for l := range strings.SplitSeq(out, "\n") { + if strings.TrimSpace(l) != "" { + line = l + break + } + } + if line == "" { + t.Fatalf("no log output captured; stdout = %q", out) + } + + var parsed map[string]any + if err := json.Unmarshal([]byte(line), &parsed); err != nil { + t.Fatalf("log line is not valid JSON: %v\nline: %s", err, line) + } + + // Field assertions — zerolog uses "message" for the msg and "level" for level. + if got, _ := parsed["message"].(string); got != "hello" { + t.Errorf("message = %v, want hello", parsed["message"]) + } + if got, _ := parsed["user"].(string); got != "alice" { + t.Errorf("user field = %v, want alice", parsed["user"]) + } + if got, _ := parsed["level"].(string); got != "info" { + t.Errorf("level = %v, want info", parsed["level"]) + } + if _, ok := parsed["time"]; !ok { + t.Errorf("expected time field; got keys %v", keysOf(parsed)) + } + if _, ok := parsed["caller"]; !ok { + t.Errorf("expected caller field; got keys %v", keysOf(parsed)) + } +} + +func TestInit_LevelFilterDropsBelowThreshold(t *testing.T) { + serializeLoggerTests.Lock() + defer serializeLoggerTests.Unlock() + + out := captureStdout(t, func() { + Init(Config{Level: "warn", Format: "json"}) + Debug().Msg("debug-dropped") + Info().Msg("info-dropped") + Warn().Msg("warn-kept") + Error().Msg("error-kept") + }) + + if strings.Contains(out, "debug-dropped") { + t.Errorf("debug event leaked through warn filter: %s", out) + } + if strings.Contains(out, "info-dropped") { + t.Errorf("info event leaked through warn filter: %s", out) + } + if !strings.Contains(out, "warn-kept") { + t.Errorf("warn event missing: %s", out) + } + if !strings.Contains(out, "error-kept") { + t.Errorf("error event missing: %s", out) + } +} + +func TestInit_UnknownLevelDefaultsToInfo(t *testing.T) { + serializeLoggerTests.Lock() + defer serializeLoggerTests.Unlock() + + out := captureStdout(t, func() { + Init(Config{Level: "gibberish", Format: "json"}) + Debug().Msg("debug-should-be-dropped") + Info().Msg("info-should-appear") + }) + + if strings.Contains(out, "debug-should-be-dropped") { + t.Errorf("debug leaked at default info level: %s", out) + } + if !strings.Contains(out, "info-should-appear") { + t.Errorf("info missing at default info level: %s", out) + } +} + +func TestInit_TextFormatDoesNotCrashAndIsNotJSON(t *testing.T) { + serializeLoggerTests.Lock() + defer serializeLoggerTests.Unlock() + + out := captureStdout(t, func() { + Init(Config{Level: "info", Format: "text"}) + Info().Str("k", "v").Msg("plain") + }) + + if !strings.Contains(out, "plain") { + t.Errorf("text output missing message: %s", out) + } + // Console writer output is ANSI-colored key=value form, not JSON. + var parsed map[string]any + if json.Unmarshal([]byte(strings.Split(out, "\n")[0]), &parsed) == nil { + t.Errorf("text format unexpectedly parsed as JSON: %s", out) + } +} + +func TestGet_AutoInitializesWhenUnused(t *testing.T) { + serializeLoggerTests.Lock() + defer serializeLoggerTests.Unlock() + + // Forcibly clear the global so Get() hits the lazy-init branch. + globalLogger = nil + + l := Get() + if l == nil { + t.Fatal("Get() returned nil; lazy init did not run") + } + if globalLogger == nil { + t.Fatal("globalLogger still nil after Get()") + } +} + +func TestWithComponent_AddsComponentField(t *testing.T) { + serializeLoggerTests.Lock() + defer serializeLoggerTests.Unlock() + + out := captureStdout(t, func() { + Init(Config{Level: "info", Format: "json"}) + comp := WithComponent("buckets") + comp.Info().Msg("tagged") + }) + + line := firstNonEmptyLine(out) + var parsed map[string]any + if err := json.Unmarshal([]byte(line), &parsed); err != nil { + t.Fatalf("not JSON: %v — %s", err, line) + } + if got, _ := parsed["component"].(string); got != "buckets" { + t.Errorf("component = %v, want buckets", parsed["component"]) + } +} + +func TestLogger_WithContext_AddsFields(t *testing.T) { + serializeLoggerTests.Lock() + defer serializeLoggerTests.Unlock() + + out := captureStdout(t, func() { + Init(Config{Level: "info", Format: "json"}) + l := Get().WithContext(map[string]any{ + "request_id": "req-42", + "attempt": 2, + }) + l.Info().Msg("ctx") + }) + + line := firstNonEmptyLine(out) + var parsed map[string]any + if err := json.Unmarshal([]byte(line), &parsed); err != nil { + t.Fatalf("not JSON: %v — %s", err, line) + } + if parsed["request_id"] != "req-42" { + t.Errorf("request_id = %v", parsed["request_id"]) + } + // JSON numbers decode to float64. + if got, _ := parsed["attempt"].(float64); got != 2 { + t.Errorf("attempt = %v, want 2", parsed["attempt"]) + } +} + +// --- helpers --- + +func firstNonEmptyLine(s string) string { + for l := range strings.SplitSeq(s, "\n") { + if strings.TrimSpace(l) != "" { + return l + } + } + return "" +} + +func keysOf(m map[string]any) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + return out +} diff --git a/backend/pkg/logger/redact.go b/backend/pkg/logger/redact.go new file mode 100644 index 0000000..56e0380 --- /dev/null +++ b/backend/pkg/logger/redact.go @@ -0,0 +1,22 @@ +package logger + +// redactThreshold is the minimum length at which partial visibility is shown. +// Below this, the entire value is replaced with "***" because first-4/last-4 +// would leak too much of short strings. +const redactThreshold = 12 + +// RedactKey returns a partially-visible form of a non-secret identifier +// (access key ID, user ID, etc.) showing first 4 and last 4 characters. +// Shorter values are fully redacted to avoid over-exposure. +func RedactKey(s string) string { + if len(s) < redactThreshold { + return "***" + } + return s[:4] + "…" + s[len(s)-4:] +} + +// RedactToken returns "***" for any secret (passwords, bearer tokens, JWT, +// client secrets). Secrets must never be partially visible in logs. +func RedactToken(s string) string { + return "***" +} diff --git a/backend/pkg/logger/redact_test.go b/backend/pkg/logger/redact_test.go new file mode 100644 index 0000000..d78467f --- /dev/null +++ b/backend/pkg/logger/redact_test.go @@ -0,0 +1,32 @@ +package logger + +import "testing" + +func TestRedactKey(t *testing.T) { + cases := []struct { + name string + in string + want string + }{ + {"empty", "", "***"}, + {"short", "abc", "***"}, + {"just-under-threshold", "abcdefghijk", "***"}, // 11 chars + {"at-threshold", "abcdefghijkl", "abcd…ijkl"}, // 12 chars + {"long", "GK5a9bfcdefghijklzW9q", "GK5a…zW9q"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := RedactKey(tc.in); got != tc.want { + t.Errorf("RedactKey(%q) = %q, want %q", tc.in, got, tc.want) + } + }) + } +} + +func TestRedactToken_AlwaysStars(t *testing.T) { + for _, in := range []string{"", "abc", "very-long-secret-token-value"} { + if got := RedactToken(in); got != "***" { + t.Errorf("RedactToken(%q) = %q, want ***", in, got) + } + } +} diff --git a/backend/pkg/utils/cache_test.go b/backend/pkg/utils/cache_test.go new file mode 100644 index 0000000..28eadb7 --- /dev/null +++ b/backend/pkg/utils/cache_test.go @@ -0,0 +1,129 @@ +package utils + +import ( + "fmt" + "sync" + "testing" + "time" +) + +func TestCache_GetMissReturnsNil(t *testing.T) { + c := NewCache() + if v := c.Get("nope"); v != nil { + t.Errorf("expected nil for missing key, got %v", v) + } +} + +func TestCache_SetThenGetReturnsValue(t *testing.T) { + c := NewCache() + c.Set("k", "v", time.Minute) + got := c.Get("k") + if got != "v" { + t.Errorf("Get(k) = %v, want v", got) + } +} + +func TestCache_SetWithDifferentTypes(t *testing.T) { + c := NewCache() + c.Set("str", "hello", time.Minute) + c.Set("int", 42, time.Minute) + c.Set("slice", []int{1, 2, 3}, time.Minute) + + if got := c.Get("str"); got != "hello" { + t.Errorf("str: got %v", got) + } + if got := c.Get("int"); got != 42 { + t.Errorf("int: got %v", got) + } + if got, ok := c.Get("slice").([]int); !ok || len(got) != 3 { + t.Errorf("slice: got %v", c.Get("slice")) + } +} + +func TestCache_GetExpiredReturnsNil(t *testing.T) { + c := NewCache() + c.Set("k", "v", 10*time.Millisecond) + time.Sleep(25 * time.Millisecond) + if got := c.Get("k"); got != nil { + t.Errorf("expected nil after TTL, got %v", got) + } +} + +func TestCache_DeleteRemovesItem(t *testing.T) { + c := NewCache() + c.Set("k", "v", time.Minute) + c.Delete("k") + if got := c.Get("k"); got != nil { + t.Errorf("expected nil after Delete, got %v", got) + } +} + +func TestCache_DeleteMissingKeyIsNoOp(t *testing.T) { + c := NewCache() + // Should not panic or error. + c.Delete("never-set") +} + +func TestCache_ClearRemovesAllItems(t *testing.T) { + c := NewCache() + c.Set("a", 1, time.Minute) + c.Set("b", 2, time.Minute) + c.Set("c", 3, time.Minute) + + c.Clear() + + if c.Get("a") != nil || c.Get("b") != nil || c.Get("c") != nil { + t.Errorf("expected all items cleared") + } +} + +func TestCache_SetOverwrites(t *testing.T) { + c := NewCache() + c.Set("k", "v1", time.Minute) + c.Set("k", "v2", time.Minute) + if got := c.Get("k"); got != "v2" { + t.Errorf("expected v2 after overwrite, got %v", got) + } +} + +// TestCache_ConcurrentAccess exercises the RWMutex under load. Run with +// `go test -race` to catch data races. Uses bounded concurrency so the test +// stays deterministic. +func TestCache_ConcurrentAccess(t *testing.T) { + c := NewCache() + const goroutines = 50 + const opsPerGoroutine = 100 + + var wg sync.WaitGroup + wg.Add(goroutines) + + for g := range goroutines { + go func(id int) { + defer wg.Done() + for i := range opsPerGoroutine { + key := fmt.Sprintf("k%d", (id+i)%10) + c.Set(key, i, time.Minute) + _ = c.Get(key) + if i%10 == 0 { + c.Delete(key) + } + } + }(g) + } + + wg.Wait() + // If we got here without a panic and `-race` is clean, the RWMutex is + // protecting the map correctly. +} + +// TestGlobalCache_IsUsable is a smoke test for the package-level var. +// It doesn't Clear() afterwards because the global is shared state that +// other packages may depend on at test time. +func TestGlobalCache_IsUsable(t *testing.T) { + key := "stage2-smoke-key" + GlobalCache.Set(key, "x", time.Minute) + t.Cleanup(func() { GlobalCache.Delete(key) }) + if got := GlobalCache.Get(key); got != "x" { + t.Errorf("GlobalCache.Get = %v, want x", got) + } +} diff --git a/backend/pkg/utils/retry.go b/backend/pkg/utils/retry.go index bb632ca..027d61a 100644 --- a/backend/pkg/utils/retry.go +++ b/backend/pkg/utils/retry.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "net" + "strings" "syscall" "time" ) @@ -50,6 +51,15 @@ func IsConnectionRefused(err error) bool { return true } + // Some HTTP clients (e.g. azuretls) collapse the syscall error into a + // plain string before returning. Fall back to substring matching so the + // retry path still triggers for those wrappers. "connectex" is the + // Windows variant emitted by the Go runtime. + msg := err.Error() + if strings.Contains(msg, "connection refused") || strings.Contains(msg, "actively refused") { + return true + } + return false } diff --git a/backend/pkg/utils/retry_test.go b/backend/pkg/utils/retry_test.go new file mode 100644 index 0000000..7166b09 --- /dev/null +++ b/backend/pkg/utils/retry_test.go @@ -0,0 +1,268 @@ +package utils + +import ( + "context" + "errors" + "fmt" + "net" + "strings" + "syscall" + "testing" + "time" +) + +func TestIsConnectionRefused(t *testing.T) { + tests := []struct { + name string + err error + want bool + }{ + { + name: "nil error returns false", + err: nil, + want: false, + }, + { + name: "unrelated error returns false", + err: errors.New("something else went wrong"), + want: false, + }, + { + name: "bare ECONNREFUSED returns true (fallback errors.Is branch)", + err: syscall.ECONNREFUSED, + want: true, + }, + { + name: "wrapped ECONNREFUSED returns true (fallback errors.Is branch)", + err: fmt.Errorf("context: %w", syscall.ECONNREFUSED), + want: true, + }, + { + name: "OpError dial+ECONNREFUSED returns true (primary branch)", + err: &net.OpError{ + Op: "dial", + Net: "tcp", + Err: syscall.ECONNREFUSED, + }, + want: true, + }, + { + name: "OpError read+ECONNREFUSED returns true (primary branch)", + err: &net.OpError{ + Op: "read", + Net: "tcp", + Err: syscall.ECONNREFUSED, + }, + want: true, + }, + { + name: "OpError dial+ETIMEDOUT returns false (primary branch, wrong errno)", + err: &net.OpError{ + Op: "dial", + Net: "tcp", + Err: syscall.ETIMEDOUT, + }, + want: false, + }, + { + name: "OpError dial+plain error falls through to errors.Is and returns false (inner As miss)", + err: &net.OpError{ + Op: "dial", + Net: "tcp", + Err: errors.New("not a syscall errno"), + }, + want: false, + }, + { + name: "OpError write+ECONNREFUSED returns true via fallback errors.Is", + err: &net.OpError{ + Op: "write", + Net: "tcp", + Err: syscall.ECONNREFUSED, + }, + want: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := IsConnectionRefused(tc.err); got != tc.want { + t.Errorf("IsConnectionRefused(%v) = %v, want %v", tc.err, got, tc.want) + } + }) + } +} + +// fastRetryConfig keeps test runtime in the low-millisecond range. +func fastRetryConfig() RetryConfig { + return RetryConfig{ + MaxRetries: 3, + InitialBackoff: 1 * time.Millisecond, + MaxBackoff: 5 * time.Millisecond, + BackoffFactor: 2.0, + } +} + +func TestRetryWithBackoff_SuccessOnFirstAttempt(t *testing.T) { + calls := 0 + err := RetryWithBackoff(context.Background(), fastRetryConfig(), func() error { + calls++ + return nil + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if calls != 1 { + t.Errorf("want 1 call, got %d", calls) + } +} + +func TestRetryWithBackoff_NonRetryableErrorReturnedImmediately(t *testing.T) { + sentinel := errors.New("boom") + calls := 0 + err := RetryWithBackoff(context.Background(), fastRetryConfig(), func() error { + calls++ + return sentinel + }) + if !errors.Is(err, sentinel) { + t.Errorf("want wrapped sentinel, got %v", err) + } + if calls != 1 { + t.Errorf("want 1 call (no retry on non-conn-refused), got %d", calls) + } +} + +func TestRetryWithBackoff_SuccessAfterTransientRefusals(t *testing.T) { + cfg := fastRetryConfig() + cfg.MaxRetries = 5 // allow up to 6 attempts + calls := 0 + err := RetryWithBackoff(context.Background(), cfg, func() error { + calls++ + if calls < 3 { + return syscall.ECONNREFUSED + } + return nil + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if calls != 3 { + t.Errorf("want 3 calls (2 failures + 1 success), got %d", calls) + } +} + +func TestRetryWithBackoff_MaxRetriesExceededReturnsWrappedError(t *testing.T) { + cfg := fastRetryConfig() + cfg.MaxRetries = 2 // 3 total attempts (attempt 0, 1, 2) + calls := 0 + err := RetryWithBackoff(context.Background(), cfg, func() error { + calls++ + return syscall.ECONNREFUSED + }) + if err == nil { + t.Fatal("expected error after exhausting retries, got nil") + } + if !errors.Is(err, syscall.ECONNREFUSED) { + t.Errorf("expected wrapped ECONNREFUSED, got %v", err) + } + // The loop runs attempt = 0..MaxRetries inclusive. + if calls != cfg.MaxRetries+1 { + t.Errorf("want %d calls, got %d", cfg.MaxRetries+1, calls) + } + // Error message includes the retry count for operator diagnostics. + if !containsAll(err.Error(), "max retries", "2") { + t.Errorf("error message missing retry count: %q", err.Error()) + } +} + +func TestRetryWithBackoff_ZeroMaxRetriesReturnsImmediately(t *testing.T) { + cfg := RetryConfig{ + MaxRetries: 0, + InitialBackoff: 1 * time.Second, // large on purpose; must not sleep + MaxBackoff: 5 * time.Second, + BackoffFactor: 2.0, + } + calls := 0 + start := time.Now() + err := RetryWithBackoff(context.Background(), cfg, func() error { + calls++ + return syscall.ECONNREFUSED + }) + elapsed := time.Since(start) + + if err == nil { + t.Fatal("expected error, got nil") + } + if !errors.Is(err, syscall.ECONNREFUSED) { + t.Errorf("expected wrapped ECONNREFUSED, got %v", err) + } + if calls != 1 { + t.Errorf("want 1 call (no retry budget), got %d", calls) + } + // The only sleep would be after the attempt, but attempt == MaxRetries is + // short-circuited before the sleep select. So total runtime must be well + // under InitialBackoff. + if elapsed >= 500*time.Millisecond { + t.Errorf("no-retry path should not have slept; elapsed %v", elapsed) + } +} + +func TestRetryWithBackoff_ContextCancelledDuringBackoff(t *testing.T) { + // Use a slow backoff so cancellation is guaranteed to land during the sleep. + cfg := RetryConfig{ + MaxRetries: 5, + InitialBackoff: 50 * time.Millisecond, + MaxBackoff: 1 * time.Second, + BackoffFactor: 2.0, + } + ctx, cancel := context.WithCancel(context.Background()) + // Cancel shortly after the first failed attempt starts its backoff. + go func() { + time.Sleep(10 * time.Millisecond) + cancel() + }() + calls := 0 + err := RetryWithBackoff(ctx, cfg, func() error { + calls++ + return syscall.ECONNREFUSED + }) + if err == nil { + t.Fatal("expected error from cancelled context, got nil") + } + if !errors.Is(err, context.Canceled) { + t.Errorf("expected wrapped context.Canceled, got %v", err) + } + if calls < 1 { + t.Errorf("expected at least 1 call before cancellation, got %d", calls) + } +} + +func TestRetryWithBackoff_WaitsBetweenAttempts(t *testing.T) { + // Lower-bound timing check — with InitialBackoff=20ms and BackoffFactor=2, + // three failed attempts sleep ~20ms + ~40ms = ~60ms before giving up. + // Assert >= 50ms to absorb scheduler jitter. + cfg := RetryConfig{ + MaxRetries: 2, + InitialBackoff: 20 * time.Millisecond, + MaxBackoff: 100 * time.Millisecond, + BackoffFactor: 2.0, + } + start := time.Now() + _ = RetryWithBackoff(context.Background(), cfg, func() error { + return syscall.ECONNREFUSED + }) + elapsed := time.Since(start) + if elapsed < 50*time.Millisecond { + t.Errorf("expected at least ~60ms of backoff delay, got %v", elapsed) + } +} + +// containsAll reports whether s contains every substring in subs. +func containsAll(s string, subs ...string) bool { + for _, sub := range subs { + if !strings.Contains(s, sub) { + return false + } + } + return true +} diff --git a/backend/tests/smoke/docker-compose.test.yml b/backend/tests/smoke/docker-compose.test.yml new file mode 100644 index 0000000..5c900d5 --- /dev/null +++ b/backend/tests/smoke/docker-compose.test.yml @@ -0,0 +1,39 @@ +--- +services: + garage: + image: dxflrs/garage:v2.1.0 + volumes: + - ./garage.toml:/etc/garage.toml:ro + tmpfs: + - /var/lib/garage/meta + - /var/lib/garage/data + healthcheck: + test: ["CMD", "/garage", "status"] + interval: 2s + timeout: 2s + retries: 30 + start_period: 2s + + backend: + build: + context: ../../.. + dockerfile: Dockerfile + environment: + GARAGE_UI_SERVER_HOST: "0.0.0.0" + GARAGE_UI_SERVER_PORT: "8080" + GARAGE_UI_SERVER_ENVIRONMENT: "development" + GARAGE_UI_GARAGE_ENDPOINT: "garage:3900" + GARAGE_UI_GARAGE_REGION: "garage" + GARAGE_UI_GARAGE_ADMIN_ENDPOINT: "http://garage:3903" + GARAGE_UI_GARAGE_ADMIN_TOKEN: "smoke-admin-token-do-not-use-in-prod" + GARAGE_UI_AUTH_ADMIN_ENABLED: "true" + GARAGE_UI_AUTH_ADMIN_USERNAME: "smokeadmin" + GARAGE_UI_AUTH_ADMIN_PASSWORD: "smokepass" + GARAGE_UI_CORS_ENABLED: "false" + GARAGE_UI_LOGGING_LEVEL: "info" + GARAGE_UI_LOGGING_FORMAT: "json" + ports: + - "127.0.0.1:18080:8080" + depends_on: + garage: + condition: service_healthy diff --git a/backend/tests/smoke/garage.toml b/backend/tests/smoke/garage.toml new file mode 100644 index 0000000..570066b --- /dev/null +++ b/backend/tests/smoke/garage.toml @@ -0,0 +1,23 @@ +metadata_dir = "/var/lib/garage/meta" +data_dir = "/var/lib/garage/data" +db_engine = "lmdb" + +replication_factor = 1 + +rpc_bind_addr = "[::]:3901" +rpc_public_addr = "127.0.0.1:3901" +rpc_secret = "0000000000000000000000000000000000000000000000000000000000000000" + +[s3_api] +s3_region = "garage" +api_bind_addr = "[::]:3900" +root_domain = ".s3.garage" + +[s3_web] +bind_addr = "[::]:3902" +root_domain = ".web.garage" +index = "index.html" + +[admin] +api_bind_addr = "[::]:3903" +admin_token = "smoke-admin-token-do-not-use-in-prod" diff --git a/backend/tests/smoke/smoke_test.go b/backend/tests/smoke/smoke_test.go new file mode 100644 index 0000000..9853bea --- /dev/null +++ b/backend/tests/smoke/smoke_test.go @@ -0,0 +1,388 @@ +//go:build smoke + +package smoke_test + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + "time" +) + +const ( + composeProject = "garage-ui-smoke" + backendBaseURL = "http://127.0.0.1:18080" + adminToken = "smoke-admin-token-do-not-use-in-prod" + adminUsername = "smokeadmin" + adminPassword = "smokepass" + readyTimeout = 90 * time.Second + readyPollEvery = 1 * time.Second +) + +func composeFile(t testing.TB) string { + t.Helper() + _, thisFile, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("runtime.Caller failed") + } + return filepath.Join(filepath.Dir(thisFile), "docker-compose.test.yml") +} + +func runCompose(t testing.TB, args ...string) string { + t.Helper() + full := append([]string{"compose", "-p", composeProject, "-f", composeFile(t)}, args...) + cmd := exec.Command("docker", full...) + var out bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = &out + if err := cmd.Run(); err != nil { + t.Fatalf("docker %s failed: %v\n%s", strings.Join(full, " "), err, out.String()) + } + return out.String() +} + +func composeExec(service string, argv ...string) (string, error) { + full := append([]string{"compose", "-p", composeProject, "-f", mustComposeFile(), "exec", "-T", service}, argv...) + cmd := exec.Command("docker", full...) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return "", fmt.Errorf("docker %s: %w\nstderr:\n%s", strings.Join(full, " "), err, stderr.String()) + } + return stdout.String(), nil +} + +func mustComposeFile() string { + _, thisFile, _, ok := runtime.Caller(0) + if !ok { + panic("runtime.Caller failed") + } + return filepath.Join(filepath.Dir(thisFile), "docker-compose.test.yml") +} + +func waitForHTTP(ctx context.Context, url string, timeout time.Duration) error { + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return err + } + resp, err := http.DefaultClient.Do(req) + if err == nil { + _ = resp.Body.Close() + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + return nil + } + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(readyPollEvery): + } + } + return fmt.Errorf("timeout waiting for %s", url) +} + +func initGarageLayout() error { + nodeOut, err := composeExec("garage", "/garage", "node", "id", "-q") + if err != nil { + return fmt.Errorf("garage node id: %w", err) + } + nodeID := strings.TrimSpace(nodeOut) + if i := strings.Index(nodeID, "@"); i > 0 { + nodeID = nodeID[:i] + } + if nodeID == "" { + return fmt.Errorf("empty node id from garage node id") + } + + if _, err := composeExec("garage", "/garage", "layout", "assign", "-z", "dc1", "-c", "1G", nodeID); err != nil { + return fmt.Errorf("garage layout assign: %w", err) + } + if _, err := composeExec("garage", "/garage", "layout", "apply", "--version", "1"); err != nil { + return fmt.Errorf("garage layout apply: %w", err) + } + return nil +} + +func TestMain(m *testing.M) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + fmt.Fprintln(os.Stderr, "[smoke] bringing up garage (waiting for healthcheck)...") + runComposeNoT("up", "-d", "--wait", "garage") + + cleanup := func() { + fmt.Fprintln(os.Stderr, "[smoke] tearing down compose...") + _ = exec.Command("docker", "compose", "-p", composeProject, "-f", mustComposeFile(), "logs").Run() + _ = exec.Command("docker", "compose", "-p", composeProject, "-f", mustComposeFile(), "down", "-v").Run() + } + + fmt.Fprintln(os.Stderr, "[smoke] initializing garage cluster layout...") + if err := initGarageLayout(); err != nil { + fmt.Fprintf(os.Stderr, "[smoke] layout init failed: %v\n", err) + cleanup() + os.Exit(1) + } + + fmt.Fprintln(os.Stderr, "[smoke] starting backend...") + runComposeNoT("up", "-d", "backend") + + if err := waitForHTTP(ctx, backendBaseURL+"/health", readyTimeout); err != nil { + fmt.Fprintf(os.Stderr, "[smoke] backend not ready: %v\n", err) + cleanup() + os.Exit(1) + } + + code := m.Run() + + if code != 0 { + fmt.Fprintln(os.Stderr, "[smoke] tests failed — dumping compose logs") + logCmd := exec.Command("docker", "compose", "-p", composeProject, "-f", mustComposeFile(), "logs", "--no-color") + logCmd.Stdout = os.Stderr + logCmd.Stderr = os.Stderr + _ = logCmd.Run() + } + + _ = exec.Command("docker", "compose", "-p", composeProject, "-f", mustComposeFile(), "down", "-v").Run() + os.Exit(code) +} + +func runComposeNoT(args ...string) { + full := append([]string{"compose", "-p", composeProject, "-f", mustComposeFile()}, args...) + cmd := exec.Command("docker", full...) + cmd.Stdout = os.Stderr + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + fmt.Fprintf(os.Stderr, "[smoke] docker %s failed: %v\n", strings.Join(full, " "), err) + os.Exit(1) + } +} + +type testState struct { + client *http.Client + token string + bucketName string + objectKey string + sourceBody []byte + accessKeyID string +} + +func (s *testState) do(t *testing.T, method, path string, body io.Reader, contentType string) *http.Response { + t.Helper() + req, err := http.NewRequest(method, backendBaseURL+path, body) + if err != nil { + t.Fatalf("build request: %v", err) + } + if s.token != "" { + req.Header.Set("Authorization", "Bearer "+s.token) + } + if contentType != "" { + req.Header.Set("Content-Type", contentType) + } + resp, err := s.client.Do(req) + if err != nil { + t.Fatalf("%s %s: %v", method, path, err) + } + return resp +} + +func readBody(t *testing.T, resp *http.Response) []byte { + t.Helper() + defer resp.Body.Close() + b, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("read body: %v", err) + } + return b +} + +func TestSmokeGoldenPath(t *testing.T) { + sourcePath := filepath.Join(filepath.Dir(mustComposeFile()), "testdata", "small.bin") + source, err := os.ReadFile(sourcePath) + if err != nil { + t.Fatalf("read fixture: %v", err) + } + + state := &testState{ + client: &http.Client{Timeout: 30 * time.Second}, + bucketName: fmt.Sprintf("smoke-%d", time.Now().UnixNano()), + objectKey: "small.bin", + sourceBody: source, + } + + t.Run("AdminLogin", func(t *testing.T) { + payload := fmt.Sprintf(`{"username":%q,"password":%q}`, adminUsername, adminPassword) + resp := state.do(t, "POST", "/auth/login", strings.NewReader(payload), "application/json") + body := readBody(t, resp) + if resp.StatusCode != 200 { + t.Fatalf("login status = %d, body = %s", resp.StatusCode, body) + } + var parsed struct { + Success bool `json:"success"` + Token string `json:"token"` + } + if err := json.Unmarshal(body, &parsed); err != nil { + t.Fatalf("parse login body: %v, body=%s", err, body) + } + if !parsed.Success || parsed.Token == "" { + t.Fatalf("login did not return token: %s", body) + } + state.token = parsed.Token + }) + + t.Run("CreateBucket", func(t *testing.T) { + if state.token == "" { + t.Skip("login did not succeed") + } + payload := fmt.Sprintf(`{"name":%q}`, state.bucketName) + resp := state.do(t, "POST", "/api/v1/buckets", strings.NewReader(payload), "application/json") + body := readBody(t, resp) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + t.Fatalf("create bucket status = %d, body = %s", resp.StatusCode, body) + } + }) + + t.Run("CreateKey", func(t *testing.T) { + if state.token == "" { + t.Skip("login did not succeed") + } + payload := `{"name":"smoke-key"}` + resp := state.do(t, "POST", "/api/v1/users", strings.NewReader(payload), "application/json") + body := readBody(t, resp) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + t.Fatalf("create key status = %d, body = %s", resp.StatusCode, body) + } + var parsed struct { + Data struct { + AccessKeyID string `json:"accessKeyId"` + } `json:"data"` + } + if err := json.Unmarshal(body, &parsed); err != nil { + t.Fatalf("parse key body: %v, body=%s", err, body) + } + if parsed.Data.AccessKeyID == "" { + t.Fatalf("empty accessKeyId: %s", body) + } + state.accessKeyID = parsed.Data.AccessKeyID + }) + + t.Run("GrantPermission", func(t *testing.T) { + if state.accessKeyID == "" { + t.Skip("key creation did not succeed") + } + payload := fmt.Sprintf( + `{"accessKeyId":%q,"permissions":{"read":true,"write":true,"owner":false}}`, + state.accessKeyID, + ) + path := fmt.Sprintf("/api/v1/buckets/%s/permissions", state.bucketName) + resp := state.do(t, "POST", path, strings.NewReader(payload), "application/json") + body := readBody(t, resp) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + t.Fatalf("grant permission status = %d, body = %s", resp.StatusCode, body) + } + }) + + t.Run("UploadObject", func(t *testing.T) { + if state.token == "" { + t.Skip("login did not succeed") + } + var buf bytes.Buffer + mw := multipart.NewWriter(&buf) + fw, err := mw.CreateFormFile("file", filepath.Base(state.objectKey)) + if err != nil { + t.Fatalf("create form file: %v", err) + } + if _, err := fw.Write(state.sourceBody); err != nil { + t.Fatalf("write form file: %v", err) + } + if err := mw.WriteField("key", state.objectKey); err != nil { + t.Fatalf("write key field: %v", err) + } + if err := mw.Close(); err != nil { + t.Fatalf("close multipart: %v", err) + } + path := fmt.Sprintf("/api/v1/buckets/%s/objects/", state.bucketName) + resp := state.do(t, "POST", path, &buf, mw.FormDataContentType()) + body := readBody(t, resp) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + t.Fatalf("upload status = %d, body = %s", resp.StatusCode, body) + } + }) + + t.Run("ListObjects", func(t *testing.T) { + if state.token == "" { + t.Skip("login did not succeed") + } + path := fmt.Sprintf("/api/v1/buckets/%s/objects/", state.bucketName) + resp := state.do(t, "GET", path, nil, "") + body := readBody(t, resp) + if resp.StatusCode != 200 { + t.Fatalf("list status = %d, body = %s", resp.StatusCode, body) + } + if !bytes.Contains(body, []byte(state.objectKey)) { + t.Fatalf("uploaded key %q not found in list: %s", state.objectKey, body) + } + }) + + t.Run("DownloadObject", func(t *testing.T) { + if state.token == "" { + t.Skip("login did not succeed") + } + path := fmt.Sprintf("/api/v1/buckets/%s/objects/%s", state.bucketName, state.objectKey) + resp := state.do(t, "GET", path, nil, "") + body := readBody(t, resp) + if resp.StatusCode != 200 { + t.Fatalf("download status = %d, body = %s", resp.StatusCode, body) + } + if !bytes.Equal(body, state.sourceBody) { + t.Fatalf("downloaded bytes differ: got %d bytes, want %d bytes", len(body), len(state.sourceBody)) + } + }) + + t.Run("DeleteObject", func(t *testing.T) { + if state.token == "" { + t.Skip("login did not succeed") + } + path := fmt.Sprintf("/api/v1/buckets/%s/objects/%s", state.bucketName, state.objectKey) + resp := state.do(t, "DELETE", path, nil, "") + body := readBody(t, resp) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + t.Fatalf("delete object status = %d, body = %s", resp.StatusCode, body) + } + }) + + t.Run("DeleteBucket", func(t *testing.T) { + if state.token == "" { + t.Skip("login did not succeed") + } + path := fmt.Sprintf("/api/v1/buckets/%s", state.bucketName) + resp := state.do(t, "DELETE", path, nil, "") + body := readBody(t, resp) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + t.Fatalf("delete bucket status = %d, body = %s", resp.StatusCode, body) + } + }) + + t.Run("ListKeys", func(t *testing.T) { + if state.token == "" { + t.Skip("login did not succeed") + } + resp := state.do(t, "GET", "/api/v1/users", nil, "") + body := readBody(t, resp) + if resp.StatusCode != 200 { + t.Fatalf("list users status = %d, body = %s", resp.StatusCode, body) + } + }) +} diff --git a/backend/tests/smoke/testdata/small.bin b/backend/tests/smoke/testdata/small.bin new file mode 100644 index 0000000..4292932 Binary files /dev/null and b/backend/tests/smoke/testdata/small.bin differ diff --git a/config.yaml.example b/config.yaml.example index b0cbda1..f3186ff 100644 --- a/config.yaml.example +++ b/config.yaml.example @@ -57,9 +57,6 @@ auth: # OIDC Provider URLs issuer_url: "https://keycloak.example.com/realms/master" - auth_url: "https://keycloak.example.com/realms/master/protocol/openid-connect/auth" - token_url: "https://keycloak.example.com/realms/master/protocol/openid-connect/token" - userinfo_url: "https://keycloak.example.com/realms/master/protocol/openid-connect/userinfo" # Token validation skip_issuer_check: false diff --git a/helm/garage-ui/README.md b/helm/garage-ui/README.md index d3a2cf9..4bc3cfe 100644 --- a/helm/garage-ui/README.md +++ b/helm/garage-ui/README.md @@ -409,9 +409,6 @@ config: - email - profile issuer_url: "https://auth.example.com/realms/production" - auth_url: "https://auth.example.com/realms/production/protocol/openid-connect/auth" - token_url: "https://auth.example.com/realms/production/protocol/openid-connect/token" - userinfo_url: "https://auth.example.com/realms/production/protocol/openid-connect/userinfo" cookie_secure: true cookie_http_only: true cookie_same_site: "lax" diff --git a/helm/garage-ui/values.schema.json b/helm/garage-ui/values.schema.json index 0b523da..69c9da8 100644 --- a/helm/garage-ui/values.schema.json +++ b/helm/garage-ui/values.schema.json @@ -292,24 +292,6 @@ "pattern": "^https?://", "default": "https://keycloak.example.com/realms/master" }, - "auth_url": { - "type": "string", - "description": "Authorization endpoint URL", - "pattern": "^https?://", - "default": "https://keycloak.example.com/realms/master/protocol/openid-connect/auth" - }, - "token_url": { - "type": "string", - "description": "Token endpoint URL", - "pattern": "^https?://", - "default": "https://keycloak.example.com/realms/master/protocol/openid-connect/token" - }, - "userinfo_url": { - "type": "string", - "description": "User info endpoint URL", - "pattern": "^https?://", - "default": "https://keycloak.example.com/realms/master/protocol/openid-connect/userinfo" - }, "skip_issuer_check": { "type": "boolean", "description": "Skip issuer validation (not recommended for production)", diff --git a/helm/garage-ui/values.yaml b/helm/garage-ui/values.yaml index f0a813c..96951d0 100644 --- a/helm/garage-ui/values.yaml +++ b/helm/garage-ui/values.yaml @@ -77,9 +77,6 @@ config: - profile # OIDC provider endpoints issuer_url: "https://keycloak.example.com/realms/master" - auth_url: "https://keycloak.example.com/realms/master/protocol/openid-connect/auth" - token_url: "https://keycloak.example.com/realms/master/protocol/openid-connect/token" - userinfo_url: "https://keycloak.example.com/realms/master/protocol/openid-connect/userinfo" # Validation settings skip_issuer_check: false skip_expiry_check: false diff --git a/scripts/coverage-gate.sh b/scripts/coverage-gate.sh new file mode 100644 index 0000000..58c267c --- /dev/null +++ b/scripts/coverage-gate.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Usage: scripts/coverage-gate.sh +# +# Enforces: +# - Total backend coverage >= 70% (excluding backend/main.go and backend/docs/). +# - Per-package floor >= 70% for each of: +# internal/auth, internal/config, internal/handlers, internal/services. + +if [ "$#" -ne 1 ]; then + echo "usage: $0 " >&2 + exit 2 +fi + +COVERAGE_IN="$1" +TOTAL_FLOOR="70.0" +PER_PKG_FLOOR="70.0" +PER_PKG_GATES=( + "internal/auth" + "internal/config" + "internal/handlers" + "internal/services" +) + +if [ ! -f "$COVERAGE_IN" ]; then + echo "error: coverage file not found: $COVERAGE_IN" >&2 + exit 2 +fi + +FILTERED="coverage-filtered.out" +trap 'rm -f "$FILTERED"' EXIT + +# Keep the mode: line, drop excluded paths. +# Excluded from coverage totals: +# - main.go: entry point, covered transitively by smoke. +# - backend/docs/: generated swagger. +# - mocks/ and *_mock.go: test infrastructure, not production code. +# - services/s3.go: network methods deferred to smoke per parent spec +# (docs/superpowers/specs/2026-04-17-backend-test-suite-design.md §internal/services). +grep -E '^(mode:|.*/)' "$COVERAGE_IN" \ + | grep -v '/main\.go' \ + | grep -v '/docs/' \ + | grep -v '/mocks/' \ + | grep -v '_mock\.go' \ + | grep -v '/services/s3\.go' \ + > "$FILTERED" + +FUNC_OUT=$(cd backend && go tool cover -func="../$FILTERED") +TOTAL_PCT=$(echo "$FUNC_OUT" | awk '/^total:/ {gsub("%",""); print $NF}') + +if [ -z "$TOTAL_PCT" ]; then + echo "error: could not parse total coverage from go tool cover output" >&2 + echo "$FUNC_OUT" >&2 + exit 2 +fi + +echo "== Coverage summary ==" +printf " %-30s %s%%\n" "total" "$TOTAL_PCT" + +FAIL=0 +if awk -v a="$TOTAL_PCT" -v b="$TOTAL_FLOOR" 'BEGIN { exit !(a+0 < b+0) }'; then + echo "FAIL total: ${TOTAL_PCT}% < ${TOTAL_FLOOR}%" + FAIL=1 +fi + +for pkg in "${PER_PKG_GATES[@]}"; do + pkg_pct=$(echo "$FUNC_OUT" \ + | awk -v p="/$pkg/" '$1 ~ p { sub("%","",$NF); sum+=$NF; n++ } END { if (n>0) printf "%.1f", sum/n; else print "n/a" }') + printf " %-30s %s%%\n" "$pkg" "$pkg_pct" + if [ "$pkg_pct" = "n/a" ]; then + echo "FAIL $pkg: no coverage data" + FAIL=1 + continue + fi + if awk -v a="$pkg_pct" -v b="$PER_PKG_FLOOR" 'BEGIN { exit !(a+0 < b+0) }'; then + echo "FAIL $pkg: ${pkg_pct}% < ${PER_PKG_FLOOR}%" + FAIL=1 + fi +done + +if [ "$FAIL" -ne 0 ]; then + exit 1 +fi + +echo "PASS"