diff --git a/scripts/tests/web-base-path-nginx-smoke-test.sh b/scripts/tests/web-base-path-nginx-smoke-test.sh index 8f51b5ec..e701c52c 100755 --- a/scripts/tests/web-base-path-nginx-smoke-test.sh +++ b/scripts/tests/web-base-path-nginx-smoke-test.sh @@ -1,155 +1,155 @@ -#!/bin/sh -set -eu - -# Real container smoke test: runs the actual nginx:alpine entrypoint with the -# repo's nginx template + 20-base-path.sh, then verifies over HTTP that a -# sub-path deployment serves real assets (not the SPA fallback) and redirects -# the bare prefix. This catches routing regressions that a text-only check -# cannot (e.g. assets falling through to index.html). - -if ! command -v docker >/dev/null 2>&1; then - printf '%s\n' 'web-base-path-nginx-smoke-test skipped (docker unavailable)' - exit 0 -fi - -ROOT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd) -NGINX_IMAGE="${NGINX_SMOKE_IMAGE:-nginx:alpine}" -name="skillhub-base-path-smoke-$$" -port=18080 - -tmp=$(mktemp -d) -cleanup() { - docker rm -f "$name" "$name-fixed" >/dev/null 2>&1 || true - rm -rf "$tmp" -} -trap cleanup EXIT - -html="$tmp/html" -mkdir -p "$html/assets" -printf '%s\n' 'INDEX_HTML_MARKER' >"$html/index.html" -printf '%s\n' 'APP_JS_MARKER' >"$html/assets/app.js" - -# The image build chmods the entrypoint scripts; here we mount a copy and make it -# executable, since the nginx entrypoint silently ignores non-executable *.sh. -entrypoint_d="$tmp/entrypoint.d" -mkdir -p "$entrypoint_d" -cp "$ROOT_DIR/web/docker-entrypoint.d/20-base-path.sh" "$entrypoint_d/20-base-path.sh" -chmod +x "$entrypoint_d/20-base-path.sh" - -if ! docker run -d --name "$name" \ - -p "$port:80" \ - -e SKILLHUB_API_UPSTREAM=http://127.0.0.1:9 \ - -e SKILLHUB_TRUST_FORWARDED_PROTO=false \ - -e SKILLHUB_WEB_BASE_PATH=/skillhub/ \ - -v "$html:/usr/share/nginx/html:ro" \ - -v "$ROOT_DIR/web/nginx.conf.template:/etc/nginx/templates/default.conf.template:ro" \ - -v "$entrypoint_d/20-base-path.sh:/docker-entrypoint.d/20-base-path.sh:ro" \ - "$NGINX_IMAGE" >/dev/null 2>&1; then - printf '%s\n' 'web-base-path-nginx-smoke-test skipped (docker run failed, e.g. no image/network)' - exit 0 -fi - -base="http://127.0.0.1:$port" -ready=0 -i=0 -while [ "$i" -lt 30 ]; do - if curl -fsS -o /dev/null "$base/nginx-health" 2>/dev/null; then - ready=1 - break - fi - i=$((i + 1)) - sleep 1 -done -if [ "$ready" -ne 1 ]; then - echo 'nginx did not become ready' >&2 - docker logs "$name" >&2 || true - exit 1 -fi - -# Asset under the sub-path must serve the real file, not the SPA fallback. -asset=$(curl -fsS "$base/skillhub/assets/app.js") -if [ "$asset" != 'APP_JS_MARKER' ]; then - echo "sub-path asset must serve the real file, got: $asset" >&2 - exit 1 -fi - -# App route under the sub-path falls back to index.html (SPA). -index=$(curl -fsS "$base/skillhub/dashboard") -if [ "$index" != 'INDEX_HTML_MARKER' ]; then - echo "sub-path SPA route must serve index.html, got: $index" >&2 - exit 1 -fi - -# The SPA shell must revalidate on every navigation so a deployment upgrade -# cannot leave browsers referencing fingerprinted chunks from the old version. -cache_control=$(curl -fsS -o /dev/null -D - "$base/skillhub/dashboard" \ - | awk 'tolower($1) == "cache-control:" { sub(/^[^:]*:[[:space:]]*/, ""); print }' \ - | tr -d '\r') -if [ "$cache_control" != 'no-cache, must-revalidate' ]; then - echo "SPA routes must require revalidation, got Cache-Control: $cache_control" >&2 - exit 1 -fi - -# Bare prefix redirects to the trailing-slash form. -code=$(curl -s -o /dev/null -w '%{http_code}' "$base/skillhub") -if [ "$code" != '301' ]; then - echo "bare prefix must 301-redirect, got: $code" >&2 - exit 1 -fi -location=$(curl -s -o /dev/null -D - "$base/skillhub" | awk 'tolower($1) == "location:" { print $2 }' | tr -d '\r') -if [ "$location" != '/skillhub/' ]; then - echo "bare prefix redirect must stay relative to preserve an upstream HTTPS scheme, got: $location" >&2 - exit 1 -fi - -docker rm -f "$name" >/dev/null 2>&1 || true - -# Fixed-base image served via the bundled deploy configs: assets are baked under -# /fixed/, a baked-base marker is present, and SKILLHUB_WEB_BASE_PATH is passed as -# an empty string (as compose.release.yml / k8s do). Routing must follow the baked -# base, not fall back to root. Reproduces the reported P1 regression. -fixed_html="$tmp/fixed-html" -mkdir -p "$fixed_html/assets" -printf '%s\n' 'INDEX_HTML_MARKER' >"$fixed_html/index.html" -printf '%s\n' 'FIXED_APP_JS_MARKER' >"$fixed_html/assets/app.js" -baked_file="$tmp/baked-base-path" -printf '%s' '/fixed/' >"$baked_file" -fixed_name="$name-fixed" -fixed_port=18081 - -docker run -d --name "$fixed_name" \ - -p "$fixed_port:80" \ - -e SKILLHUB_API_UPSTREAM=http://127.0.0.1:9 \ - -e SKILLHUB_TRUST_FORWARDED_PROTO=false \ - -e SKILLHUB_WEB_BASE_PATH= \ - -e SKILLHUB_WEB_BAKED_BASE_PATH_FILE=/etc/skillhub/baked-base-path \ - -v "$fixed_html:/usr/share/nginx/html:ro" \ - -v "$baked_file:/etc/skillhub/baked-base-path:ro" \ - -v "$ROOT_DIR/web/nginx.conf.template:/etc/nginx/templates/default.conf.template:ro" \ - -v "$entrypoint_d/20-base-path.sh:/docker-entrypoint.d/20-base-path.sh:ro" \ - "$NGINX_IMAGE" >/dev/null 2>&1 - -fixed_base="http://127.0.0.1:$fixed_port" -ready=0 -i=0 -while [ "$i" -lt 30 ]; do - if curl -fsS -o /dev/null "$fixed_base/nginx-health" 2>/dev/null; then - ready=1 - break - fi - i=$((i + 1)) - sleep 1 -done -if [ "$ready" -ne 1 ]; then - echo 'nginx (fixed-base) did not become ready' >&2 - docker logs "$fixed_name" >&2 || true - exit 1 -fi - -fixed_asset=$(curl -fsS "$fixed_base/fixed/assets/app.js") -if [ "$fixed_asset" != 'FIXED_APP_JS_MARKER' ]; then - echo "fixed-base asset must serve the real file, got: $fixed_asset" >&2 - exit 1 -fi - -printf '%s\n' 'web-base-path-nginx-smoke-test passed' +#!/bin/sh +set -eu + +# Real container smoke test: runs the actual nginx:alpine entrypoint with the +# repo's nginx template + 20-base-path.sh, then verifies over HTTP that a +# sub-path deployment serves real assets (not the SPA fallback) and redirects +# the bare prefix. This catches routing regressions that a text-only check +# cannot (e.g. assets falling through to index.html). + +if ! command -v docker >/dev/null 2>&1; then + printf '%s\n' 'web-base-path-nginx-smoke-test skipped (docker unavailable)' + exit 0 +fi + +ROOT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd) +NGINX_IMAGE="${NGINX_SMOKE_IMAGE:-nginx:alpine}" +name="skillhub-base-path-smoke-$$" +port=18080 + +tmp=$(mktemp -d) +cleanup() { + docker rm -f "$name" "$name-fixed" >/dev/null 2>&1 || true + rm -rf "$tmp" +} +trap cleanup EXIT + +html="$tmp/html" +mkdir -p "$html/assets" +printf '%s\n' 'INDEX_HTML_MARKER' >"$html/index.html" +printf '%s\n' 'APP_JS_MARKER' >"$html/assets/app.js" + +# The image build chmods the entrypoint scripts; here we mount a copy and make it +# executable, since the nginx entrypoint silently ignores non-executable *.sh. +entrypoint_d="$tmp/entrypoint.d" +mkdir -p "$entrypoint_d" +cp "$ROOT_DIR/web/docker-entrypoint.d/20-base-path.sh" "$entrypoint_d/20-base-path.sh" +chmod +x "$entrypoint_d/20-base-path.sh" + +if ! docker run -d --name "$name" \ + -p "$port:80" \ + -e SKILLHUB_API_UPSTREAM=http://127.0.0.1:9 \ + -e SKILLHUB_TRUST_FORWARDED_PROTO=false \ + -e SKILLHUB_WEB_BASE_PATH=/skillhub/ \ + -v "$html:/usr/share/nginx/html:ro" \ + -v "$ROOT_DIR/web/nginx.conf.template:/etc/nginx/templates/default.conf.template:ro" \ + -v "$entrypoint_d/20-base-path.sh:/docker-entrypoint.d/20-base-path.sh:ro" \ + "$NGINX_IMAGE" >/dev/null 2>&1; then + printf '%s\n' 'web-base-path-nginx-smoke-test skipped (docker run failed, e.g. no image/network)' + exit 0 +fi + +base="http://127.0.0.1:$port" +ready=0 +i=0 +while [ "$i" -lt 30 ]; do + if curl -fsS -o /dev/null "$base/nginx-health" 2>/dev/null; then + ready=1 + break + fi + i=$((i + 1)) + sleep 1 +done +if [ "$ready" -ne 1 ]; then + echo 'nginx did not become ready' >&2 + docker logs "$name" >&2 || true + exit 1 +fi + +# Asset under the sub-path must serve the real file, not the SPA fallback. +asset=$(curl -fsS "$base/skillhub/assets/app.js") +if [ "$asset" != 'APP_JS_MARKER' ]; then + echo "sub-path asset must serve the real file, got: $asset" >&2 + exit 1 +fi + +# App route under the sub-path falls back to index.html (SPA). +index=$(curl -fsS "$base/skillhub/dashboard") +if [ "$index" != 'INDEX_HTML_MARKER' ]; then + echo "sub-path SPA route must serve index.html, got: $index" >&2 + exit 1 +fi + +# The SPA shell must revalidate on every navigation so a deployment upgrade +# cannot leave browsers referencing fingerprinted chunks from the old version. +cache_control=$(curl -fsS -o /dev/null -D - "$base/skillhub/dashboard" \ + | awk 'tolower($1) == "cache-control:" { sub(/^[^:]*:[[:space:]]*/, ""); print }' \ + | tr -d '\r') +if [ "$cache_control" != 'no-cache, must-revalidate' ]; then + echo "SPA routes must require revalidation, got Cache-Control: $cache_control" >&2 + exit 1 +fi + +# Bare prefix redirects to the trailing-slash form. +code=$(curl -s -o /dev/null -w '%{http_code}' "$base/skillhub") +if [ "$code" != '301' ]; then + echo "bare prefix must 301-redirect, got: $code" >&2 + exit 1 +fi +location=$(curl -s -o /dev/null -D - "$base/skillhub" | awk 'tolower($1) == "location:" { print $2 }' | tr -d '\r') +if [ "$location" != '/skillhub/' ]; then + echo "bare prefix redirect must stay relative to preserve an upstream HTTPS scheme, got: $location" >&2 + exit 1 +fi + +docker rm -f "$name" >/dev/null 2>&1 || true + +# Fixed-base image served via the bundled deploy configs: assets are baked under +# /fixed/, a baked-base marker is present, and SKILLHUB_WEB_BASE_PATH is passed as +# an empty string (as compose.release.yml / k8s do). Routing must follow the baked +# base, not fall back to root. Reproduces the reported P1 regression. +fixed_html="$tmp/fixed-html" +mkdir -p "$fixed_html/assets" +printf '%s\n' 'INDEX_HTML_MARKER' >"$fixed_html/index.html" +printf '%s\n' 'FIXED_APP_JS_MARKER' >"$fixed_html/assets/app.js" +baked_file="$tmp/baked-base-path" +printf '%s' '/fixed/' >"$baked_file" +fixed_name="$name-fixed" +fixed_port=18081 + +docker run -d --name "$fixed_name" \ + -p "$fixed_port:80" \ + -e SKILLHUB_API_UPSTREAM=http://127.0.0.1:9 \ + -e SKILLHUB_TRUST_FORWARDED_PROTO=false \ + -e SKILLHUB_WEB_BASE_PATH= \ + -e SKILLHUB_WEB_BAKED_BASE_PATH_FILE=/etc/skillhub/baked-base-path \ + -v "$fixed_html:/usr/share/nginx/html:ro" \ + -v "$baked_file:/etc/skillhub/baked-base-path:ro" \ + -v "$ROOT_DIR/web/nginx.conf.template:/etc/nginx/templates/default.conf.template:ro" \ + -v "$entrypoint_d/20-base-path.sh:/docker-entrypoint.d/20-base-path.sh:ro" \ + "$NGINX_IMAGE" >/dev/null 2>&1 + +fixed_base="http://127.0.0.1:$fixed_port" +ready=0 +i=0 +while [ "$i" -lt 30 ]; do + if curl -fsS -o /dev/null "$fixed_base/nginx-health" 2>/dev/null; then + ready=1 + break + fi + i=$((i + 1)) + sleep 1 +done +if [ "$ready" -ne 1 ]; then + echo 'nginx (fixed-base) did not become ready' >&2 + docker logs "$fixed_name" >&2 || true + exit 1 +fi + +fixed_asset=$(curl -fsS "$fixed_base/fixed/assets/app.js") +if [ "$fixed_asset" != 'FIXED_APP_JS_MARKER' ]; then + echo "fixed-base asset must serve the real file, got: $fixed_asset" >&2 + exit 1 +fi + +printf '%s\n' 'web-base-path-nginx-smoke-test passed' diff --git a/web/nginx.conf.template b/web/nginx.conf.template index 7f9d886f..d66beb2c 100644 --- a/web/nginx.conf.template +++ b/web/nginx.conf.template @@ -1,93 +1,93 @@ -server_tokens off; -server { - listen 80; - server_name _; - root /usr/share/nginx/html; - index index.html; - - client_max_body_size 100M; - - gzip on; - gzip_types text/plain text/css application/json application/javascript text/xml; - gzip_min_length 1000; - - # Ignore client-supplied forwarded proto by default. Operators may explicitly trust a - # sanitizing upstream proxy; only canonical http/https values are then accepted. - set $proxy_x_forwarded_proto $scheme; - set $forwarded_proto_source "${SKILLHUB_TRUST_FORWARDED_PROTO}:$http_x_forwarded_proto"; - if ($forwarded_proto_source ~* "^true:https$") { - set $proxy_x_forwarded_proto https; - } - if ($forwarded_proto_source ~* "^true:http$") { - set $proxy_x_forwarded_proto http; - } - - # Sub-path routing is generated by docker-entrypoint.d/20-base-path.sh. The glob - # tolerates its absence (root deployment or config-only tests); the default below - # keeps $skillhub_forwarded_prefix defined even when the file is not present. - set $skillhub_forwarded_prefix ""; - include /etc/nginx/skillhub-base-path*.conf; - - location / { - add_header Cache-Control "no-cache, must-revalidate" always; - try_files $uri $uri/ /index.html; - } - - location /api/ { - proxy_pass ${SKILLHUB_API_UPSTREAM}; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; - proxy_set_header X-Forwarded-Prefix $skillhub_forwarded_prefix; - } - - location /oauth2/ { - proxy_pass ${SKILLHUB_API_UPSTREAM}; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; - proxy_set_header X-Forwarded-Prefix $skillhub_forwarded_prefix; - } - - location /login/oauth2/ { - proxy_pass ${SKILLHUB_API_UPSTREAM}; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; - proxy_set_header X-Forwarded-Prefix $skillhub_forwarded_prefix; - } - - location /.well-known/ { - proxy_pass ${SKILLHUB_API_UPSTREAM}; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; - proxy_set_header X-Forwarded-Prefix $skillhub_forwarded_prefix; - } - - location /assets/ { - expires 1y; - add_header Cache-Control "public, immutable"; - } - - location = /registry/skill.md { - default_type text/plain; - add_header Content-Disposition "inline"; - add_header X-Content-Type-Options "nosniff"; - try_files $uri =404; - } - - location = /runtime-config.js { - add_header Cache-Control "no-store"; - try_files $uri =404; - } - - location /nginx-health { - return 200 'ok'; - add_header Content-Type text/plain; - } -} +server_tokens off; +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + client_max_body_size 100M; + + gzip on; + gzip_types text/plain text/css application/json application/javascript text/xml; + gzip_min_length 1000; + + # Ignore client-supplied forwarded proto by default. Operators may explicitly trust a + # sanitizing upstream proxy; only canonical http/https values are then accepted. + set $proxy_x_forwarded_proto $scheme; + set $forwarded_proto_source "${SKILLHUB_TRUST_FORWARDED_PROTO}:$http_x_forwarded_proto"; + if ($forwarded_proto_source ~* "^true:https$") { + set $proxy_x_forwarded_proto https; + } + if ($forwarded_proto_source ~* "^true:http$") { + set $proxy_x_forwarded_proto http; + } + + # Sub-path routing is generated by docker-entrypoint.d/20-base-path.sh. The glob + # tolerates its absence (root deployment or config-only tests); the default below + # keeps $skillhub_forwarded_prefix defined even when the file is not present. + set $skillhub_forwarded_prefix ""; + include /etc/nginx/skillhub-base-path*.conf; + + location / { + add_header Cache-Control "no-cache, must-revalidate" always; + try_files $uri $uri/ /index.html; + } + + location /api/ { + proxy_pass ${SKILLHUB_API_UPSTREAM}; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; + proxy_set_header X-Forwarded-Prefix $skillhub_forwarded_prefix; + } + + location /oauth2/ { + proxy_pass ${SKILLHUB_API_UPSTREAM}; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; + proxy_set_header X-Forwarded-Prefix $skillhub_forwarded_prefix; + } + + location /login/oauth2/ { + proxy_pass ${SKILLHUB_API_UPSTREAM}; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; + proxy_set_header X-Forwarded-Prefix $skillhub_forwarded_prefix; + } + + location /.well-known/ { + proxy_pass ${SKILLHUB_API_UPSTREAM}; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; + proxy_set_header X-Forwarded-Prefix $skillhub_forwarded_prefix; + } + + location /assets/ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + + location = /registry/skill.md { + default_type text/plain; + add_header Content-Disposition "inline"; + add_header X-Content-Type-Options "nosniff"; + try_files $uri =404; + } + + location = /runtime-config.js { + add_header Cache-Control "no-store"; + try_files $uri =404; + } + + location /nginx-health { + return 200 'ok'; + add_header Content-Type text/plain; + } +}