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("