@@ -94,7 +94,7 @@ Configure the application via `docker-compose.yml`:
|
||||
| `CHARON_ENV` | `production` | Set to `development` for verbose logging (`CPM_ENV` supported for backward compatibility). |
|
||||
| `CHARON_HTTP_PORT` | `8080` | Port for the Web UI (`CPM_HTTP_PORT` supported for backward compatibility). |
|
||||
| `CHARON_DB_PATH` | `/app/data/charon.db` | Path to the SQLite database (`CPM_DB_PATH` supported for backward compatibility). |
|
||||
| `CHARON_CADDY_ADMIN_API` | `http://localhost:2019` | Internal URL for Caddy API (`CPM_CADDY_ADMIN_API` supported for backward compatibility). |
|
||||
| `CHARON_CADDY_ADMIN_API` | `http://localhost:2019` | Internal URL for Caddy API (`CPM_CADDY_ADMIN_API` supported for backward compatibility). Must resolve to an internal allowlisted host on port `2019`. |
|
||||
| `CHARON_CADDY_CONFIG_ROOT` | `/config` | Path to Caddy autosave configuration directory. |
|
||||
| `CHARON_CADDY_LOG_DIR` | `/var/log/caddy` | Directory for Caddy access logs. |
|
||||
| `CHARON_CROWDSEC_LOG_DIR` | `/var/log/crowdsec` | Directory for CrowdSec logs. |
|
||||
@@ -218,6 +218,8 @@ environment:
|
||||
- CPM_CADDY_ADMIN_API=http://your-caddy-host:2019
|
||||
```
|
||||
|
||||
If using a non-localhost internal hostname, add it to `CHARON_SSRF_INTERNAL_HOST_ALLOWLIST`.
|
||||
|
||||
**Warning**: Charon will replace Caddy's entire configuration. Backup first!
|
||||
|
||||
## Performance Tuning
|
||||
|
||||
@@ -32,6 +32,8 @@ services:
|
||||
#- CPM_SECURITY_RATELIMIT_ENABLED=false
|
||||
#- CPM_SECURITY_ACL_ENABLED=false
|
||||
- FEATURE_CERBERUS_ENABLED=true
|
||||
# Docker socket group access: copy docker-compose.override.example.yml
|
||||
# to docker-compose.override.yml and set your host's docker GID.
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro # For local container discovery
|
||||
- crowdsec_data:/app/data/crowdsec
|
||||
|
||||
@@ -27,6 +27,8 @@ services:
|
||||
- FEATURE_CERBERUS_ENABLED=true
|
||||
# Emergency "break-glass" token for security reset when ACL blocks access
|
||||
- CHARON_EMERGENCY_TOKEN=03e4682c1164f0c1cb8e17c99bd1a2d9156b59824dde41af3bb67c513e5c5e92
|
||||
# Docker socket group access: copy docker-compose.override.example.yml
|
||||
# to docker-compose.override.yml and set your host's docker GID.
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
cap_add:
|
||||
|
||||
26
.docker/compose/docker-compose.override.example.yml
Normal file
26
.docker/compose/docker-compose.override.example.yml
Normal file
@@ -0,0 +1,26 @@
|
||||
# Docker Compose override — copy to docker-compose.override.yml to activate.
|
||||
#
|
||||
# Use case: grant the container access to the host Docker socket so that
|
||||
# Charon can discover running containers.
|
||||
#
|
||||
# 1. cp docker-compose.override.example.yml docker-compose.override.yml
|
||||
# 2. Uncomment the service that matches your compose file:
|
||||
# - "charon" for docker-compose.local.yml
|
||||
# - "app" for docker-compose.dev.yml
|
||||
# 3. Replace <GID> with the output of: stat -c '%g' /var/run/docker.sock
|
||||
# 4. docker compose up -d
|
||||
|
||||
services:
|
||||
# Uncomment for docker-compose.local.yml
|
||||
charon:
|
||||
group_add:
|
||||
- "<GID>" # e.g. "988" — run: stat -c '%g' /var/run/docker.sock
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
|
||||
# Uncomment for docker-compose.dev.yml
|
||||
app:
|
||||
group_add:
|
||||
- "<GID>" # e.g. "988" — run: stat -c '%g' /var/run/docker.sock
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
@@ -85,6 +85,7 @@ services:
|
||||
- playwright_data:/app/data
|
||||
- playwright_caddy_data:/data
|
||||
- playwright_caddy_config:/config
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro # For container discovery in tests
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-sf", "http://localhost:8080/api/v1/health"]
|
||||
interval: 5s
|
||||
@@ -111,6 +112,7 @@ services:
|
||||
volumes:
|
||||
- playwright_crowdsec_data:/var/lib/crowdsec/data
|
||||
- playwright_crowdsec_config:/etc/crowdsec
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro # For container discovery in tests
|
||||
healthcheck:
|
||||
test: ["CMD", "cscli", "version"]
|
||||
interval: 10s
|
||||
|
||||
@@ -49,6 +49,8 @@ services:
|
||||
# True tmpfs for E2E test data - fresh on every run, in-memory only
|
||||
# mode=1777 allows any user to write (container runs as non-root)
|
||||
- /app/data:size=100M,mode=1777
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro # For container discovery in tests
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -fsS http://localhost:8080/api/v1/health || exit 1"]
|
||||
interval: 5s
|
||||
|
||||
@@ -27,30 +27,24 @@ get_group_by_gid() {
|
||||
}
|
||||
|
||||
create_group_with_gid() {
|
||||
local gid="$1"
|
||||
local name="$2"
|
||||
|
||||
if command -v addgroup >/dev/null 2>&1; then
|
||||
addgroup -g "$gid" "$name" 2>/dev/null || true
|
||||
addgroup -g "$1" "$2" 2>/dev/null || true
|
||||
return
|
||||
fi
|
||||
|
||||
if command -v groupadd >/dev/null 2>&1; then
|
||||
groupadd -g "$gid" "$name" 2>/dev/null || true
|
||||
groupadd -g "$1" "$2" 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
add_user_to_group() {
|
||||
local user="$1"
|
||||
local group="$2"
|
||||
|
||||
if command -v addgroup >/dev/null 2>&1; then
|
||||
addgroup "$user" "$group" 2>/dev/null || true
|
||||
addgroup "$1" "$2" 2>/dev/null || true
|
||||
return
|
||||
fi
|
||||
|
||||
if command -v usermod >/dev/null 2>&1; then
|
||||
usermod -aG "$group" "$user" 2>/dev/null || true
|
||||
usermod -aG "$2" "$1" 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -142,8 +136,15 @@ if [ -S "/var/run/docker.sock" ] && is_root; then
|
||||
fi
|
||||
fi
|
||||
elif [ -S "/var/run/docker.sock" ]; then
|
||||
echo "Note: Docker socket mounted but container is running non-root; skipping docker.sock group setup."
|
||||
echo " If Docker discovery is needed, run with matching group permissions (e.g., --group-add)"
|
||||
DOCKER_SOCK_GID=$(stat -c '%g' /var/run/docker.sock 2>/dev/null || echo "unknown")
|
||||
echo "Note: Docker socket mounted (GID=$DOCKER_SOCK_GID) but container is running non-root; skipping docker.sock group setup."
|
||||
echo " If Docker discovery is needed, add 'group_add: [\"$DOCKER_SOCK_GID\"]' to your compose service."
|
||||
if [ "$DOCKER_SOCK_GID" = "0" ]; then
|
||||
if [ "${ALLOW_DOCKER_SOCK_GID_0:-false}" != "true" ]; then
|
||||
echo "⚠️ WARNING: Docker socket GID is 0 (root group). group_add: [\"0\"] grants root-group access."
|
||||
echo " Set ALLOW_DOCKER_SOCK_GID_0=true to acknowledge this risk."
|
||||
fi
|
||||
fi
|
||||
else
|
||||
echo "Note: Docker socket not found. Docker container discovery will be unavailable."
|
||||
fi
|
||||
@@ -191,7 +192,7 @@ if command -v cscli >/dev/null; then
|
||||
echo "Initializing persistent CrowdSec configuration..."
|
||||
|
||||
# Check if .dist has content
|
||||
if [ -d "/etc/crowdsec.dist" ] && [ -n "$(ls -A /etc/crowdsec.dist 2>/dev/null)" ]; then
|
||||
if [ -d "/etc/crowdsec.dist" ] && find /etc/crowdsec.dist -mindepth 1 -maxdepth 1 -print -quit 2>/dev/null | grep -q .; then
|
||||
echo "Copying config from /etc/crowdsec.dist..."
|
||||
if ! cp -r /etc/crowdsec.dist/* "$CS_CONFIG_DIR/"; then
|
||||
echo "ERROR: Failed to copy config from /etc/crowdsec.dist"
|
||||
@@ -208,7 +209,7 @@ if command -v cscli >/dev/null; then
|
||||
exit 1
|
||||
fi
|
||||
echo "✓ Successfully initialized config from .dist directory"
|
||||
elif [ -d "/etc/crowdsec" ] && [ ! -L "/etc/crowdsec" ] && [ -n "$(ls -A /etc/crowdsec 2>/dev/null)" ]; then
|
||||
elif [ -d "/etc/crowdsec" ] && [ ! -L "/etc/crowdsec" ] && find /etc/crowdsec -mindepth 1 -maxdepth 1 -print -quit 2>/dev/null | grep -q .; then
|
||||
echo "Copying config from /etc/crowdsec (fallback)..."
|
||||
if ! cp -r /etc/crowdsec/* "$CS_CONFIG_DIR/"; then
|
||||
echo "ERROR: Failed to copy config from /etc/crowdsec (fallback)"
|
||||
@@ -248,7 +249,7 @@ if command -v cscli >/dev/null; then
|
||||
echo "Expected: /etc/crowdsec -> /app/data/crowdsec/config"
|
||||
echo "This indicates a critical build-time issue. Symlink must be created at build time as root."
|
||||
echo "DEBUG: Directory check:"
|
||||
ls -la /etc/ | grep crowdsec || echo " (no crowdsec entry found)"
|
||||
find /etc -mindepth 1 -maxdepth 1 -name '*crowdsec*' -exec ls -ld {} \; 2>/dev/null || echo " (no crowdsec entry found)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
4
.github/agents/Backend_Dev.agent.md
vendored
4
.github/agents/Backend_Dev.agent.md
vendored
File diff suppressed because one or more lines are too long
3
.github/agents/DevOps.agent.md
vendored
3
.github/agents/DevOps.agent.md
vendored
File diff suppressed because one or more lines are too long
3
.github/agents/Doc_Writer.agent.md
vendored
3
.github/agents/Doc_Writer.agent.md
vendored
File diff suppressed because one or more lines are too long
4
.github/agents/Frontend_Dev.agent.md
vendored
4
.github/agents/Frontend_Dev.agent.md
vendored
File diff suppressed because one or more lines are too long
4
.github/agents/Management.agent.md
vendored
4
.github/agents/Management.agent.md
vendored
File diff suppressed because one or more lines are too long
4
.github/agents/Planning.agent.md
vendored
4
.github/agents/Planning.agent.md
vendored
File diff suppressed because one or more lines are too long
4
.github/agents/Playwright_Dev.agent.md
vendored
4
.github/agents/Playwright_Dev.agent.md
vendored
File diff suppressed because one or more lines are too long
4
.github/agents/QA_Security.agent.md
vendored
4
.github/agents/QA_Security.agent.md
vendored
File diff suppressed because one or more lines are too long
3
.github/agents/Supervisor.agent.md
vendored
3
.github/agents/Supervisor.agent.md
vendored
File diff suppressed because one or more lines are too long
55
.github/security-severity-policy.yml
vendored
Normal file
55
.github/security-severity-policy.yml
vendored
Normal file
@@ -0,0 +1,55 @@
|
||||
version: 1
|
||||
effective_date: 2026-02-25
|
||||
scope:
|
||||
- local pre-commit manual security hooks
|
||||
- github actions security workflows
|
||||
|
||||
defaults:
|
||||
blocking:
|
||||
- critical
|
||||
- high
|
||||
medium:
|
||||
mode: risk-based
|
||||
default_action: report
|
||||
require_sla: true
|
||||
default_sla_days: 14
|
||||
escalation:
|
||||
trigger: high-signal class or repeated finding
|
||||
action: require issue + owner + due date
|
||||
low:
|
||||
action: report
|
||||
|
||||
codeql:
|
||||
severity_mapping:
|
||||
error: high_or_critical
|
||||
warning: medium_or_lower
|
||||
note: informational
|
||||
blocking_levels:
|
||||
- error
|
||||
warning_policy:
|
||||
default_action: report
|
||||
escalation_high_signal_rule_ids:
|
||||
- go/request-forgery
|
||||
- js/missing-rate-limiting
|
||||
- js/insecure-randomness
|
||||
|
||||
trivy:
|
||||
blocking_severities:
|
||||
- CRITICAL
|
||||
- HIGH
|
||||
medium_policy:
|
||||
action: report
|
||||
escalation: issue-with-sla
|
||||
|
||||
grype:
|
||||
blocking_severities:
|
||||
- Critical
|
||||
- High
|
||||
medium_policy:
|
||||
action: report
|
||||
escalation: issue-with-sla
|
||||
|
||||
enforcement_contract:
|
||||
codeql_local_vs_ci: "local and ci block on codeql error-level findings only"
|
||||
supply_chain_medium: "medium vulnerabilities are non-blocking by default and require explicit triage"
|
||||
auth_regression_guard: "state-changing routes must remain protected by auth middleware"
|
||||
@@ -32,7 +32,7 @@ cd "${PROJECT_ROOT}"
|
||||
validate_project_structure "backend" "scripts/go-test-coverage.sh" || error_exit "Invalid project structure"
|
||||
|
||||
# Set default environment variables
|
||||
set_default_env "CHARON_MIN_COVERAGE" "85"
|
||||
set_default_env "CHARON_MIN_COVERAGE" "87"
|
||||
set_default_env "PERF_MAX_MS_GETSTATUS_P95" "25ms"
|
||||
set_default_env "PERF_MAX_MS_GETSTATUS_P95_PARALLEL" "50ms"
|
||||
set_default_env "PERF_MAX_MS_LISTDECISIONS_P95" "75ms"
|
||||
|
||||
@@ -32,7 +32,7 @@ cd "${PROJECT_ROOT}"
|
||||
validate_project_structure "frontend" "scripts/frontend-test-coverage.sh" || error_exit "Invalid project structure"
|
||||
|
||||
# Set default environment variables
|
||||
set_default_env "CHARON_MIN_COVERAGE" "85"
|
||||
set_default_env "CHARON_MIN_COVERAGE" "87"
|
||||
|
||||
# Execute the legacy script
|
||||
log_step "EXECUTION" "Running frontend tests with coverage"
|
||||
|
||||
4
.github/workflows/benchmark.yml
vendored
4
.github/workflows/benchmark.yml
vendored
@@ -3,6 +3,8 @@ name: Go Benchmark
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
@@ -33,7 +35,7 @@ jobs:
|
||||
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
|
||||
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
cache-dependency-path: backend/go.sum
|
||||
|
||||
6
.github/workflows/codecov-upload.yml
vendored
6
.github/workflows/codecov-upload.yml
vendored
@@ -3,6 +3,8 @@ name: Upload Coverage to Codecov
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
run_backend:
|
||||
@@ -17,7 +19,7 @@ on:
|
||||
type: boolean
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref_name }}-${{ github.run_id }}
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
@@ -43,7 +45,7 @@ jobs:
|
||||
ref: ${{ github.sha }}
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
|
||||
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
cache-dependency-path: backend/go.sum
|
||||
|
||||
73
.github/workflows/codeql.yml
vendored
73
.github/workflows/codeql.yml
vendored
@@ -4,7 +4,7 @@ on:
|
||||
pull_request:
|
||||
branches: [main, nightly, development]
|
||||
push:
|
||||
branches: [main, nightly, development, 'feature/**', 'fix/**']
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: '0 3 * * 1' # Mondays 03:00 UTC
|
||||
@@ -57,7 +57,7 @@ jobs:
|
||||
|
||||
- name: Setup Go
|
||||
if: matrix.language == 'go'
|
||||
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
|
||||
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6
|
||||
with:
|
||||
go-version: 1.26.0
|
||||
cache-dependency-path: backend/go.sum
|
||||
@@ -122,10 +122,28 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# shellcheck disable=SC2016
|
||||
EFFECTIVE_LEVELS_JQ='[
|
||||
.runs[] as $run
|
||||
| $run.results[]
|
||||
| . as $result
|
||||
| ($run.tool.driver.rules // []) as $rules
|
||||
| ((
|
||||
$result.level
|
||||
// (if (($result.ruleIndex | type) == "number") then ($rules[$result.ruleIndex].defaultConfiguration.level // empty) else empty end)
|
||||
// ([
|
||||
$rules[]?
|
||||
| select((.id // "") == ($result.ruleId // ""))
|
||||
| (.defaultConfiguration.level // empty)
|
||||
][0] // empty)
|
||||
// ""
|
||||
) | ascii_downcase)
|
||||
]'
|
||||
|
||||
echo "Found SARIF file: $SARIF_FILE"
|
||||
ERROR_COUNT=$(jq '[.runs[].results[] | select(.level == "error")] | length' "$SARIF_FILE")
|
||||
WARNING_COUNT=$(jq '[.runs[].results[] | select(.level == "warning")] | length' "$SARIF_FILE")
|
||||
NOTE_COUNT=$(jq '[.runs[].results[] | select(.level == "note")] | length' "$SARIF_FILE")
|
||||
ERROR_COUNT=$(jq -r "${EFFECTIVE_LEVELS_JQ} | map(select(. == \"error\")) | length" "$SARIF_FILE")
|
||||
WARNING_COUNT=$(jq -r "${EFFECTIVE_LEVELS_JQ} | map(select(. == \"warning\")) | length" "$SARIF_FILE")
|
||||
NOTE_COUNT=$(jq -r "${EFFECTIVE_LEVELS_JQ} | map(select(. == \"note\")) | length" "$SARIF_FILE")
|
||||
|
||||
{
|
||||
echo "**Findings:**"
|
||||
@@ -135,14 +153,32 @@ jobs:
|
||||
echo ""
|
||||
|
||||
if [ "$ERROR_COUNT" -gt 0 ]; then
|
||||
echo "❌ **CRITICAL:** High-severity security issues found!"
|
||||
echo "❌ **BLOCKING:** CodeQL error-level security issues found"
|
||||
echo ""
|
||||
echo "### Top Issues:"
|
||||
echo '```'
|
||||
jq -r '.runs[].results[] | select(.level == "error") | "\(.ruleId): \(.message.text)"' "$SARIF_FILE" | head -5
|
||||
# shellcheck disable=SC2016
|
||||
jq -r '
|
||||
.runs[] as $run
|
||||
| $run.results[]
|
||||
| . as $result
|
||||
| ($run.tool.driver.rules // []) as $rules
|
||||
| ((
|
||||
$result.level
|
||||
// (if (($result.ruleIndex | type) == "number") then ($rules[$result.ruleIndex].defaultConfiguration.level // empty) else empty end)
|
||||
// ([
|
||||
$rules[]?
|
||||
| select((.id // "") == ($result.ruleId // ""))
|
||||
| (.defaultConfiguration.level // empty)
|
||||
][0] // empty)
|
||||
// ""
|
||||
) | ascii_downcase) as $effectiveLevel
|
||||
| select($effectiveLevel == "error")
|
||||
| "\($effectiveLevel): \($result.ruleId // \"<unknown-rule>\"): \($result.message.text)"
|
||||
' "$SARIF_FILE" | head -5
|
||||
echo '```'
|
||||
else
|
||||
echo "✅ No high-severity issues found"
|
||||
echo "✅ No blocking CodeQL issues found"
|
||||
fi
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
@@ -169,9 +205,26 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ERROR_COUNT=$(jq '[.runs[].results[] | select(.level == "error")] | length' "$SARIF_FILE")
|
||||
# shellcheck disable=SC2016
|
||||
ERROR_COUNT=$(jq -r '[
|
||||
.runs[] as $run
|
||||
| $run.results[]
|
||||
| . as $result
|
||||
| ($run.tool.driver.rules // []) as $rules
|
||||
| ((
|
||||
$result.level
|
||||
// (if (($result.ruleIndex | type) == "number") then ($rules[$result.ruleIndex].defaultConfiguration.level // empty) else empty end)
|
||||
// ([
|
||||
$rules[]?
|
||||
| select((.id // "") == ($result.ruleId // ""))
|
||||
| (.defaultConfiguration.level // empty)
|
||||
][0] // empty)
|
||||
// ""
|
||||
) | ascii_downcase) as $effectiveLevel
|
||||
| select($effectiveLevel == "error")
|
||||
] | length' "$SARIF_FILE")
|
||||
|
||||
if [ "$ERROR_COUNT" -gt 0 ]; then
|
||||
echo "::error::CodeQL found $ERROR_COUNT high-severity security issues. Fix before merging."
|
||||
echo "::error::CodeQL found $ERROR_COUNT blocking findings (effective-level=error). Fix before merging. Policy: .github/security-severity-policy.yml"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
186
.github/workflows/container-prune.yml
vendored
186
.github/workflows/container-prune.yml
vendored
@@ -5,10 +5,6 @@ on:
|
||||
- cron: '0 3 * * 0' # Weekly: Sundays at 03:00 UTC
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
registries:
|
||||
description: 'Comma-separated registries to prune (ghcr,dockerhub)'
|
||||
required: false
|
||||
default: 'ghcr,dockerhub'
|
||||
keep_days:
|
||||
description: 'Number of days to retain images (unprotected)'
|
||||
required: false
|
||||
@@ -27,16 +23,17 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
prune:
|
||||
prune-ghcr:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
OWNER: ${{ github.repository_owner }}
|
||||
IMAGE_NAME: charon
|
||||
REGISTRIES: ${{ github.event.inputs.registries || 'ghcr,dockerhub' }}
|
||||
KEEP_DAYS: ${{ github.event.inputs.keep_days || '30' }}
|
||||
KEEP_LAST_N: ${{ github.event.inputs.keep_last_n || '30' }}
|
||||
DRY_RUN: ${{ github.event.inputs.dry_run || 'false' }}
|
||||
PROTECTED_REGEX: '["^v","^latest$","^main$","^develop$"]'
|
||||
DRY_RUN: ${{ github.event_name == 'pull_request' && 'true' || github.event.inputs.dry_run || 'false' }}
|
||||
PROTECTED_REGEX: '["^v?[0-9]+\\.[0-9]+\\.[0-9]+$","^latest$","^main$","^develop$"]'
|
||||
PRUNE_UNTAGGED: 'true'
|
||||
PRUNE_SBOM_TAGS: 'true'
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
@@ -45,21 +42,19 @@ jobs:
|
||||
run: |
|
||||
sudo apt-get update && sudo apt-get install -y jq curl
|
||||
|
||||
- name: Run container prune
|
||||
- name: Run GHCR prune
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
run: |
|
||||
chmod +x scripts/prune-container-images.sh
|
||||
./scripts/prune-container-images.sh 2>&1 | tee prune-${{ github.run_id }}.log
|
||||
chmod +x scripts/prune-ghcr.sh
|
||||
./scripts/prune-ghcr.sh 2>&1 | tee prune-ghcr-${{ github.run_id }}.log
|
||||
|
||||
- name: Summarize prune results (space reclaimed)
|
||||
if: ${{ always() }}
|
||||
- name: Summarize GHCR results
|
||||
if: always()
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SUMMARY_FILE=prune-summary.env
|
||||
LOG_FILE=prune-${{ github.run_id }}.log
|
||||
SUMMARY_FILE=prune-summary-ghcr.env
|
||||
LOG_FILE=prune-ghcr-${{ github.run_id }}.log
|
||||
|
||||
human() {
|
||||
local bytes=${1:-0}
|
||||
@@ -67,7 +62,7 @@ jobs:
|
||||
echo "0 B"
|
||||
return
|
||||
fi
|
||||
awk -v b="$bytes" 'function human(x){ split("B KiB MiB GiB TiB",u," "); i=0; while(x>1024){x/=1024;i++} printf "%0.2f %s", x, u[i+1]} END{human(b)}'
|
||||
awk -v b="$bytes" 'BEGIN { split("B KiB MiB GiB TiB",u," "); i=0; x=b; while(x>1024){x/=1024;i++} printf "%0.2f %s", x, u[i+1] }'
|
||||
}
|
||||
|
||||
if [ -f "$SUMMARY_FILE" ]; then
|
||||
@@ -77,34 +72,155 @@ jobs:
|
||||
TOTAL_DELETED_BYTES=$(grep -E '^TOTAL_DELETED_BYTES=' "$SUMMARY_FILE" | cut -d= -f2 || echo 0)
|
||||
|
||||
{
|
||||
echo "## Container prune summary"
|
||||
echo "## GHCR prune summary"
|
||||
echo "- candidates: ${TOTAL_CANDIDATES} (≈ $(human "${TOTAL_CANDIDATES_BYTES}"))"
|
||||
echo "- deleted: ${TOTAL_DELETED} (≈ $(human "${TOTAL_DELETED_BYTES}"))"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
printf 'PRUNE_SUMMARY: candidates=%s candidates_bytes=%s deleted=%s deleted_bytes=%s\n' \
|
||||
"${TOTAL_CANDIDATES}" "${TOTAL_CANDIDATES_BYTES}" "${TOTAL_DELETED}" "${TOTAL_DELETED_BYTES}"
|
||||
echo "Deleted approximately: $(human "${TOTAL_DELETED_BYTES}")"
|
||||
echo "space_saved=$(human "${TOTAL_DELETED_BYTES}")" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
deleted_bytes=$(grep -oE '\( *approx +[0-9]+ bytes\)' "$LOG_FILE" | sed -E 's/.*approx +([0-9]+) bytes.*/\1/' | awk '{s+=$1} END {print s+0}' || true)
|
||||
deleted_count=$(grep -cE 'deleting |DRY RUN: would delete' "$LOG_FILE" || true)
|
||||
|
||||
{
|
||||
echo "## Container prune summary"
|
||||
echo "## GHCR prune summary"
|
||||
echo "- deleted (approx): ${deleted_count} (≈ $(human "${deleted_bytes}"))"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
printf 'PRUNE_SUMMARY: deleted_approx=%s deleted_bytes=%s\n' "${deleted_count}" "${deleted_bytes}"
|
||||
echo "Deleted approximately: $(human "${deleted_bytes}")"
|
||||
echo "space_saved=$(human "${deleted_bytes}")" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Upload prune artifacts
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
- name: Upload GHCR prune artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
|
||||
with:
|
||||
name: prune-log-${{ github.run_id }}
|
||||
name: prune-ghcr-log-${{ github.run_id }}
|
||||
path: |
|
||||
prune-${{ github.run_id }}.log
|
||||
prune-summary.env
|
||||
prune-ghcr-${{ github.run_id }}.log
|
||||
prune-summary-ghcr.env
|
||||
|
||||
prune-dockerhub:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
OWNER: ${{ github.repository_owner }}
|
||||
IMAGE_NAME: charon
|
||||
KEEP_DAYS: ${{ github.event.inputs.keep_days || '30' }}
|
||||
KEEP_LAST_N: ${{ github.event.inputs.keep_last_n || '30' }}
|
||||
DRY_RUN: ${{ github.event_name == 'pull_request' && 'true' || github.event.inputs.dry_run || 'false' }}
|
||||
PROTECTED_REGEX: '["^v?[0-9]+\\.[0-9]+\\.[0-9]+$","^latest$","^main$","^develop$"]'
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
|
||||
- name: Install tools
|
||||
run: |
|
||||
sudo apt-get update && sudo apt-get install -y jq curl
|
||||
|
||||
- name: Run Docker Hub prune
|
||||
env:
|
||||
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
run: |
|
||||
chmod +x scripts/prune-dockerhub.sh
|
||||
./scripts/prune-dockerhub.sh 2>&1 | tee prune-dockerhub-${{ github.run_id }}.log
|
||||
|
||||
- name: Summarize Docker Hub results
|
||||
if: always()
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SUMMARY_FILE=prune-summary-dockerhub.env
|
||||
LOG_FILE=prune-dockerhub-${{ github.run_id }}.log
|
||||
|
||||
human() {
|
||||
local bytes=${1:-0}
|
||||
if [ -z "$bytes" ] || [ "$bytes" -eq 0 ]; then
|
||||
echo "0 B"
|
||||
return
|
||||
fi
|
||||
awk -v b="$bytes" 'BEGIN { split("B KiB MiB GiB TiB",u," "); i=0; x=b; while(x>1024){x/=1024;i++} printf "%0.2f %s", x, u[i+1] }'
|
||||
}
|
||||
|
||||
if [ -f "$SUMMARY_FILE" ]; then
|
||||
TOTAL_CANDIDATES=$(grep -E '^TOTAL_CANDIDATES=' "$SUMMARY_FILE" | cut -d= -f2 || echo 0)
|
||||
TOTAL_CANDIDATES_BYTES=$(grep -E '^TOTAL_CANDIDATES_BYTES=' "$SUMMARY_FILE" | cut -d= -f2 || echo 0)
|
||||
TOTAL_DELETED=$(grep -E '^TOTAL_DELETED=' "$SUMMARY_FILE" | cut -d= -f2 || echo 0)
|
||||
TOTAL_DELETED_BYTES=$(grep -E '^TOTAL_DELETED_BYTES=' "$SUMMARY_FILE" | cut -d= -f2 || echo 0)
|
||||
|
||||
{
|
||||
echo "## Docker Hub prune summary"
|
||||
echo "- candidates: ${TOTAL_CANDIDATES} (≈ $(human "${TOTAL_CANDIDATES_BYTES}"))"
|
||||
echo "- deleted: ${TOTAL_DELETED} (≈ $(human "${TOTAL_DELETED_BYTES}"))"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
deleted_bytes=$(grep -oE '\( *approx +[0-9]+ bytes\)' "$LOG_FILE" | sed -E 's/.*approx +([0-9]+) bytes.*/\1/' | awk '{s+=$1} END {print s+0}' || true)
|
||||
deleted_count=$(grep -cE 'deleting |DRY RUN: would delete' "$LOG_FILE" || true)
|
||||
|
||||
{
|
||||
echo "## Docker Hub prune summary"
|
||||
echo "- deleted (approx): ${deleted_count} (≈ $(human "${deleted_bytes}"))"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
- name: Upload Docker Hub prune artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
|
||||
with:
|
||||
name: prune-dockerhub-log-${{ github.run_id }}
|
||||
path: |
|
||||
prune-dockerhub-${{ github.run_id }}.log
|
||||
prune-summary-dockerhub.env
|
||||
|
||||
summarize:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [prune-ghcr, prune-dockerhub]
|
||||
if: always()
|
||||
steps:
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8
|
||||
with:
|
||||
pattern: prune-*-log-${{ github.run_id }}
|
||||
merge-multiple: true
|
||||
|
||||
- name: Combined summary
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
human() {
|
||||
local bytes=${1:-0}
|
||||
if [ -z "$bytes" ] || [ "$bytes" -eq 0 ]; then
|
||||
echo "0 B"
|
||||
return
|
||||
fi
|
||||
awk -v b="$bytes" 'BEGIN { split("B KiB MiB GiB TiB",u," "); i=0; x=b; while(x>1024){x/=1024;i++} printf "%0.2f %s", x, u[i+1] }'
|
||||
}
|
||||
|
||||
GHCR_CANDIDATES=0 GHCR_CANDIDATES_BYTES=0 GHCR_DELETED=0 GHCR_DELETED_BYTES=0
|
||||
if [ -f prune-summary-ghcr.env ]; then
|
||||
GHCR_CANDIDATES=$(grep -E '^TOTAL_CANDIDATES=' prune-summary-ghcr.env | cut -d= -f2 || echo 0)
|
||||
GHCR_CANDIDATES_BYTES=$(grep -E '^TOTAL_CANDIDATES_BYTES=' prune-summary-ghcr.env | cut -d= -f2 || echo 0)
|
||||
GHCR_DELETED=$(grep -E '^TOTAL_DELETED=' prune-summary-ghcr.env | cut -d= -f2 || echo 0)
|
||||
GHCR_DELETED_BYTES=$(grep -E '^TOTAL_DELETED_BYTES=' prune-summary-ghcr.env | cut -d= -f2 || echo 0)
|
||||
fi
|
||||
|
||||
HUB_CANDIDATES=0 HUB_CANDIDATES_BYTES=0 HUB_DELETED=0 HUB_DELETED_BYTES=0
|
||||
if [ -f prune-summary-dockerhub.env ]; then
|
||||
HUB_CANDIDATES=$(grep -E '^TOTAL_CANDIDATES=' prune-summary-dockerhub.env | cut -d= -f2 || echo 0)
|
||||
HUB_CANDIDATES_BYTES=$(grep -E '^TOTAL_CANDIDATES_BYTES=' prune-summary-dockerhub.env | cut -d= -f2 || echo 0)
|
||||
HUB_DELETED=$(grep -E '^TOTAL_DELETED=' prune-summary-dockerhub.env | cut -d= -f2 || echo 0)
|
||||
HUB_DELETED_BYTES=$(grep -E '^TOTAL_DELETED_BYTES=' prune-summary-dockerhub.env | cut -d= -f2 || echo 0)
|
||||
fi
|
||||
|
||||
TOTAL_CANDIDATES=$((GHCR_CANDIDATES + HUB_CANDIDATES))
|
||||
TOTAL_CANDIDATES_BYTES=$((GHCR_CANDIDATES_BYTES + HUB_CANDIDATES_BYTES))
|
||||
TOTAL_DELETED=$((GHCR_DELETED + HUB_DELETED))
|
||||
TOTAL_DELETED_BYTES=$((GHCR_DELETED_BYTES + HUB_DELETED_BYTES))
|
||||
|
||||
{
|
||||
echo "## Combined container prune summary"
|
||||
echo ""
|
||||
echo "| Registry | Candidates | Deleted | Space Reclaimed |"
|
||||
echo "|----------|------------|---------|-----------------|"
|
||||
echo "| GHCR | ${GHCR_CANDIDATES} | ${GHCR_DELETED} | $(human "${GHCR_DELETED_BYTES}") |"
|
||||
echo "| Docker Hub | ${HUB_CANDIDATES} | ${HUB_DELETED} | $(human "${HUB_DELETED_BYTES}") |"
|
||||
echo "| **Total** | **${TOTAL_CANDIDATES}** | **${TOTAL_DELETED}** | **$(human "${TOTAL_DELETED_BYTES}")** |"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
printf 'PRUNE_SUMMARY: candidates=%s candidates_bytes=%s deleted=%s deleted_bytes=%s\n' \
|
||||
"${TOTAL_CANDIDATES}" "${TOTAL_CANDIDATES_BYTES}" "${TOTAL_DELETED}" "${TOTAL_DELETED_BYTES}"
|
||||
echo "Total space reclaimed: $(human "${TOTAL_DELETED_BYTES}")"
|
||||
|
||||
49
.github/workflows/docker-build.yml
vendored
49
.github/workflows/docker-build.yml
vendored
@@ -23,7 +23,11 @@ name: Docker Build, Publish & Test
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
workflow_run:
|
||||
workflows: ["Docker Lint"]
|
||||
types: [completed]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_branch || github.head_ref || github.ref_name }}
|
||||
@@ -38,7 +42,7 @@ env:
|
||||
TRIGGER_HEAD_SHA: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.sha }}
|
||||
TRIGGER_REF: ${{ github.event_name == 'workflow_run' && format('refs/heads/{0}', github.event.workflow_run.head_branch) || github.ref }}
|
||||
TRIGGER_HEAD_REF: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_branch || github.head_ref }}
|
||||
TRIGGER_PR_NUMBER: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.pull_requests[0].number || github.event.pull_request.number }}
|
||||
TRIGGER_PR_NUMBER: ${{ github.event_name == 'workflow_run' && join(github.event.workflow_run.pull_requests.*.number, '') || github.event.pull_request.number }}
|
||||
TRIGGER_ACTOR: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.actor.login || github.actor }}
|
||||
|
||||
jobs:
|
||||
@@ -339,7 +343,7 @@ jobs:
|
||||
|
||||
- name: Upload Image Artifact
|
||||
if: success() && steps.skip.outputs.skip_build != 'true' && env.TRIGGER_EVENT == 'pull_request'
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: ${{ env.TRIGGER_EVENT == 'pull_request' && format('pr-image-{0}', env.TRIGGER_PR_NUMBER) || 'push-image' }}
|
||||
path: /tmp/charon-pr-image.tar
|
||||
@@ -561,12 +565,13 @@ jobs:
|
||||
uses: github/codeql-action/upload-sarif@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
|
||||
with:
|
||||
sarif_file: 'trivy-results.sarif'
|
||||
category: '.github/workflows/docker-build.yml:build-and-push'
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Generate SBOM (Software Bill of Materials) for supply chain security
|
||||
# Only for production builds (main/development) - feature branches use downstream supply-chain-pr.yml
|
||||
- name: Generate SBOM
|
||||
uses: anchore/sbom-action@28d71544de8eaf1b958d335707167c5f783590ad # v0.22.2
|
||||
uses: anchore/sbom-action@17ae1740179002c89186b61233e0f892c3118b11 # v0.23.0
|
||||
if: env.TRIGGER_EVENT != 'pull_request' && steps.skip.outputs.skip_build != 'true' && steps.skip.outputs.is_feature_push != 'true'
|
||||
with:
|
||||
image: ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build-and-push.outputs.digest }}
|
||||
@@ -575,7 +580,7 @@ jobs:
|
||||
|
||||
# Create verifiable attestation for the SBOM
|
||||
- name: Attest SBOM
|
||||
uses: actions/attest-sbom@4651f806c01d8637787e274ac3bdf724ef169f34 # v3.0.0
|
||||
uses: actions/attest-sbom@07e74fc4e78d1aad915e867f9a094073a9f71527 # v4.0.0
|
||||
if: env.TRIGGER_EVENT != 'pull_request' && steps.skip.outputs.skip_build != 'true' && steps.skip.outputs.is_feature_push != 'true'
|
||||
with:
|
||||
subject-name: ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
@@ -702,13 +707,47 @@ jobs:
|
||||
exit-code: '1' # Intended to block, but continued on error for now
|
||||
continue-on-error: true
|
||||
|
||||
- name: Upload Trivy scan results
|
||||
- name: Check Trivy PR SARIF exists
|
||||
if: always()
|
||||
id: trivy-pr-check
|
||||
run: |
|
||||
if [ -f trivy-pr-results.sarif ]; then
|
||||
echo "exists=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "exists=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Upload Trivy scan results
|
||||
if: always() && steps.trivy-pr-check.outputs.exists == 'true'
|
||||
uses: github/codeql-action/upload-sarif@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
|
||||
with:
|
||||
sarif_file: 'trivy-pr-results.sarif'
|
||||
category: 'docker-pr-image'
|
||||
|
||||
- name: Upload Trivy compatibility results (docker-build category)
|
||||
if: always() && steps.trivy-pr-check.outputs.exists == 'true'
|
||||
uses: github/codeql-action/upload-sarif@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
|
||||
with:
|
||||
sarif_file: 'trivy-pr-results.sarif'
|
||||
category: '.github/workflows/docker-build.yml:build-and-push'
|
||||
continue-on-error: true
|
||||
|
||||
- name: Upload Trivy compatibility results (docker-publish alias)
|
||||
if: always() && steps.trivy-pr-check.outputs.exists == 'true'
|
||||
uses: github/codeql-action/upload-sarif@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
|
||||
with:
|
||||
sarif_file: 'trivy-pr-results.sarif'
|
||||
category: '.github/workflows/docker-publish.yml:build-and-push'
|
||||
continue-on-error: true
|
||||
|
||||
- name: Upload Trivy compatibility results (nightly alias)
|
||||
if: always() && steps.trivy-pr-check.outputs.exists == 'true'
|
||||
uses: github/codeql-action/upload-sarif@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
|
||||
with:
|
||||
sarif_file: 'trivy-pr-results.sarif'
|
||||
category: 'trivy-nightly'
|
||||
continue-on-error: true
|
||||
|
||||
- name: Create scan summary
|
||||
if: always()
|
||||
run: |
|
||||
|
||||
187
.github/workflows/e2e-tests-split.yml
vendored
187
.github/workflows/e2e-tests-split.yml
vendored
@@ -80,7 +80,6 @@ on:
|
||||
default: false
|
||||
type: boolean
|
||||
pull_request:
|
||||
push:
|
||||
|
||||
env:
|
||||
NODE_VERSION: '20'
|
||||
@@ -96,7 +95,7 @@ env:
|
||||
CI_LOG_LEVEL: 'verbose'
|
||||
|
||||
concurrency:
|
||||
group: e2e-split-${{ github.workflow }}-${{ github.ref }}-${{ github.event.pull_request.head.sha || github.sha }}
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
@@ -143,7 +142,7 @@ jobs:
|
||||
|
||||
- name: Set up Go
|
||||
if: steps.resolve-image.outputs.image_source == 'build'
|
||||
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
|
||||
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
cache: true
|
||||
@@ -191,7 +190,7 @@ jobs:
|
||||
|
||||
- name: Upload Docker image artifact
|
||||
if: steps.resolve-image.outputs.image_source == 'build'
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: docker-image
|
||||
path: charon-e2e-image.tar
|
||||
@@ -230,6 +229,7 @@ jobs:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: 'npm'
|
||||
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
if: needs.build.outputs.image_source == 'registry'
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
@@ -247,7 +247,7 @@ jobs:
|
||||
|
||||
- name: Download Docker image artifact
|
||||
if: needs.build.outputs.image_source == 'build'
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8
|
||||
with:
|
||||
name: docker-image
|
||||
|
||||
@@ -347,7 +347,7 @@ jobs:
|
||||
|
||||
- name: Upload HTML report (Chromium Security)
|
||||
if: always()
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: playwright-report-chromium-security
|
||||
path: playwright-report/
|
||||
@@ -355,7 +355,7 @@ jobs:
|
||||
|
||||
- name: Upload Chromium Security coverage (if enabled)
|
||||
if: always() && (inputs.playwright_coverage == 'true' || vars.PLAYWRIGHT_COVERAGE == '1')
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: e2e-coverage-chromium-security
|
||||
path: coverage/e2e/
|
||||
@@ -363,7 +363,7 @@ jobs:
|
||||
|
||||
- name: Upload test traces on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: traces-chromium-security
|
||||
path: test-results/**/*.zip
|
||||
@@ -382,7 +382,7 @@ jobs:
|
||||
|
||||
- name: Upload diagnostics
|
||||
if: always()
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: e2e-diagnostics-chromium-security
|
||||
path: diagnostics/
|
||||
@@ -395,7 +395,7 @@ jobs:
|
||||
|
||||
- name: Upload Docker logs on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: docker-logs-chromium-security
|
||||
path: docker-logs-chromium-security.txt
|
||||
@@ -431,6 +431,7 @@ jobs:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: 'npm'
|
||||
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
if: needs.build.outputs.image_source == 'registry'
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
@@ -448,7 +449,7 @@ jobs:
|
||||
|
||||
- name: Download Docker image artifact
|
||||
if: needs.build.outputs.image_source == 'build'
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8
|
||||
with:
|
||||
name: docker-image
|
||||
|
||||
@@ -556,7 +557,7 @@ jobs:
|
||||
|
||||
- name: Upload HTML report (Firefox Security)
|
||||
if: always()
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: playwright-report-firefox-security
|
||||
path: playwright-report/
|
||||
@@ -564,7 +565,7 @@ jobs:
|
||||
|
||||
- name: Upload Firefox Security coverage (if enabled)
|
||||
if: always() && (inputs.playwright_coverage == 'true' || vars.PLAYWRIGHT_COVERAGE == '1')
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: e2e-coverage-firefox-security
|
||||
path: coverage/e2e/
|
||||
@@ -572,7 +573,7 @@ jobs:
|
||||
|
||||
- name: Upload test traces on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: traces-firefox-security
|
||||
path: test-results/**/*.zip
|
||||
@@ -591,7 +592,7 @@ jobs:
|
||||
|
||||
- name: Upload diagnostics
|
||||
if: always()
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: e2e-diagnostics-firefox-security
|
||||
path: diagnostics/
|
||||
@@ -604,7 +605,7 @@ jobs:
|
||||
|
||||
- name: Upload Docker logs on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: docker-logs-firefox-security
|
||||
path: docker-logs-firefox-security.txt
|
||||
@@ -640,6 +641,7 @@ jobs:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: 'npm'
|
||||
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
if: needs.build.outputs.image_source == 'registry'
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
@@ -657,7 +659,7 @@ jobs:
|
||||
|
||||
- name: Download Docker image artifact
|
||||
if: needs.build.outputs.image_source == 'build'
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8
|
||||
with:
|
||||
name: docker-image
|
||||
|
||||
@@ -765,7 +767,7 @@ jobs:
|
||||
|
||||
- name: Upload HTML report (WebKit Security)
|
||||
if: always()
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: playwright-report-webkit-security
|
||||
path: playwright-report/
|
||||
@@ -773,7 +775,7 @@ jobs:
|
||||
|
||||
- name: Upload WebKit Security coverage (if enabled)
|
||||
if: always() && (inputs.playwright_coverage == 'true' || vars.PLAYWRIGHT_COVERAGE == '1')
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: e2e-coverage-webkit-security
|
||||
path: coverage/e2e/
|
||||
@@ -781,7 +783,7 @@ jobs:
|
||||
|
||||
- name: Upload test traces on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: traces-webkit-security
|
||||
path: test-results/**/*.zip
|
||||
@@ -800,7 +802,7 @@ jobs:
|
||||
|
||||
- name: Upload diagnostics
|
||||
if: always()
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: e2e-diagnostics-webkit-security
|
||||
path: diagnostics/
|
||||
@@ -813,7 +815,7 @@ jobs:
|
||||
|
||||
- name: Upload Docker logs on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: docker-logs-webkit-security
|
||||
path: docker-logs-webkit-security.txt
|
||||
@@ -861,6 +863,39 @@ jobs:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: 'npm'
|
||||
|
||||
- name: Preflight disk diagnostics (before cleanup)
|
||||
run: |
|
||||
echo "Disk usage before cleanup"
|
||||
df -h
|
||||
docker system df || true
|
||||
|
||||
- name: Preflight cleanup (best effort)
|
||||
run: |
|
||||
echo "Best-effort cleanup for CI runner"
|
||||
docker system prune -af || true
|
||||
rm -rf playwright-report playwright-output coverage/e2e test-results diagnostics || true
|
||||
rm -f docker-logs-*.txt charon-e2e-image.tar || true
|
||||
|
||||
- name: Preflight disk diagnostics and threshold gate
|
||||
run: |
|
||||
set -euo pipefail
|
||||
MIN_FREE_BYTES=$((5 * 1024 * 1024 * 1024))
|
||||
echo "Disk usage after cleanup"
|
||||
df -h
|
||||
docker system df || true
|
||||
|
||||
WORKSPACE_PATH="${GITHUB_WORKSPACE:-$PWD}"
|
||||
FREE_ROOT_BYTES=$(df -PB1 / | awk 'NR==2 {print $4}')
|
||||
FREE_WORKSPACE_BYTES=$(df -PB1 "$WORKSPACE_PATH" | awk 'NR==2 {print $4}')
|
||||
|
||||
echo "Free bytes on /: $FREE_ROOT_BYTES"
|
||||
echo "Free bytes on workspace ($WORKSPACE_PATH): $FREE_WORKSPACE_BYTES"
|
||||
|
||||
if [ "$FREE_ROOT_BYTES" -lt "$MIN_FREE_BYTES" ] || [ "$FREE_WORKSPACE_BYTES" -lt "$MIN_FREE_BYTES" ]; then
|
||||
echo "::error::[CI_DISK_PRESSURE] Insufficient free disk after cleanup. Required >= 5GiB on both / and workspace. root=${FREE_ROOT_BYTES}B workspace=${FREE_WORKSPACE_BYTES}B"
|
||||
exit 42
|
||||
fi
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
if: needs.build.outputs.image_source == 'registry'
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
@@ -878,7 +913,7 @@ jobs:
|
||||
|
||||
- name: Download Docker image artifact
|
||||
if: needs.build.outputs.image_source == 'build'
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8
|
||||
with:
|
||||
name: docker-image
|
||||
|
||||
@@ -968,7 +1003,7 @@ jobs:
|
||||
|
||||
- name: Upload HTML report (Chromium shard ${{ matrix.shard }})
|
||||
if: always()
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: playwright-report-chromium-shard-${{ matrix.shard }}
|
||||
path: playwright-report/
|
||||
@@ -976,7 +1011,7 @@ jobs:
|
||||
|
||||
- name: Upload Playwright output (Chromium shard ${{ matrix.shard }})
|
||||
if: always()
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: playwright-output-chromium-shard-${{ matrix.shard }}
|
||||
path: playwright-output/chromium-shard-${{ matrix.shard }}/
|
||||
@@ -984,7 +1019,7 @@ jobs:
|
||||
|
||||
- name: Upload Chromium coverage (if enabled)
|
||||
if: always() && (inputs.playwright_coverage == 'true' || vars.PLAYWRIGHT_COVERAGE == '1')
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: e2e-coverage-chromium-shard-${{ matrix.shard }}
|
||||
path: coverage/e2e/
|
||||
@@ -992,7 +1027,7 @@ jobs:
|
||||
|
||||
- name: Upload test traces on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: traces-chromium-shard-${{ matrix.shard }}
|
||||
path: test-results/**/*.zip
|
||||
@@ -1011,7 +1046,7 @@ jobs:
|
||||
|
||||
- name: Upload diagnostics
|
||||
if: always()
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: e2e-diagnostics-chromium-shard-${{ matrix.shard }}
|
||||
path: diagnostics/
|
||||
@@ -1024,7 +1059,7 @@ jobs:
|
||||
|
||||
- name: Upload Docker logs on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: docker-logs-chromium-shard-${{ matrix.shard }}
|
||||
path: docker-logs-chromium-shard-${{ matrix.shard }}.txt
|
||||
@@ -1065,6 +1100,39 @@ jobs:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: 'npm'
|
||||
|
||||
- name: Preflight disk diagnostics (before cleanup)
|
||||
run: |
|
||||
echo "Disk usage before cleanup"
|
||||
df -h
|
||||
docker system df || true
|
||||
|
||||
- name: Preflight cleanup (best effort)
|
||||
run: |
|
||||
echo "Best-effort cleanup for CI runner"
|
||||
docker system prune -af || true
|
||||
rm -rf playwright-report playwright-output coverage/e2e test-results diagnostics || true
|
||||
rm -f docker-logs-*.txt charon-e2e-image.tar || true
|
||||
|
||||
- name: Preflight disk diagnostics and threshold gate
|
||||
run: |
|
||||
set -euo pipefail
|
||||
MIN_FREE_BYTES=$((5 * 1024 * 1024 * 1024))
|
||||
echo "Disk usage after cleanup"
|
||||
df -h
|
||||
docker system df || true
|
||||
|
||||
WORKSPACE_PATH="${GITHUB_WORKSPACE:-$PWD}"
|
||||
FREE_ROOT_BYTES=$(df -PB1 / | awk 'NR==2 {print $4}')
|
||||
FREE_WORKSPACE_BYTES=$(df -PB1 "$WORKSPACE_PATH" | awk 'NR==2 {print $4}')
|
||||
|
||||
echo "Free bytes on /: $FREE_ROOT_BYTES"
|
||||
echo "Free bytes on workspace ($WORKSPACE_PATH): $FREE_WORKSPACE_BYTES"
|
||||
|
||||
if [ "$FREE_ROOT_BYTES" -lt "$MIN_FREE_BYTES" ] || [ "$FREE_WORKSPACE_BYTES" -lt "$MIN_FREE_BYTES" ]; then
|
||||
echo "::error::[CI_DISK_PRESSURE] Insufficient free disk after cleanup. Required >= 5GiB on both / and workspace. root=${FREE_ROOT_BYTES}B workspace=${FREE_WORKSPACE_BYTES}B"
|
||||
exit 42
|
||||
fi
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
if: needs.build.outputs.image_source == 'registry'
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
@@ -1082,7 +1150,7 @@ jobs:
|
||||
|
||||
- name: Download Docker image artifact
|
||||
if: needs.build.outputs.image_source == 'build'
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8
|
||||
with:
|
||||
name: docker-image
|
||||
|
||||
@@ -1180,7 +1248,7 @@ jobs:
|
||||
|
||||
- name: Upload HTML report (Firefox shard ${{ matrix.shard }})
|
||||
if: always()
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: playwright-report-firefox-shard-${{ matrix.shard }}
|
||||
path: playwright-report/
|
||||
@@ -1188,7 +1256,7 @@ jobs:
|
||||
|
||||
- name: Upload Playwright output (Firefox shard ${{ matrix.shard }})
|
||||
if: always()
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: playwright-output-firefox-shard-${{ matrix.shard }}
|
||||
path: playwright-output/firefox-shard-${{ matrix.shard }}/
|
||||
@@ -1196,7 +1264,7 @@ jobs:
|
||||
|
||||
- name: Upload Firefox coverage (if enabled)
|
||||
if: always() && (inputs.playwright_coverage == 'true' || vars.PLAYWRIGHT_COVERAGE == '1')
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: e2e-coverage-firefox-shard-${{ matrix.shard }}
|
||||
path: coverage/e2e/
|
||||
@@ -1204,7 +1272,7 @@ jobs:
|
||||
|
||||
- name: Upload test traces on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: traces-firefox-shard-${{ matrix.shard }}
|
||||
path: test-results/**/*.zip
|
||||
@@ -1223,7 +1291,7 @@ jobs:
|
||||
|
||||
- name: Upload diagnostics
|
||||
if: always()
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: e2e-diagnostics-firefox-shard-${{ matrix.shard }}
|
||||
path: diagnostics/
|
||||
@@ -1236,7 +1304,7 @@ jobs:
|
||||
|
||||
- name: Upload Docker logs on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: docker-logs-firefox-shard-${{ matrix.shard }}
|
||||
path: docker-logs-firefox-shard-${{ matrix.shard }}.txt
|
||||
@@ -1277,6 +1345,39 @@ jobs:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: 'npm'
|
||||
|
||||
- name: Preflight disk diagnostics (before cleanup)
|
||||
run: |
|
||||
echo "Disk usage before cleanup"
|
||||
df -h
|
||||
docker system df || true
|
||||
|
||||
- name: Preflight cleanup (best effort)
|
||||
run: |
|
||||
echo "Best-effort cleanup for CI runner"
|
||||
docker system prune -af || true
|
||||
rm -rf playwright-report playwright-output coverage/e2e test-results diagnostics || true
|
||||
rm -f docker-logs-*.txt charon-e2e-image.tar || true
|
||||
|
||||
- name: Preflight disk diagnostics and threshold gate
|
||||
run: |
|
||||
set -euo pipefail
|
||||
MIN_FREE_BYTES=$((5 * 1024 * 1024 * 1024))
|
||||
echo "Disk usage after cleanup"
|
||||
df -h
|
||||
docker system df || true
|
||||
|
||||
WORKSPACE_PATH="${GITHUB_WORKSPACE:-$PWD}"
|
||||
FREE_ROOT_BYTES=$(df -PB1 / | awk 'NR==2 {print $4}')
|
||||
FREE_WORKSPACE_BYTES=$(df -PB1 "$WORKSPACE_PATH" | awk 'NR==2 {print $4}')
|
||||
|
||||
echo "Free bytes on /: $FREE_ROOT_BYTES"
|
||||
echo "Free bytes on workspace ($WORKSPACE_PATH): $FREE_WORKSPACE_BYTES"
|
||||
|
||||
if [ "$FREE_ROOT_BYTES" -lt "$MIN_FREE_BYTES" ] || [ "$FREE_WORKSPACE_BYTES" -lt "$MIN_FREE_BYTES" ]; then
|
||||
echo "::error::[CI_DISK_PRESSURE] Insufficient free disk after cleanup. Required >= 5GiB on both / and workspace. root=${FREE_ROOT_BYTES}B workspace=${FREE_WORKSPACE_BYTES}B"
|
||||
exit 42
|
||||
fi
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
if: needs.build.outputs.image_source == 'registry'
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
@@ -1294,7 +1395,7 @@ jobs:
|
||||
|
||||
- name: Download Docker image artifact
|
||||
if: needs.build.outputs.image_source == 'build'
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8
|
||||
with:
|
||||
name: docker-image
|
||||
|
||||
@@ -1392,7 +1493,7 @@ jobs:
|
||||
|
||||
- name: Upload HTML report (WebKit shard ${{ matrix.shard }})
|
||||
if: always()
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
|
||||
with:
|
||||
name: playwright-report-webkit-shard-${{ matrix.shard }}
|
||||
path: playwright-report/
|
||||
@@ -1400,7 +1501,7 @@ jobs:
|
||||
|
||||
- name: Upload Playwright output (WebKit shard ${{ matrix.shard }})
|
||||
if: always()
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
|
||||
with:
|
||||
name: playwright-output-webkit-shard-${{ matrix.shard }}
|
||||
path: playwright-output/webkit-shard-${{ matrix.shard }}/
|
||||
@@ -1408,7 +1509,7 @@ jobs:
|
||||
|
||||
- name: Upload WebKit coverage (if enabled)
|
||||
if: always() && (inputs.playwright_coverage == 'true' || vars.PLAYWRIGHT_COVERAGE == '1')
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
|
||||
with:
|
||||
name: e2e-coverage-webkit-shard-${{ matrix.shard }}
|
||||
path: coverage/e2e/
|
||||
@@ -1416,7 +1517,7 @@ jobs:
|
||||
|
||||
- name: Upload test traces on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
|
||||
with:
|
||||
name: traces-webkit-shard-${{ matrix.shard }}
|
||||
path: test-results/**/*.zip
|
||||
@@ -1435,7 +1536,7 @@ jobs:
|
||||
|
||||
- name: Upload diagnostics
|
||||
if: always()
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
|
||||
with:
|
||||
name: e2e-diagnostics-webkit-shard-${{ matrix.shard }}
|
||||
path: diagnostics/
|
||||
@@ -1448,7 +1549,7 @@ jobs:
|
||||
|
||||
- name: Upload Docker logs on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
|
||||
with:
|
||||
name: docker-logs-webkit-shard-${{ matrix.shard }}
|
||||
path: docker-logs-webkit-shard-${{ matrix.shard }}.txt
|
||||
|
||||
175
.github/workflows/nightly-build.yml
vendored
175
.github/workflows/nightly-build.yml
vendored
@@ -103,11 +103,12 @@ jobs:
|
||||
const workflows = [
|
||||
{ id: 'e2e-tests-split.yml' },
|
||||
{ id: 'codecov-upload.yml', inputs: { run_backend: 'true', run_frontend: 'true' } },
|
||||
{ id: 'security-pr.yml' },
|
||||
{ id: 'supply-chain-verify.yml' },
|
||||
{ id: 'codeql.yml' },
|
||||
];
|
||||
|
||||
core.info('Skipping security-pr.yml: PR-only workflow intentionally excluded from nightly non-PR dispatch');
|
||||
|
||||
for (const workflow of workflows) {
|
||||
const { data: workflowRuns } = await github.rest.actions.listWorkflowRuns({
|
||||
owner,
|
||||
@@ -220,14 +221,66 @@ jobs:
|
||||
echo "- ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}:nightly@${{ steps.build.outputs.digest }}" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Generate SBOM
|
||||
uses: anchore/sbom-action@28d71544de8eaf1b958d335707167c5f783590ad # v0.22.2
|
||||
id: sbom_primary
|
||||
continue-on-error: true
|
||||
uses: anchore/sbom-action@17ae1740179002c89186b61233e0f892c3118b11 # v0.23.0
|
||||
with:
|
||||
image: ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}:nightly@${{ steps.build.outputs.digest }}
|
||||
format: cyclonedx-json
|
||||
output-file: sbom-nightly.json
|
||||
syft-version: v1.42.1
|
||||
|
||||
- name: Generate SBOM fallback with pinned Syft
|
||||
if: always()
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [[ "${{ steps.sbom_primary.outcome }}" == "success" ]] && [[ -s sbom-nightly.json ]] && jq -e . sbom-nightly.json >/dev/null 2>&1; then
|
||||
echo "Primary SBOM generation succeeded with valid JSON; skipping fallback"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Primary SBOM generation failed or produced missing/invalid output; using deterministic Syft fallback"
|
||||
|
||||
SYFT_VERSION="v1.42.1"
|
||||
OS="$(uname -s | tr '[:upper:]' '[:lower:]')"
|
||||
ARCH="$(uname -m)"
|
||||
case "$ARCH" in
|
||||
x86_64) ARCH="amd64" ;;
|
||||
aarch64|arm64) ARCH="arm64" ;;
|
||||
*) echo "Unsupported architecture: $ARCH"; exit 1 ;;
|
||||
esac
|
||||
|
||||
TARBALL="syft_${SYFT_VERSION#v}_${OS}_${ARCH}.tar.gz"
|
||||
BASE_URL="https://github.com/anchore/syft/releases/download/${SYFT_VERSION}"
|
||||
|
||||
curl -fsSLo "$TARBALL" "${BASE_URL}/${TARBALL}"
|
||||
curl -fsSLo checksums.txt "${BASE_URL}/syft_${SYFT_VERSION#v}_checksums.txt"
|
||||
|
||||
grep " ${TARBALL}$" checksums.txt > checksum_line.txt
|
||||
sha256sum -c checksum_line.txt
|
||||
|
||||
tar -xzf "$TARBALL" syft
|
||||
chmod +x syft
|
||||
|
||||
./syft "${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}:nightly@${{ steps.build.outputs.digest }}" -o cyclonedx-json=sbom-nightly.json
|
||||
|
||||
- name: Verify SBOM artifact
|
||||
if: always()
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -s sbom-nightly.json
|
||||
jq -e . sbom-nightly.json >/dev/null
|
||||
jq -e '
|
||||
.bomFormat == "CycloneDX"
|
||||
and (.specVersion | type == "string" and length > 0)
|
||||
and has("version")
|
||||
and has("metadata")
|
||||
and (.components | type == "array")
|
||||
' sbom-nightly.json >/dev/null
|
||||
|
||||
- name: Upload SBOM artifact
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: sbom-nightly
|
||||
path: sbom-nightly.json
|
||||
@@ -331,7 +384,7 @@ jobs:
|
||||
run: echo "IMAGE_NAME_LC=${IMAGE_NAME,,}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Download SBOM
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
|
||||
with:
|
||||
name: sbom-nightly
|
||||
|
||||
@@ -355,10 +408,116 @@ jobs:
|
||||
sarif_file: 'trivy-nightly.sarif'
|
||||
category: 'trivy-nightly'
|
||||
|
||||
- name: Check for critical CVEs
|
||||
- name: Security severity policy summary
|
||||
run: |
|
||||
if grep -q "CRITICAL" trivy-nightly.sarif; then
|
||||
echo "❌ Critical vulnerabilities found in nightly build"
|
||||
{
|
||||
echo "## 🔐 Nightly Supply Chain Severity Policy"
|
||||
echo ""
|
||||
echo "- Blocking: Critical, High"
|
||||
echo "- Medium: non-blocking by default (report + triage SLA)"
|
||||
echo "- Policy file: .github/security-severity-policy.yml"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Check for Critical/High CVEs
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
jq -e . trivy-nightly.sarif >/dev/null
|
||||
|
||||
CRITICAL_COUNT=$(jq -r '
|
||||
[
|
||||
.runs[] as $run
|
||||
| ($run.tool.driver.rules // []) as $rules
|
||||
| $run.results[]?
|
||||
| . as $result
|
||||
| (
|
||||
(
|
||||
if (($result.ruleIndex | type) == "number") then
|
||||
($rules[$result.ruleIndex].properties["security-severity"] // empty)
|
||||
else
|
||||
empty
|
||||
end
|
||||
)
|
||||
// ([
|
||||
$rules[]?
|
||||
| select((.id // "") == ($result.ruleId // ""))
|
||||
| .properties["security-severity"]
|
||||
][0] // empty)
|
||||
// empty
|
||||
) as $securitySeverity
|
||||
| (try ($securitySeverity | tonumber) catch empty) as $score
|
||||
| select($score != null and $score >= 9.0)
|
||||
] | length
|
||||
' trivy-nightly.sarif)
|
||||
|
||||
HIGH_COUNT=$(jq -r '
|
||||
[
|
||||
.runs[] as $run
|
||||
| ($run.tool.driver.rules // []) as $rules
|
||||
| $run.results[]?
|
||||
| . as $result
|
||||
| (
|
||||
(
|
||||
if (($result.ruleIndex | type) == "number") then
|
||||
($rules[$result.ruleIndex].properties["security-severity"] // empty)
|
||||
else
|
||||
empty
|
||||
end
|
||||
)
|
||||
// ([
|
||||
$rules[]?
|
||||
| select((.id // "") == ($result.ruleId // ""))
|
||||
| .properties["security-severity"]
|
||||
][0] // empty)
|
||||
// empty
|
||||
) as $securitySeverity
|
||||
| (try ($securitySeverity | tonumber) catch empty) as $score
|
||||
| select($score != null and $score >= 7.0 and $score < 9.0)
|
||||
] | length
|
||||
' trivy-nightly.sarif)
|
||||
|
||||
MEDIUM_COUNT=$(jq -r '
|
||||
[
|
||||
.runs[] as $run
|
||||
| ($run.tool.driver.rules // []) as $rules
|
||||
| $run.results[]?
|
||||
| . as $result
|
||||
| (
|
||||
(
|
||||
if (($result.ruleIndex | type) == "number") then
|
||||
($rules[$result.ruleIndex].properties["security-severity"] // empty)
|
||||
else
|
||||
empty
|
||||
end
|
||||
)
|
||||
// ([
|
||||
$rules[]?
|
||||
| select((.id // "") == ($result.ruleId // ""))
|
||||
| .properties["security-severity"]
|
||||
][0] // empty)
|
||||
// empty
|
||||
) as $securitySeverity
|
||||
| (try ($securitySeverity | tonumber) catch empty) as $score
|
||||
| select($score != null and $score >= 4.0 and $score < 7.0)
|
||||
] | length
|
||||
' trivy-nightly.sarif)
|
||||
|
||||
{
|
||||
echo "- Structured SARIF counts: CRITICAL=${CRITICAL_COUNT}, HIGH=${HIGH_COUNT}, MEDIUM=${MEDIUM_COUNT}"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
if [ "$CRITICAL_COUNT" -gt 0 ]; then
|
||||
echo "❌ Critical vulnerabilities found in nightly build (${CRITICAL_COUNT})"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ No critical vulnerabilities found"
|
||||
|
||||
if [ "$HIGH_COUNT" -gt 0 ]; then
|
||||
echo "❌ High vulnerabilities found in nightly build (${HIGH_COUNT})"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$MEDIUM_COUNT" -gt 0 ]; then
|
||||
echo "::warning::Medium vulnerabilities found in nightly build (${MEDIUM_COUNT}). Non-blocking by policy; triage with SLA per .github/security-severity-policy.yml"
|
||||
fi
|
||||
|
||||
echo "✅ No Critical/High vulnerabilities found"
|
||||
|
||||
25
.github/workflows/quality-checks.yml
vendored
25
.github/workflows/quality-checks.yml
vendored
@@ -3,6 +3,8 @@ name: Quality Checks
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
@@ -18,6 +20,27 @@ env:
|
||||
GOTOOLCHAIN: auto
|
||||
|
||||
jobs:
|
||||
auth-route-protection-contract:
|
||||
name: Auth Route Protection Contract
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.sha }}
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
cache-dependency-path: backend/go.sum
|
||||
|
||||
- name: Run auth protection contract tests
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd backend
|
||||
go test ./internal/api/routes -run 'TestRegister_StateChangingRoutesRequireAuthentication|TestRegister_StateChangingRoutesDenyByDefaultWithExplicitAllowlist|TestRegister_AuthenticatedRoutes' -count=1 -v
|
||||
|
||||
codecov-trigger-parity-guard:
|
||||
name: Codecov Trigger/Comment Parity Guard
|
||||
runs-on: ubuntu-latest
|
||||
@@ -113,7 +136,7 @@ jobs:
|
||||
} >> "$GITHUB_ENV"
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0
|
||||
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
cache-dependency-path: backend/go.sum
|
||||
|
||||
15
.github/workflows/release-goreleaser.yml
vendored
15
.github/workflows/release-goreleaser.yml
vendored
@@ -20,6 +20,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
goreleaser:
|
||||
if: ${{ !contains(github.ref_name, '-candidate') && !contains(github.ref_name, '-rc') }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
# Use the built-in GITHUB_TOKEN by default for GitHub API operations.
|
||||
@@ -32,10 +33,22 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Enforce PR-2 release promotion guard
|
||||
env:
|
||||
REPO_VARS_JSON: ${{ toJSON(vars) }}
|
||||
run: |
|
||||
PR2_GATE_STATUS="$(printf '%s' "$REPO_VARS_JSON" | jq -r '.CHARON_PR2_GATES_PASSED // "false"')"
|
||||
if [[ "$PR2_GATE_STATUS" != "true" ]]; then
|
||||
echo "::error::Releasable tag promotion is blocked until PR-2 security/retirement gates pass."
|
||||
echo "::error::Set repository variable CHARON_PR2_GATES_PASSED=true only after PR-2 approval."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
|
||||
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
cache-dependency-path: backend/go.sum
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
|
||||
|
||||
2
.github/workflows/renovate.yml
vendored
2
.github/workflows/renovate.yml
vendored
@@ -25,7 +25,7 @@ jobs:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Run Renovate
|
||||
uses: renovatebot/github-action@d65ef9e20512193cc070238b49c3873a361cd50c # v46.1.1
|
||||
uses: renovatebot/github-action@7b4b65bf31e07d4e3e51708d07700fb41bc03166 # v46.1.3
|
||||
with:
|
||||
configurationFile: .github/renovate.json
|
||||
token: ${{ secrets.RENOVATE_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
|
||||
2
.github/workflows/repo-health.yml
vendored
2
.github/workflows/repo-health.yml
vendored
@@ -34,7 +34,7 @@ jobs:
|
||||
|
||||
- name: Upload health output
|
||||
if: always()
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
|
||||
with:
|
||||
name: repo-health-output
|
||||
path: |
|
||||
|
||||
339
.github/workflows/security-pr.yml
vendored
339
.github/workflows/security-pr.yml
vendored
@@ -4,18 +4,22 @@
|
||||
name: Security Scan (PR)
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["Docker Build, Publish & Test"]
|
||||
types: [completed]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr_number:
|
||||
description: 'PR number to scan (optional)'
|
||||
required: false
|
||||
description: 'PR number to scan'
|
||||
required: true
|
||||
type: string
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
|
||||
concurrency:
|
||||
group: security-pr-${{ github.event.workflow_run.event || github.event_name }}-${{ github.event.workflow_run.head_branch || github.ref }}
|
||||
group: security-pr-${{ github.event_name == 'workflow_run' && github.event.workflow_run.event || github.event_name }}-${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_branch || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
@@ -23,16 +27,18 @@ jobs:
|
||||
name: Trivy Binary Scan
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
# Run for: manual dispatch, PR builds, or any push builds from docker-build
|
||||
# Run for manual dispatch, direct PR/push, or successful upstream workflow_run
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
github.event_name == 'pull_request' ||
|
||||
((github.event.workflow_run.event == 'push' || github.event.workflow_run.pull_requests[0].number != null) &&
|
||||
(github.event.workflow_run.status != 'completed' || github.event.workflow_run.conclusion == 'success'))
|
||||
github.event_name == 'push' ||
|
||||
(github.event_name == 'workflow_run' &&
|
||||
github.event.workflow_run.event == 'pull_request' &&
|
||||
github.event.workflow_run.status == 'completed' &&
|
||||
github.event.workflow_run.conclusion == 'success')
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
security-events: write
|
||||
actions: read
|
||||
|
||||
@@ -41,27 +47,65 @@ jobs:
|
||||
# actions/checkout v4.2.2
|
||||
uses: actions/checkout@0c366fd6a839edf440554fa01a7085ccba70ac98
|
||||
with:
|
||||
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
|
||||
ref: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.sha }}
|
||||
|
||||
- name: Extract PR number from workflow_run
|
||||
id: pr-info
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
|
||||
# Manual dispatch - use input or fail gracefully
|
||||
if [[ -n "${{ inputs.pr_number }}" ]]; then
|
||||
echo "pr_number=${{ inputs.pr_number }}" >> "$GITHUB_OUTPUT"
|
||||
echo "✅ Using manually provided PR number: ${{ inputs.pr_number }}"
|
||||
else
|
||||
echo "⚠️ No PR number provided for manual dispatch"
|
||||
echo "pr_number=" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
if [[ "${{ github.event_name }}" == "push" ]]; then
|
||||
echo "pr_number=" >> "$GITHUB_OUTPUT"
|
||||
echo "is_push=true" >> "$GITHUB_OUTPUT"
|
||||
echo "✅ Push event detected; using local image path"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
|
||||
echo "pr_number=${{ github.event.pull_request.number }}" >> "$GITHUB_OUTPUT"
|
||||
echo "is_push=false" >> "$GITHUB_OUTPUT"
|
||||
echo "✅ Pull request event detected: PR #${{ github.event.pull_request.number }}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
|
||||
INPUT_PR_NUMBER="${{ inputs.pr_number }}"
|
||||
if [[ -z "${INPUT_PR_NUMBER}" ]]; then
|
||||
echo "❌ workflow_dispatch requires inputs.pr_number"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! "${INPUT_PR_NUMBER}" =~ ^[0-9]+$ ]]; then
|
||||
echo "❌ reason_category=invalid_input"
|
||||
echo "reason=workflow_dispatch pr_number must be digits-only"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
PR_NUMBER="${INPUT_PR_NUMBER}"
|
||||
echo "pr_number=${PR_NUMBER}" >> "$GITHUB_OUTPUT"
|
||||
echo "is_push=false" >> "$GITHUB_OUTPUT"
|
||||
echo "✅ Using manually provided PR number: ${PR_NUMBER}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "${{ github.event_name }}" == "workflow_run" ]]; then
|
||||
if [[ "${{ github.event.workflow_run.event }}" != "pull_request" ]]; then
|
||||
# Explicit contract validation happens in the dedicated guard step.
|
||||
echo "pr_number=" >> "$GITHUB_OUTPUT"
|
||||
echo "is_push=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ -n "${{ github.event.workflow_run.pull_requests[0].number || '' }}" ]]; then
|
||||
echo "pr_number=${{ github.event.workflow_run.pull_requests[0].number }}" >> "$GITHUB_OUTPUT"
|
||||
echo "is_push=false" >> "$GITHUB_OUTPUT"
|
||||
echo "✅ Found PR number from workflow_run payload: ${{ github.event.workflow_run.pull_requests[0].number }}"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# Extract PR number from context
|
||||
HEAD_SHA="${{ github.event.workflow_run.head_sha || github.event.pull_request.head.sha || github.sha }}"
|
||||
HEAD_SHA="${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.event.pull_request.head.sha || github.sha }}"
|
||||
echo "🔍 Looking for PR with head SHA: ${HEAD_SHA}"
|
||||
|
||||
# Query GitHub API for PR associated with this commit
|
||||
@@ -73,21 +117,38 @@ jobs:
|
||||
|
||||
if [[ -n "${PR_NUMBER}" ]]; then
|
||||
echo "pr_number=${PR_NUMBER}" >> "$GITHUB_OUTPUT"
|
||||
echo "is_push=false" >> "$GITHUB_OUTPUT"
|
||||
echo "✅ Found PR number: ${PR_NUMBER}"
|
||||
else
|
||||
echo "⚠️ Could not find PR number for SHA: ${HEAD_SHA}"
|
||||
echo "pr_number=" >> "$GITHUB_OUTPUT"
|
||||
echo "❌ Could not determine PR number for workflow_run SHA: ${HEAD_SHA}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if this is a push event (not a PR)
|
||||
if [[ "${{ github.event_name }}" == "push" || "${{ github.event.workflow_run.event }}" == "push" || -z "${PR_NUMBER}" ]]; then
|
||||
HEAD_BRANCH="${{ github.event.workflow_run.head_branch || github.ref_name }}"
|
||||
echo "is_push=true" >> "$GITHUB_OUTPUT"
|
||||
echo "✅ Detected push build from branch: ${HEAD_BRANCH}"
|
||||
else
|
||||
echo "is_push=false" >> "$GITHUB_OUTPUT"
|
||||
- name: Validate workflow_run trust boundary and event contract
|
||||
if: github.event_name == 'workflow_run'
|
||||
run: |
|
||||
if [[ "${{ github.event.workflow_run.name }}" != "Docker Build, Publish & Test" ]]; then
|
||||
echo "❌ reason_category=unexpected_upstream_workflow"
|
||||
echo "workflow_name=${{ github.event.workflow_run.name }}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "${{ github.event.workflow_run.event }}" != "pull_request" ]]; then
|
||||
echo "❌ reason_category=unsupported_upstream_event"
|
||||
echo "upstream_event=${{ github.event.workflow_run.event }}"
|
||||
echo "run_id=${{ github.event.workflow_run.id }}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "${{ github.event.workflow_run.head_repository.full_name }}" != "${{ github.repository }}" ]]; then
|
||||
echo "❌ reason_category=untrusted_upstream_repository"
|
||||
echo "upstream_head_repository=${{ github.event.workflow_run.head_repository.full_name }}"
|
||||
echo "expected_repository=${{ github.repository }}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ workflow_run trust boundary and event contract validated"
|
||||
|
||||
- name: Build Docker image (Local)
|
||||
if: github.event_name == 'push' || github.event_name == 'pull_request'
|
||||
run: |
|
||||
@@ -97,95 +158,149 @@ jobs:
|
||||
|
||||
- name: Check for PR image artifact
|
||||
id: check-artifact
|
||||
if: (steps.pr-info.outputs.pr_number != '' || steps.pr-info.outputs.is_push == 'true') && github.event_name != 'push' && github.event_name != 'pull_request'
|
||||
if: github.event_name == 'workflow_run' || github.event_name == 'workflow_dispatch'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
# Determine artifact name based on event type
|
||||
if [[ "${{ steps.pr-info.outputs.is_push }}" == "true" ]]; then
|
||||
ARTIFACT_NAME="push-image"
|
||||
else
|
||||
PR_NUMBER="${{ steps.pr-info.outputs.pr_number }}"
|
||||
ARTIFACT_NAME="pr-image-${PR_NUMBER}"
|
||||
PR_NUMBER="${{ steps.pr-info.outputs.pr_number }}"
|
||||
if [[ ! "${PR_NUMBER}" =~ ^[0-9]+$ ]]; then
|
||||
echo "❌ reason_category=invalid_input"
|
||||
echo "reason=Resolved PR number must be digits-only"
|
||||
exit 1
|
||||
fi
|
||||
RUN_ID="${{ github.event.workflow_run.id }}"
|
||||
|
||||
ARTIFACT_NAME="pr-image-${PR_NUMBER}"
|
||||
RUN_ID="${{ github.event_name == 'workflow_run' && github.event.workflow_run.id || '' }}"
|
||||
|
||||
echo "🔍 Checking for artifact: ${ARTIFACT_NAME}"
|
||||
|
||||
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
|
||||
# For manual dispatch, find the most recent workflow run with this artifact
|
||||
RUN_ID=$(gh api \
|
||||
# Manual replay path: find latest successful docker-build pull_request run for this PR.
|
||||
RUNS_JSON=$(gh api \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "X-GitHub-Api-Version: 2022-11-28" \
|
||||
"/repos/${{ github.repository }}/actions/workflows/docker-build.yml/runs?status=success&per_page=10" \
|
||||
--jq '.workflow_runs[0].id // empty' 2>/dev/null || echo "")
|
||||
"/repos/${{ github.repository }}/actions/workflows/docker-build.yml/runs?event=pull_request&status=success&per_page=100" 2>&1)
|
||||
RUNS_STATUS=$?
|
||||
|
||||
if [[ ${RUNS_STATUS} -ne 0 ]]; then
|
||||
echo "❌ reason_category=api_error"
|
||||
echo "reason=Failed to query workflow runs for PR lookup"
|
||||
echo "upstream_run_id=unknown"
|
||||
echo "artifact_name=${ARTIFACT_NAME}"
|
||||
echo "api_output=${RUNS_JSON}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
RUN_ID=$(printf '%s' "${RUNS_JSON}" | jq -r --argjson pr "${PR_NUMBER}" '.workflow_runs[] | select((.pull_requests // []) | any(.number == $pr)) | .id' | head -n 1)
|
||||
|
||||
if [[ -z "${RUN_ID}" ]]; then
|
||||
echo "⚠️ No successful workflow runs found"
|
||||
echo "artifact_exists=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
echo "❌ reason_category=not_found"
|
||||
echo "reason=No successful docker-build pull_request run found for PR #${PR_NUMBER}"
|
||||
echo "upstream_run_id=unknown"
|
||||
echo "artifact_name=${ARTIFACT_NAME}"
|
||||
exit 1
|
||||
fi
|
||||
elif [[ -z "${RUN_ID}" ]]; then
|
||||
# If triggered by push/pull_request, RUN_ID is empty. Find recent run for this commit.
|
||||
HEAD_SHA="${{ github.event.workflow_run.head_sha || github.event.pull_request.head.sha || github.sha }}"
|
||||
echo "🔍 Searching for workflow run for SHA: ${HEAD_SHA}"
|
||||
# Retry a few times as the run might be just starting or finishing
|
||||
for i in {1..3}; do
|
||||
RUN_ID=$(gh api \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "X-GitHub-Api-Version: 2022-11-28" \
|
||||
"/repos/${{ github.repository }}/actions/workflows/docker-build.yml/runs?head_sha=${HEAD_SHA}&status=success&per_page=1" \
|
||||
--jq '.workflow_runs[0].id // empty' 2>/dev/null || echo "")
|
||||
if [[ -n "${RUN_ID}" ]]; then break; fi
|
||||
echo "⏳ Waiting for workflow run to appear/complete... ($i/3)"
|
||||
sleep 5
|
||||
done
|
||||
fi
|
||||
|
||||
echo "run_id=${RUN_ID}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Check if the artifact exists in the workflow run
|
||||
ARTIFACT_ID=$(gh api \
|
||||
ARTIFACTS_JSON=$(gh api \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "X-GitHub-Api-Version: 2022-11-28" \
|
||||
"/repos/${{ github.repository }}/actions/runs/${RUN_ID}/artifacts" \
|
||||
--jq ".artifacts[] | select(.name == \"${ARTIFACT_NAME}\") | .id" 2>/dev/null || echo "")
|
||||
"/repos/${{ github.repository }}/actions/runs/${RUN_ID}/artifacts" 2>&1)
|
||||
ARTIFACTS_STATUS=$?
|
||||
|
||||
if [[ -n "${ARTIFACT_ID}" ]]; then
|
||||
echo "artifact_exists=true" >> "$GITHUB_OUTPUT"
|
||||
echo "artifact_id=${ARTIFACT_ID}" >> "$GITHUB_OUTPUT"
|
||||
echo "✅ Found artifact: ${ARTIFACT_NAME} (ID: ${ARTIFACT_ID})"
|
||||
else
|
||||
echo "artifact_exists=false" >> "$GITHUB_OUTPUT"
|
||||
echo "⚠️ Artifact not found: ${ARTIFACT_NAME}"
|
||||
echo "ℹ️ This is expected for non-PR builds or if the image was not uploaded"
|
||||
if [[ ${ARTIFACTS_STATUS} -ne 0 ]]; then
|
||||
echo "❌ reason_category=api_error"
|
||||
echo "reason=Failed to query artifacts for upstream run"
|
||||
echo "upstream_run_id=${RUN_ID}"
|
||||
echo "artifact_name=${ARTIFACT_NAME}"
|
||||
echo "api_output=${ARTIFACTS_JSON}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Skip if no artifact
|
||||
if: ((steps.pr-info.outputs.pr_number == '' && steps.pr-info.outputs.is_push != 'true') || steps.check-artifact.outputs.artifact_exists != 'true') && github.event_name != 'push' && github.event_name != 'pull_request'
|
||||
run: |
|
||||
echo "ℹ️ Skipping security scan - no PR image artifact available"
|
||||
echo "This is expected for:"
|
||||
echo " - Pushes to main/release branches"
|
||||
echo " - PRs where Docker build failed"
|
||||
echo " - Manual dispatch without PR number"
|
||||
exit 0
|
||||
ARTIFACT_ID=$(printf '%s' "${ARTIFACTS_JSON}" | jq -r --arg name "${ARTIFACT_NAME}" '.artifacts[] | select(.name == $name) | .id' | head -n 1)
|
||||
|
||||
if [[ -z "${ARTIFACT_ID}" ]]; then
|
||||
echo "❌ reason_category=not_found"
|
||||
echo "reason=Required artifact was not found"
|
||||
echo "upstream_run_id=${RUN_ID}"
|
||||
echo "artifact_name=${ARTIFACT_NAME}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
{
|
||||
echo "artifact_exists=true"
|
||||
echo "artifact_id=${ARTIFACT_ID}"
|
||||
echo "artifact_name=${ARTIFACT_NAME}"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
echo "✅ Found artifact: ${ARTIFACT_NAME} (ID: ${ARTIFACT_ID})"
|
||||
|
||||
- name: Download PR image artifact
|
||||
if: steps.check-artifact.outputs.artifact_exists == 'true'
|
||||
if: github.event_name == 'workflow_run' || github.event_name == 'workflow_dispatch'
|
||||
# actions/download-artifact v4.1.8
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131
|
||||
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3
|
||||
with:
|
||||
name: ${{ steps.pr-info.outputs.is_push == 'true' && 'push-image' || format('pr-image-{0}', steps.pr-info.outputs.pr_number) }}
|
||||
name: ${{ steps.check-artifact.outputs.artifact_name }}
|
||||
run-id: ${{ steps.check-artifact.outputs.run_id }}
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Load Docker image
|
||||
if: steps.check-artifact.outputs.artifact_exists == 'true'
|
||||
if: github.event_name == 'workflow_run' || github.event_name == 'workflow_dispatch'
|
||||
id: load-image
|
||||
run: |
|
||||
echo "📦 Loading Docker image..."
|
||||
docker load < charon-pr-image.tar
|
||||
echo "✅ Docker image loaded"
|
||||
|
||||
if [[ ! -r "charon-pr-image.tar" ]]; then
|
||||
echo "❌ ERROR: Artifact image tar is missing or unreadable"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
MANIFEST_TAGS=""
|
||||
if tar -tf charon-pr-image.tar | grep -qx "manifest.json"; then
|
||||
MANIFEST_TAGS=$(tar -xOf charon-pr-image.tar manifest.json 2>/dev/null | jq -r '.[]?.RepoTags[]?' 2>/dev/null | sed '/^$/d' || true)
|
||||
else
|
||||
echo "⚠️ manifest.json not found in artifact tar; will try docker-load-image-id fallback"
|
||||
fi
|
||||
|
||||
LOAD_OUTPUT=$(docker load < charon-pr-image.tar 2>&1)
|
||||
echo "${LOAD_OUTPUT}"
|
||||
|
||||
SOURCE_IMAGE_REF=""
|
||||
SOURCE_RESOLUTION_MODE=""
|
||||
|
||||
while IFS= read -r tag; do
|
||||
[[ -z "${tag}" ]] && continue
|
||||
if docker image inspect "${tag}" >/dev/null 2>&1; then
|
||||
SOURCE_IMAGE_REF="${tag}"
|
||||
SOURCE_RESOLUTION_MODE="manifest_tag"
|
||||
break
|
||||
fi
|
||||
done <<< "${MANIFEST_TAGS}"
|
||||
|
||||
if [[ -z "${SOURCE_IMAGE_REF}" ]]; then
|
||||
LOAD_IMAGE_ID=$(printf '%s\n' "${LOAD_OUTPUT}" | sed -nE 's/^Loaded image ID: (sha256:[0-9a-f]+)$/\1/p' | head -n1)
|
||||
if [[ -n "${LOAD_IMAGE_ID}" ]] && docker image inspect "${LOAD_IMAGE_ID}" >/dev/null 2>&1; then
|
||||
SOURCE_IMAGE_REF="${LOAD_IMAGE_ID}"
|
||||
SOURCE_RESOLUTION_MODE="load_image_id"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -z "${SOURCE_IMAGE_REF}" ]]; then
|
||||
echo "❌ ERROR: Could not resolve a valid image reference from manifest tags or docker load image ID"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
docker tag "${SOURCE_IMAGE_REF}" "charon:artifact"
|
||||
|
||||
{
|
||||
echo "source_image_ref=${SOURCE_IMAGE_REF}"
|
||||
echo "source_resolution_mode=${SOURCE_RESOLUTION_MODE}"
|
||||
echo "image_ref=charon:artifact"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
echo "✅ Docker image resolved via ${SOURCE_RESOLUTION_MODE} and tagged as charon:artifact"
|
||||
docker images | grep charon
|
||||
|
||||
- name: Extract charon binary from container
|
||||
@@ -214,31 +329,10 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Normalize image name for reference
|
||||
IMAGE_NAME=$(echo "${{ github.repository_owner }}/charon" | tr '[:upper:]' '[:lower:]')
|
||||
if [[ "${{ steps.pr-info.outputs.is_push }}" == "true" ]]; then
|
||||
BRANCH_NAME="${{ github.event.workflow_run.head_branch }}"
|
||||
if [[ -z "${BRANCH_NAME}" ]]; then
|
||||
echo "❌ ERROR: Branch name is empty for push build"
|
||||
exit 1
|
||||
fi
|
||||
# Normalize branch name for Docker tag (replace / and other special chars with -)
|
||||
# This matches docker/metadata-action behavior: type=ref,event=branch
|
||||
TAG_SAFE_BRANCH="${BRANCH_NAME//\//-}"
|
||||
IMAGE_REF="ghcr.io/${IMAGE_NAME}:${TAG_SAFE_BRANCH}"
|
||||
elif [[ -n "${{ steps.pr-info.outputs.pr_number }}" ]]; then
|
||||
IMAGE_REF="ghcr.io/${IMAGE_NAME}:pr-${{ steps.pr-info.outputs.pr_number }}"
|
||||
else
|
||||
echo "❌ ERROR: Cannot determine image reference"
|
||||
echo " - is_push: ${{ steps.pr-info.outputs.is_push }}"
|
||||
echo " - pr_number: ${{ steps.pr-info.outputs.pr_number }}"
|
||||
echo " - branch: ${{ github.event.workflow_run.head_branch }}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Validate the image reference format
|
||||
if [[ ! "${IMAGE_REF}" =~ ^ghcr\.io/[a-z0-9_-]+/[a-z0-9_-]+:[a-zA-Z0-9._-]+$ ]]; then
|
||||
echo "❌ ERROR: Invalid image reference format: ${IMAGE_REF}"
|
||||
# For workflow_run artifact path, always use locally tagged image from loaded artifact.
|
||||
IMAGE_REF="${{ steps.load-image.outputs.image_ref }}"
|
||||
if [[ -z "${IMAGE_REF}" ]]; then
|
||||
echo "❌ ERROR: Loaded artifact image reference is empty"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -268,7 +362,7 @@ jobs:
|
||||
- name: Run Trivy filesystem scan (SARIF output)
|
||||
if: steps.check-artifact.outputs.artifact_exists == 'true' || github.event_name == 'push' || github.event_name == 'pull_request'
|
||||
# aquasecurity/trivy-action v0.33.1
|
||||
uses: aquasecurity/trivy-action@e368e328979b113139d6f9068e03accaed98a518
|
||||
uses: aquasecurity/trivy-action@4c61e6329bab9be735ca35291551614bc663dff3
|
||||
with:
|
||||
scan-type: 'fs'
|
||||
scan-ref: ${{ steps.extract.outputs.binary_path }}
|
||||
@@ -277,19 +371,30 @@ jobs:
|
||||
severity: 'CRITICAL,HIGH,MEDIUM'
|
||||
continue-on-error: true
|
||||
|
||||
- name: Check Trivy SARIF output exists
|
||||
if: always() && (steps.check-artifact.outputs.artifact_exists == 'true' || github.event_name == 'push' || github.event_name == 'pull_request')
|
||||
id: trivy-sarif-check
|
||||
run: |
|
||||
if [[ -f trivy-binary-results.sarif ]]; then
|
||||
echo "exists=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "exists=false" >> "$GITHUB_OUTPUT"
|
||||
echo "ℹ️ No Trivy SARIF output found; skipping SARIF/artifact upload steps"
|
||||
fi
|
||||
|
||||
- name: Upload Trivy SARIF to GitHub Security
|
||||
if: steps.check-artifact.outputs.artifact_exists == 'true' || github.event_name == 'push' || github.event_name == 'pull_request'
|
||||
if: always() && steps.trivy-sarif-check.outputs.exists == 'true'
|
||||
# github/codeql-action v4
|
||||
uses: github/codeql-action/upload-sarif@710e2945787622b429f8982cacb154faa182de18
|
||||
uses: github/codeql-action/upload-sarif@0ec47d036c68ae0cf94c629009b1029407111281
|
||||
with:
|
||||
sarif_file: 'trivy-binary-results.sarif'
|
||||
category: ${{ steps.pr-info.outputs.is_push == 'true' && format('security-scan-{0}', github.event.workflow_run.head_branch) || format('security-scan-pr-{0}', steps.pr-info.outputs.pr_number) }}
|
||||
category: ${{ steps.pr-info.outputs.is_push == 'true' && format('security-scan-{0}', github.event_name == 'workflow_run' && github.event.workflow_run.head_branch || github.ref_name) || format('security-scan-pr-{0}', steps.pr-info.outputs.pr_number) }}
|
||||
continue-on-error: true
|
||||
|
||||
- name: Run Trivy filesystem scan (fail on CRITICAL/HIGH)
|
||||
if: steps.check-artifact.outputs.artifact_exists == 'true' || github.event_name == 'push' || github.event_name == 'pull_request'
|
||||
# aquasecurity/trivy-action v0.33.1
|
||||
uses: aquasecurity/trivy-action@e368e328979b113139d6f9068e03accaed98a518
|
||||
uses: aquasecurity/trivy-action@4c61e6329bab9be735ca35291551614bc663dff3
|
||||
with:
|
||||
scan-type: 'fs'
|
||||
scan-ref: ${{ steps.extract.outputs.binary_path }}
|
||||
@@ -298,11 +403,11 @@ jobs:
|
||||
exit-code: '1'
|
||||
|
||||
- name: Upload scan artifacts
|
||||
if: always() && (steps.check-artifact.outputs.artifact_exists == 'true' || github.event_name == 'push' || github.event_name == 'pull_request')
|
||||
if: always() && steps.trivy-sarif-check.outputs.exists == 'true'
|
||||
# actions/upload-artifact v4.4.3
|
||||
uses: actions/upload-artifact@47309c993abb98030a35d55ef7ff34b7fa1074b5
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f
|
||||
with:
|
||||
name: ${{ steps.pr-info.outputs.is_push == 'true' && format('security-scan-{0}', github.event.workflow_run.head_branch) || format('security-scan-pr-{0}', steps.pr-info.outputs.pr_number) }}
|
||||
name: ${{ steps.pr-info.outputs.is_push == 'true' && format('security-scan-{0}', github.event_name == 'workflow_run' && github.event.workflow_run.head_branch || github.ref_name) || format('security-scan-pr-{0}', steps.pr-info.outputs.pr_number) }}
|
||||
path: |
|
||||
trivy-binary-results.sarif
|
||||
retention-days: 14
|
||||
@@ -312,7 +417,7 @@ jobs:
|
||||
run: |
|
||||
{
|
||||
if [[ "${{ steps.pr-info.outputs.is_push }}" == "true" ]]; then
|
||||
echo "## 🔒 Security Scan Results - Branch: ${{ github.event.workflow_run.head_branch }}"
|
||||
echo "## 🔒 Security Scan Results - Branch: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_branch || github.ref_name }}"
|
||||
else
|
||||
echo "## 🔒 Security Scan Results - PR #${{ steps.pr-info.outputs.pr_number }}"
|
||||
fi
|
||||
|
||||
@@ -6,7 +6,7 @@ name: Weekly Security Rebuild
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 2 * * 0' # Sundays at 02:00 UTC
|
||||
- cron: '0 12 * * 2' # Tuesdays at 12:00 UTC
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
force_rebuild:
|
||||
@@ -119,7 +119,7 @@ jobs:
|
||||
severity: 'CRITICAL,HIGH,MEDIUM,LOW'
|
||||
|
||||
- name: Upload Trivy JSON results
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
|
||||
with:
|
||||
name: trivy-weekly-scan-${{ github.run_number }}
|
||||
path: trivy-weekly-results.json
|
||||
|
||||
38
.github/workflows/supply-chain-pr.yml
vendored
38
.github/workflows/supply-chain-pr.yml
vendored
@@ -11,6 +11,8 @@ on:
|
||||
type: string
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
concurrency:
|
||||
group: supply-chain-pr-${{ github.event.workflow_run.event || github.event_name }}-${{ github.event.workflow_run.head_branch || github.ref }}
|
||||
@@ -264,7 +266,7 @@ jobs:
|
||||
# Generate SBOM using official Anchore action (auto-updated by Renovate)
|
||||
- name: Generate SBOM
|
||||
if: steps.set-target.outputs.image_name != ''
|
||||
uses: anchore/sbom-action@28d71544de8eaf1b958d335707167c5f783590ad # v0.22.2
|
||||
uses: anchore/sbom-action@17ae1740179002c89186b61233e0f892c3118b11 # v0.23.0
|
||||
id: sbom
|
||||
with:
|
||||
image: ${{ steps.set-target.outputs.image_name }}
|
||||
@@ -337,6 +339,27 @@ jobs:
|
||||
echo " Low: ${LOW_COUNT}"
|
||||
echo " Total: ${TOTAL_COUNT}"
|
||||
|
||||
- name: Security severity policy summary
|
||||
if: steps.set-target.outputs.image_name != ''
|
||||
run: |
|
||||
CRITICAL_COUNT="${{ steps.vuln-summary.outputs.critical_count }}"
|
||||
HIGH_COUNT="${{ steps.vuln-summary.outputs.high_count }}"
|
||||
MEDIUM_COUNT="${{ steps.vuln-summary.outputs.medium_count }}"
|
||||
|
||||
{
|
||||
echo "## 🔐 Supply Chain Severity Policy"
|
||||
echo ""
|
||||
echo "- Blocking: Critical, High"
|
||||
echo "- Medium: non-blocking by default (report + triage SLA)"
|
||||
echo "- Policy file: .github/security-severity-policy.yml"
|
||||
echo ""
|
||||
echo "Current scan counts: Critical=${CRITICAL_COUNT}, High=${HIGH_COUNT}, Medium=${MEDIUM_COUNT}"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
if [[ "${MEDIUM_COUNT}" -gt 0 ]]; then
|
||||
echo "::warning::${MEDIUM_COUNT} medium vulnerabilities found. Non-blocking by policy; create/maintain triage issue with SLA per .github/security-severity-policy.yml"
|
||||
fi
|
||||
|
||||
- name: Upload SARIF to GitHub Security
|
||||
if: steps.check-artifact.outputs.artifact_found == 'true'
|
||||
uses: github/codeql-action/upload-sarif@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4
|
||||
@@ -348,7 +371,7 @@ jobs:
|
||||
- name: Upload supply chain artifacts
|
||||
if: steps.set-target.outputs.image_name != ''
|
||||
# actions/upload-artifact v4.6.0
|
||||
uses: actions/upload-artifact@47309c993abb98030a35d55ef7ff34b7fa1074b5
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f
|
||||
with:
|
||||
name: ${{ steps.pr-number.outputs.is_push == 'true' && format('supply-chain-{0}', steps.sanitize.outputs.branch) || format('supply-chain-pr-{0}', steps.pr-number.outputs.pr_number) }}
|
||||
path: |
|
||||
@@ -433,10 +456,11 @@ jobs:
|
||||
|
||||
echo "✅ PR comment posted"
|
||||
|
||||
- name: Fail on critical vulnerabilities
|
||||
- name: Fail on Critical/High vulnerabilities
|
||||
if: steps.set-target.outputs.image_name != ''
|
||||
run: |
|
||||
CRITICAL_COUNT="${{ steps.vuln-summary.outputs.critical_count }}"
|
||||
HIGH_COUNT="${{ steps.vuln-summary.outputs.high_count }}"
|
||||
|
||||
if [[ "${CRITICAL_COUNT}" -gt 0 ]]; then
|
||||
echo "🚨 Found ${CRITICAL_COUNT} CRITICAL vulnerabilities!"
|
||||
@@ -444,4 +468,10 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ No critical vulnerabilities found"
|
||||
if [[ "${HIGH_COUNT}" -gt 0 ]]; then
|
||||
echo "🚨 Found ${HIGH_COUNT} HIGH vulnerabilities!"
|
||||
echo "Please review the vulnerability report and address high severity issues before merging."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ No Critical/High vulnerabilities found"
|
||||
|
||||
6
.github/workflows/supply-chain-verify.yml
vendored
6
.github/workflows/supply-chain-verify.yml
vendored
@@ -119,7 +119,7 @@ jobs:
|
||||
# Generate SBOM using official Anchore action (auto-updated by Renovate)
|
||||
- name: Generate and Verify SBOM
|
||||
if: steps.image-check.outputs.exists == 'true'
|
||||
uses: anchore/sbom-action@28d71544de8eaf1b958d335707167c5f783590ad # v0.22.2
|
||||
uses: anchore/sbom-action@17ae1740179002c89186b61233e0f892c3118b11 # v0.23.0
|
||||
with:
|
||||
image: ghcr.io/${{ github.repository_owner }}/charon:${{ steps.tag.outputs.tag }}
|
||||
format: cyclonedx-json
|
||||
@@ -144,7 +144,7 @@ jobs:
|
||||
|
||||
- name: Upload SBOM Artifact
|
||||
if: steps.image-check.outputs.exists == 'true' && always()
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: sbom-${{ steps.tag.outputs.tag }}
|
||||
path: sbom-verify.cyclonedx.json
|
||||
@@ -324,7 +324,7 @@ jobs:
|
||||
|
||||
- name: Upload Vulnerability Scan Artifact
|
||||
if: steps.validate-sbom.outputs.valid == 'true' && always()
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: vulnerability-scan-${{ steps.tag.outputs.tag }}
|
||||
path: |
|
||||
|
||||
@@ -5,9 +5,9 @@ name: Weekly Nightly to Main Promotion
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Every Monday at 10:30 UTC (5:30am EST / 6:30am EDT)
|
||||
# Every Monday at 12:00 UTC (7:00am EST / 8:00am EDT)
|
||||
# Offset from nightly sync (09:00 UTC) to avoid schedule race and allow validation completion.
|
||||
- cron: '30 10 * * 1'
|
||||
- cron: '0 12 * * 1'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
reason:
|
||||
|
||||
@@ -113,7 +113,7 @@ repos:
|
||||
stages: [manual] # Only runs when explicitly called
|
||||
- id: frontend-type-check
|
||||
name: Frontend TypeScript Check
|
||||
entry: bash -c 'cd frontend && npm run type-check'
|
||||
entry: bash -c 'cd frontend && npx tsc --noEmit'
|
||||
language: system
|
||||
files: '^frontend/.*\.(ts|tsx)$'
|
||||
pass_filenames: false
|
||||
|
||||
163
.vscode/tasks.json
vendored
163
.vscode/tasks.json
vendored
@@ -724,6 +724,13 @@
|
||||
"group": "test",
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "Security: Caddy PR-1 Compatibility Matrix",
|
||||
"type": "shell",
|
||||
"command": "cd /projects/Charon && bash scripts/caddy-compat-matrix.sh --candidate-version 2.11.1 --patch-scenarios A,B,C --platforms linux/amd64,linux/arm64 --smoke-set boot_caddy,plugin_modules,config_validate,admin_api_health --output-dir test-results/caddy-compat --docs-report docs/reports/caddy-compatibility-matrix.md",
|
||||
"group": "test",
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "Test: E2E Playwright (Skill)",
|
||||
"type": "shell",
|
||||
@@ -808,6 +815,162 @@
|
||||
"close": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "Test: E2E Playwright (Chromium) - Non-Security Shards 1/4-4/4",
|
||||
"type": "shell",
|
||||
"command": "cd /projects/Charon && if [ -f .env ]; then set -a; . ./.env; set +a; fi && : \"${CHARON_EMERGENCY_TOKEN:?CHARON_EMERGENCY_TOKEN is required (set it in /projects/Charon/.env)}\" && CI=true PLAYWRIGHT_BASE_URL=http://127.0.0.1:8080 CHARON_SECURITY_TESTS_ENABLED=false PLAYWRIGHT_SKIP_SECURITY_DEPS=1 TEST_WORKER_INDEX=1 npx playwright test --project=chromium --shard=1/4 --output=playwright-output/chromium-shard-1 tests/core tests/dns-provider-crud.spec.ts tests/dns-provider-types.spec.ts tests/integration tests/manual-dns-provider.spec.ts tests/monitoring tests/settings tests/tasks && cd /projects/Charon && CI=true PLAYWRIGHT_BASE_URL=http://127.0.0.1:8080 CHARON_SECURITY_TESTS_ENABLED=false PLAYWRIGHT_SKIP_SECURITY_DEPS=1 TEST_WORKER_INDEX=2 npx playwright test --project=chromium --shard=2/4 --output=playwright-output/chromium-shard-2 tests/core tests/dns-provider-crud.spec.ts tests/dns-provider-types.spec.ts tests/integration tests/manual-dns-provider.spec.ts tests/monitoring tests/settings tests/tasks && cd /projects/Charon && CI=true PLAYWRIGHT_BASE_URL=http://127.0.0.1:8080 CHARON_SECURITY_TESTS_ENABLED=false PLAYWRIGHT_SKIP_SECURITY_DEPS=1 TEST_WORKER_INDEX=3 npx playwright test --project=chromium --shard=3/4 --output=playwright-output/chromium-shard-3 tests/core tests/dns-provider-crud.spec.ts tests/dns-provider-types.spec.ts tests/integration tests/manual-dns-provider.spec.ts tests/monitoring tests/settings tests/tasks && cd /projects/Charon && CI=true PLAYWRIGHT_BASE_URL=http://127.0.0.1:8080 CHARON_SECURITY_TESTS_ENABLED=false PLAYWRIGHT_SKIP_SECURITY_DEPS=1 TEST_WORKER_INDEX=4 npx playwright test --project=chromium --shard=4/4 --output=playwright-output/chromium-shard-4 tests/core tests/dns-provider-crud.spec.ts tests/dns-provider-types.spec.ts tests/integration tests/manual-dns-provider.spec.ts tests/monitoring tests/settings tests/tasks",
|
||||
"group": "test",
|
||||
"problemMatcher": [],
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
"panel": "dedicated",
|
||||
"close": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "Test: E2E Playwright (Chromium) - Non-Security Shard 1/4",
|
||||
"type": "shell",
|
||||
"command": "cd /projects/Charon && if [ -f .env ]; then set -a; . ./.env; set +a; fi && : \"${CHARON_EMERGENCY_TOKEN:?CHARON_EMERGENCY_TOKEN is required (set it in /projects/Charon/.env)}\" && CI=true PLAYWRIGHT_BASE_URL=http://127.0.0.1:8080 CHARON_SECURITY_TESTS_ENABLED=false PLAYWRIGHT_SKIP_SECURITY_DEPS=1 TEST_WORKER_INDEX=1 npx playwright test --project=chromium --shard=1/4 --output=playwright-output/chromium-shard-1 tests/core tests/dns-provider-crud.spec.ts tests/dns-provider-types.spec.ts tests/integration tests/manual-dns-provider.spec.ts tests/monitoring tests/settings tests/tasks",
|
||||
"group": "test",
|
||||
"problemMatcher": [],
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
"panel": "dedicated",
|
||||
"close": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "Test: E2E Playwright (Chromium) - Non-Security Shard 2/4",
|
||||
"type": "shell",
|
||||
"command": "cd /projects/Charon && if [ -f .env ]; then set -a; . ./.env; set +a; fi && : \"${CHARON_EMERGENCY_TOKEN:?CHARON_EMERGENCY_TOKEN is required (set it in /projects/Charon/.env)}\" && CI=true PLAYWRIGHT_BASE_URL=http://127.0.0.1:8080 CHARON_SECURITY_TESTS_ENABLED=false PLAYWRIGHT_SKIP_SECURITY_DEPS=1 TEST_WORKER_INDEX=2 npx playwright test --project=chromium --shard=2/4 --output=playwright-output/chromium-shard-2 tests/core tests/dns-provider-crud.spec.ts tests/dns-provider-types.spec.ts tests/integration tests/manual-dns-provider.spec.ts tests/monitoring tests/settings tests/tasks",
|
||||
"group": "test",
|
||||
"problemMatcher": [],
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
"panel": "dedicated",
|
||||
"close": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "Test: E2E Playwright (Chromium) - Non-Security Shard 3/4",
|
||||
"type": "shell",
|
||||
"command": "cd /projects/Charon && if [ -f .env ]; then set -a; . ./.env; set +a; fi && : \"${CHARON_EMERGENCY_TOKEN:?CHARON_EMERGENCY_TOKEN is required (set it in /projects/Charon/.env)}\" && CI=true PLAYWRIGHT_BASE_URL=http://127.0.0.1:8080 CHARON_SECURITY_TESTS_ENABLED=false PLAYWRIGHT_SKIP_SECURITY_DEPS=1 TEST_WORKER_INDEX=3 npx playwright test --project=chromium --shard=3/4 --output=playwright-output/chromium-shard-3 tests/core tests/dns-provider-crud.spec.ts tests/dns-provider-types.spec.ts tests/integration tests/manual-dns-provider.spec.ts tests/monitoring tests/settings tests/tasks",
|
||||
"group": "test",
|
||||
"problemMatcher": [],
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
"panel": "dedicated",
|
||||
"close": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "Test: E2E Playwright (Chromium) - Non-Security Shard 4/4",
|
||||
"type": "shell",
|
||||
"command": "cd /projects/Charon && if [ -f .env ]; then set -a; . ./.env; set +a; fi && : \"${CHARON_EMERGENCY_TOKEN:?CHARON_EMERGENCY_TOKEN is required (set it in /projects/Charon/.env)}\" && CI=true PLAYWRIGHT_BASE_URL=http://127.0.0.1:8080 CHARON_SECURITY_TESTS_ENABLED=false PLAYWRIGHT_SKIP_SECURITY_DEPS=1 TEST_WORKER_INDEX=4 npx playwright test --project=chromium --shard=4/4 --output=playwright-output/chromium-shard-4 tests/core tests/dns-provider-crud.spec.ts tests/dns-provider-types.spec.ts tests/integration tests/manual-dns-provider.spec.ts tests/monitoring tests/settings tests/tasks",
|
||||
"group": "test",
|
||||
"problemMatcher": [],
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
"panel": "dedicated",
|
||||
"close": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "Test: E2E Playwright (WebKit) - Non-Security Shards 1/4-4/4",
|
||||
"type": "shell",
|
||||
"command": "cd /projects/Charon && if [ -f .env ]; then set -a; . ./.env; set +a; fi && : \"${CHARON_EMERGENCY_TOKEN:?CHARON_EMERGENCY_TOKEN is required (set it in /projects/Charon/.env)}\" && CI=true PLAYWRIGHT_BASE_URL=http://127.0.0.1:8080 CHARON_SECURITY_TESTS_ENABLED=false PLAYWRIGHT_SKIP_SECURITY_DEPS=1 TEST_WORKER_INDEX=1 npx playwright test --project=webkit --shard=1/4 --output=playwright-output/webkit-shard-1 tests/core tests/dns-provider-crud.spec.ts tests/dns-provider-types.spec.ts tests/integration tests/manual-dns-provider.spec.ts tests/monitoring tests/settings tests/tasks && cd /projects/Charon && CI=true PLAYWRIGHT_BASE_URL=http://127.0.0.1:8080 CHARON_SECURITY_TESTS_ENABLED=false PLAYWRIGHT_SKIP_SECURITY_DEPS=1 TEST_WORKER_INDEX=2 npx playwright test --project=webkit --shard=2/4 --output=playwright-output/webkit-shard-2 tests/core tests/dns-provider-crud.spec.ts tests/dns-provider-types.spec.ts tests/integration tests/manual-dns-provider.spec.ts tests/monitoring tests/settings tests/tasks && cd /projects/Charon && CI=true PLAYWRIGHT_BASE_URL=http://127.0.0.1:8080 CHARON_SECURITY_TESTS_ENABLED=false PLAYWRIGHT_SKIP_SECURITY_DEPS=1 TEST_WORKER_INDEX=3 npx playwright test --project=webkit --shard=3/4 --output=playwright-output/webkit-shard-3 tests/core tests/dns-provider-crud.spec.ts tests/dns-provider-types.spec.ts tests/integration tests/manual-dns-provider.spec.ts tests/monitoring tests/settings tests/tasks && cd /projects/Charon && CI=true PLAYWRIGHT_BASE_URL=http://127.0.0.1:8080 CHARON_SECURITY_TESTS_ENABLED=false PLAYWRIGHT_SKIP_SECURITY_DEPS=1 TEST_WORKER_INDEX=4 npx playwright test --project=webkit --shard=4/4 --output=playwright-output/webkit-shard-4 tests/core tests/dns-provider-crud.spec.ts tests/dns-provider-types.spec.ts tests/integration tests/manual-dns-provider.spec.ts tests/monitoring tests/settings tests/tasks",
|
||||
"group": "test",
|
||||
"problemMatcher": [],
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
"panel": "dedicated",
|
||||
"close": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "Test: E2E Playwright (WebKit) - Non-Security Shard 1/4",
|
||||
"type": "shell",
|
||||
"command": "cd /projects/Charon && if [ -f .env ]; then set -a; . ./.env; set +a; fi && : \"${CHARON_EMERGENCY_TOKEN:?CHARON_EMERGENCY_TOKEN is required (set it in /projects/Charon/.env)}\" && CI=true PLAYWRIGHT_BASE_URL=http://127.0.0.1:8080 CHARON_SECURITY_TESTS_ENABLED=false PLAYWRIGHT_SKIP_SECURITY_DEPS=1 TEST_WORKER_INDEX=1 npx playwright test --project=webkit --shard=1/4 --output=playwright-output/webkit-shard-1 tests/core tests/dns-provider-crud.spec.ts tests/dns-provider-types.spec.ts tests/integration tests/manual-dns-provider.spec.ts tests/monitoring tests/settings tests/tasks",
|
||||
"group": "test",
|
||||
"problemMatcher": [],
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
"panel": "dedicated",
|
||||
"close": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "Test: E2E Playwright (WebKit) - Non-Security Shard 2/4",
|
||||
"type": "shell",
|
||||
"command": "cd /projects/Charon && if [ -f .env ]; then set -a; . ./.env; set +a; fi && : \"${CHARON_EMERGENCY_TOKEN:?CHARON_EMERGENCY_TOKEN is required (set it in /projects/Charon/.env)}\" && CI=true PLAYWRIGHT_BASE_URL=http://127.0.0.1:8080 CHARON_SECURITY_TESTS_ENABLED=false PLAYWRIGHT_SKIP_SECURITY_DEPS=1 TEST_WORKER_INDEX=2 npx playwright test --project=webkit --shard=2/4 --output=playwright-output/webkit-shard-2 tests/core tests/dns-provider-crud.spec.ts tests/dns-provider-types.spec.ts tests/integration tests/manual-dns-provider.spec.ts tests/monitoring tests/settings tests/tasks",
|
||||
"group": "test",
|
||||
"problemMatcher": [],
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
"panel": "dedicated",
|
||||
"close": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "Test: E2E Playwright (WebKit) - Non-Security Shard 3/4",
|
||||
"type": "shell",
|
||||
"command": "cd /projects/Charon && if [ -f .env ]; then set -a; . ./.env; set +a; fi && : \"${CHARON_EMERGENCY_TOKEN:?CHARON_EMERGENCY_TOKEN is required (set it in /projects/Charon/.env)}\" && CI=true PLAYWRIGHT_BASE_URL=http://127.0.0.1:8080 CHARON_SECURITY_TESTS_ENABLED=false PLAYWRIGHT_SKIP_SECURITY_DEPS=1 TEST_WORKER_INDEX=3 npx playwright test --project=webkit --shard=3/4 --output=playwright-output/webkit-shard-3 tests/core tests/dns-provider-crud.spec.ts tests/dns-provider-types.spec.ts tests/integration tests/manual-dns-provider.spec.ts tests/monitoring tests/settings tests/tasks",
|
||||
"group": "test",
|
||||
"problemMatcher": [],
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
"panel": "dedicated",
|
||||
"close": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "Test: E2E Playwright (WebKit) - Non-Security Shard 4/4",
|
||||
"type": "shell",
|
||||
"command": "cd /projects/Charon && if [ -f .env ]; then set -a; . ./.env; set +a; fi && : \"${CHARON_EMERGENCY_TOKEN:?CHARON_EMERGENCY_TOKEN is required (set it in /projects/Charon/.env)}\" && CI=true PLAYWRIGHT_BASE_URL=http://127.0.0.1:8080 CHARON_SECURITY_TESTS_ENABLED=false PLAYWRIGHT_SKIP_SECURITY_DEPS=1 TEST_WORKER_INDEX=4 npx playwright test --project=webkit --shard=4/4 --output=playwright-output/webkit-shard-4 tests/core tests/dns-provider-crud.spec.ts tests/dns-provider-types.spec.ts tests/integration tests/manual-dns-provider.spec.ts tests/monitoring tests/settings tests/tasks",
|
||||
"group": "test",
|
||||
"problemMatcher": [],
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
"panel": "dedicated",
|
||||
"close": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "Test: E2E Playwright (Chromium) - Security Suite",
|
||||
"type": "shell",
|
||||
"command": "cd /projects/Charon && if [ -f .env ]; then set -a; . ./.env; set +a; fi && : \"${CHARON_EMERGENCY_TOKEN:?CHARON_EMERGENCY_TOKEN is required (set it in /projects/Charon/.env)}\" && CI=true PLAYWRIGHT_BASE_URL=http://127.0.0.1:8080 CHARON_SECURITY_TESTS_ENABLED=true PLAYWRIGHT_SKIP_SECURITY_DEPS=0 npx playwright test --project=security-tests --output=playwright-output/chromium-security tests/security",
|
||||
"group": "test",
|
||||
"problemMatcher": [],
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
"panel": "dedicated",
|
||||
"close": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "Test: E2E Playwright (FireFox) - Security Suite",
|
||||
"type": "shell",
|
||||
"command": "cd /projects/Charon && if [ -f .env ]; then set -a; . ./.env; set +a; fi && : \"${CHARON_EMERGENCY_TOKEN:?CHARON_EMERGENCY_TOKEN is required (set it in /projects/Charon/.env)}\" && CI=true PLAYWRIGHT_BASE_URL=http://127.0.0.1:8080 CHARON_SECURITY_TESTS_ENABLED=true PLAYWRIGHT_SKIP_SECURITY_DEPS=0 npx playwright test --project=firefox --output=playwright-output/firefox-security tests/security",
|
||||
"group": "test",
|
||||
"problemMatcher": [],
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
"panel": "dedicated",
|
||||
"close": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "Test: E2E Playwright (WebKit) - Security Suite",
|
||||
"type": "shell",
|
||||
"command": "cd /projects/Charon && if [ -f .env ]; then set -a; . ./.env; set +a; fi && : \"${CHARON_EMERGENCY_TOKEN:?CHARON_EMERGENCY_TOKEN is required (set it in /projects/Charon/.env)}\" && CI=true PLAYWRIGHT_BASE_URL=http://127.0.0.1:8080 CHARON_SECURITY_TESTS_ENABLED=true PLAYWRIGHT_SKIP_SECURITY_DEPS=0 npx playwright test --project=webkit --output=playwright-output/webkit-security tests/security",
|
||||
"group": "test",
|
||||
"problemMatcher": [],
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
"panel": "dedicated",
|
||||
"close": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "Test: E2E Playwright with Coverage",
|
||||
"type": "shell",
|
||||
|
||||
@@ -126,7 +126,7 @@ graph TB
|
||||
| **HTTP Framework** | Gin | Latest | Routing, middleware, HTTP handling |
|
||||
| **Database** | SQLite | 3.x | Embedded database |
|
||||
| **ORM** | GORM | Latest | Database abstraction layer |
|
||||
| **Reverse Proxy** | Caddy Server | 2.11.0-beta.2 | Embedded HTTP/HTTPS proxy |
|
||||
| **Reverse Proxy** | Caddy Server | 2.11.1 | Embedded HTTP/HTTPS proxy |
|
||||
| **WebSocket** | gorilla/websocket | Latest | Real-time log streaming |
|
||||
| **Crypto** | golang.org/x/crypto | Latest | Password hashing, encryption |
|
||||
| **Metrics** | Prometheus Client | Latest | Application metrics |
|
||||
@@ -1259,6 +1259,14 @@ go test ./integration/...
|
||||
9. **Release Notes:** Generate changelog from commits
|
||||
10. **Notify:** Send release notification (Discord, email)
|
||||
|
||||
**Mandatory rollout gates (sign-off block):**
|
||||
|
||||
1. Digest freshness and index digest parity across GHCR and Docker Hub
|
||||
2. Per-arch digest parity across GHCR and Docker Hub
|
||||
3. SBOM and vulnerability scans against immutable refs (`image@sha256:...`)
|
||||
4. Artifact freshness timestamps after push
|
||||
5. Evidence block with required rollout verification fields
|
||||
|
||||
### Supply Chain Security
|
||||
|
||||
**Components:**
|
||||
@@ -1292,10 +1300,10 @@ cosign verify \
|
||||
wikid82/charon:latest
|
||||
|
||||
# Inspect SBOM
|
||||
syft wikid82/charon:latest -o json
|
||||
syft ghcr.io/wikid82/charon@sha256:<index-digest> -o json
|
||||
|
||||
# Scan for vulnerabilities
|
||||
grype wikid82/charon:latest
|
||||
grype ghcr.io/wikid82/charon@sha256:<index-digest>
|
||||
```
|
||||
|
||||
### Rollback Strategy
|
||||
|
||||
@@ -31,6 +31,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
### Fixed
|
||||
- Fixed: Added robust validation and debug logging for Docker image tags to prevent invalid reference errors.
|
||||
- Fixed: Removed log masking for image references and added manifest validation to debug CI failures.
|
||||
- **Proxy Hosts**: Fixed ACL and Security Headers dropdown selections so create/edit saves now keep the selected values (including clearing to none) after submit and reload.
|
||||
- **CI**: Fixed Docker image reference output so integration jobs never pull an empty image ref
|
||||
- **E2E Test Reliability**: Resolved test timeout issues affecting CI/CD pipeline stability
|
||||
- Fixed config reload overlay blocking test interactions
|
||||
|
||||
41
Dockerfile
41
Dockerfile
@@ -14,8 +14,11 @@ ARG BUILD_DEBUG=0
|
||||
# avoid accidentally pulling a v3 major release. Renovate can still update
|
||||
# this ARG to a specific v2.x tag when desired.
|
||||
## Try to build the requested Caddy v2.x tag (Renovate can update this ARG).
|
||||
## If the requested tag isn't available, fall back to a known-good v2.11.0-beta.2 build.
|
||||
ARG CADDY_VERSION=2.11.0-beta.2
|
||||
## If the requested tag isn't available, fall back to a known-good v2.11.1 build.
|
||||
ARG CADDY_VERSION=2.11.1
|
||||
ARG CADDY_CANDIDATE_VERSION=2.11.1
|
||||
ARG CADDY_USE_CANDIDATE=0
|
||||
ARG CADDY_PATCH_SCENARIO=B
|
||||
## When an official caddy image tag isn't available on the host, use a
|
||||
## plain Alpine base image and overwrite its caddy binary with our
|
||||
## xcaddy-built binary in the later COPY step. This avoids relying on
|
||||
@@ -65,7 +68,7 @@ RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||
# ---- Frontend Builder ----
|
||||
# Build the frontend using the BUILDPLATFORM to avoid arm64 musl Rollup native issues
|
||||
# renovate: datasource=docker depName=node
|
||||
FROM --platform=$BUILDPLATFORM node:24.13.1-alpine AS frontend-builder
|
||||
FROM --platform=$BUILDPLATFORM node:24.14.0-alpine AS frontend-builder
|
||||
WORKDIR /app/frontend
|
||||
|
||||
# Copy frontend package files
|
||||
@@ -196,6 +199,9 @@ FROM --platform=$BUILDPLATFORM golang:1.26-alpine AS caddy-builder
|
||||
ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
ARG CADDY_VERSION
|
||||
ARG CADDY_CANDIDATE_VERSION
|
||||
ARG CADDY_USE_CANDIDATE
|
||||
ARG CADDY_PATCH_SCENARIO
|
||||
# renovate: datasource=go depName=github.com/caddyserver/xcaddy
|
||||
ARG XCADDY_VERSION=0.4.5
|
||||
|
||||
@@ -213,10 +219,16 @@ RUN --mount=type=cache,target=/go/pkg/mod \
|
||||
RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||
--mount=type=cache,target=/go/pkg/mod \
|
||||
sh -c 'set -e; \
|
||||
CADDY_TARGET_VERSION="${CADDY_VERSION}"; \
|
||||
if [ "${CADDY_USE_CANDIDATE}" = "1" ]; then \
|
||||
CADDY_TARGET_VERSION="${CADDY_CANDIDATE_VERSION}"; \
|
||||
fi; \
|
||||
echo "Using Caddy target version: v${CADDY_TARGET_VERSION}"; \
|
||||
echo "Using Caddy patch scenario: ${CADDY_PATCH_SCENARIO}"; \
|
||||
export XCADDY_SKIP_CLEANUP=1; \
|
||||
echo "Stage 1: Generate go.mod with xcaddy..."; \
|
||||
# Run xcaddy to generate the build directory and go.mod
|
||||
GOOS=$TARGETOS GOARCH=$TARGETARCH xcaddy build v${CADDY_VERSION} \
|
||||
GOOS=$TARGETOS GOARCH=$TARGETARCH xcaddy build v${CADDY_TARGET_VERSION} \
|
||||
--with github.com/greenpau/caddy-security \
|
||||
--with github.com/corazawaf/coraza-caddy/v2 \
|
||||
--with github.com/hslatman/caddy-crowdsec-bouncer@v0.10.0 \
|
||||
@@ -239,12 +251,21 @@ RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||
go get github.com/expr-lang/expr@v1.17.7; \
|
||||
# renovate: datasource=go depName=github.com/hslatman/ipstore
|
||||
go get github.com/hslatman/ipstore@v0.4.0; \
|
||||
# NOTE: smallstep/certificates (pulled by caddy-security stack) currently
|
||||
# uses legacy nebula APIs removed in nebula v1.10+, which causes compile
|
||||
# failures in authority/provisioner. Keep this pinned to a known-compatible
|
||||
# v1.9.x release until upstream stack supports nebula v1.10+.
|
||||
# renovate: datasource=go depName=github.com/slackhq/nebula
|
||||
go get github.com/slackhq/nebula@v1.9.7; \
|
||||
if [ "${CADDY_PATCH_SCENARIO}" = "A" ]; then \
|
||||
# Rollback scenario: keep explicit nebula pin if upstream compatibility regresses.
|
||||
# NOTE: smallstep/certificates (pulled by caddy-security stack) currently
|
||||
# uses legacy nebula APIs removed in nebula v1.10+, which causes compile
|
||||
# failures in authority/provisioner. Keep this pinned to a known-compatible
|
||||
# v1.9.x release until upstream stack supports nebula v1.10+.
|
||||
# renovate: datasource=go depName=github.com/slackhq/nebula
|
||||
go get github.com/slackhq/nebula@v1.9.7; \
|
||||
elif [ "${CADDY_PATCH_SCENARIO}" = "B" ] || [ "${CADDY_PATCH_SCENARIO}" = "C" ]; then \
|
||||
# Default PR-2 posture: retire explicit nebula pin and use upstream resolution.
|
||||
echo "Skipping nebula pin for scenario ${CADDY_PATCH_SCENARIO}"; \
|
||||
else \
|
||||
echo "Unsupported CADDY_PATCH_SCENARIO=${CADDY_PATCH_SCENARIO}"; \
|
||||
exit 1; \
|
||||
fi; \
|
||||
# Clean up go.mod and ensure all dependencies are resolved
|
||||
go mod tidy; \
|
||||
echo "Dependencies patched successfully"; \
|
||||
|
||||
13
README.md
13
README.md
@@ -94,6 +94,19 @@ services:
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
```
|
||||
> **Docker Socket Access:** Charon runs as a non-root user. If you mount the Docker socket for container discovery, the container needs permission to read it. Find your socket's group ID and add it to the compose file:
|
||||
>
|
||||
> ```bash
|
||||
> stat -c '%g' /var/run/docker.sock
|
||||
> ```
|
||||
>
|
||||
> Then add `group_add: ["<gid>"]` under your service (replace `<gid>` with the number from the command above). For example, if the result is `998`:
|
||||
>
|
||||
> ```yaml
|
||||
> group_add:
|
||||
> - "998"
|
||||
> ```
|
||||
|
||||
### 2️⃣ Generate encryption key:
|
||||
```bash
|
||||
openssl rand -base64 32
|
||||
|
||||
@@ -25,11 +25,10 @@ We take security seriously. If you discover a security vulnerability in Charon,
|
||||
- Impact assessment
|
||||
- Suggested fix (if applicable)
|
||||
|
||||
**Alternative Method**: Email
|
||||
**Alternative Method**: GitHub Issues (Public)
|
||||
|
||||
- Send to: `security@charon.dev` (if configured)
|
||||
- Use PGP encryption (key available below, if applicable)
|
||||
- Include same information as GitHub advisory
|
||||
1. Go to <https://github.com/Wikid82/Charon/issues>
|
||||
2. Create a new issue with the same information as above
|
||||
|
||||
### What to Include
|
||||
|
||||
@@ -125,6 +124,7 @@ For complete technical details, see:
|
||||
|
||||
### Infrastructure Security
|
||||
|
||||
- **Non-root by default**: Charon runs as an unprivileged user (`charon`, uid 1000) inside the container. Docker socket access is granted via a minimal supplemental group matching the host socket's GID—never by running as root. If the socket GID is `0` (root group), Charon requires explicit opt-in before granting access.
|
||||
- **Container isolation**: Docker-based deployment
|
||||
- **Minimal attack surface**: Alpine Linux base image
|
||||
- **Dependency scanning**: Regular Trivy and govulncheck scans
|
||||
|
||||
74
VERSION.md
74
VERSION.md
@@ -19,36 +19,76 @@ Example: `0.1.0-alpha`, `1.0.0-beta.1`, `2.0.0-rc.2`
|
||||
|
||||
## Creating a Release
|
||||
|
||||
### Automated Release Process
|
||||
### Canonical Release Process (Tag-Derived CI)
|
||||
|
||||
1. **Update version** in `.version` file:
|
||||
1. **Create and push a release tag**:
|
||||
|
||||
```bash
|
||||
echo "1.0.0" > .version
|
||||
git tag -a v1.0.0 -m "Release v1.0.0"
|
||||
git push origin v1.0.0
|
||||
```
|
||||
|
||||
2. **Commit version bump**:
|
||||
2. **GitHub Actions automatically**:
|
||||
- Runs release workflow from the pushed tag (`.github/workflows/release-goreleaser.yml`)
|
||||
- Builds and publishes release artifacts/images through CI (`.github/workflows/docker-build.yml`)
|
||||
- Creates/updates GitHub Release metadata
|
||||
|
||||
3. **Container tags are published**:
|
||||
- `v1.0.0` (exact version)
|
||||
- `1.0` (minor version)
|
||||
- `1` (major version)
|
||||
- `latest` (for non-prerelease on main branch)
|
||||
|
||||
### Legacy/Optional `.version` Path
|
||||
|
||||
The `.version` file is optional and not the canonical release trigger.
|
||||
|
||||
Use it only when you need local/version-file parity checks:
|
||||
|
||||
1. **Set `.version` locally (optional)**:
|
||||
|
||||
```bash
|
||||
git add .version
|
||||
git commit -m "chore: bump version to 1.0.0"
|
||||
echo "1.0.0" > .version
|
||||
```
|
||||
|
||||
3. **Create and push tag**:
|
||||
2. **Validate `.version` matches the latest tag**:
|
||||
|
||||
```bash
|
||||
git tag -a v1.0.0 -m "Release v1.0.0"
|
||||
git push origin v1.0.0
|
||||
bash scripts/check-version-match-tag.sh
|
||||
```
|
||||
|
||||
4. **GitHub Actions automatically**:
|
||||
- Creates GitHub Release with changelog
|
||||
- Builds multi-arch Docker images (amd64, arm64)
|
||||
- Publishes to GitHub Container Registry with tags:
|
||||
- `v1.0.0` (exact version)
|
||||
- `1.0` (minor version)
|
||||
- `1` (major version)
|
||||
- `latest` (for non-prerelease on main branch)
|
||||
### Deterministic Rollout Verification Gates (Mandatory)
|
||||
|
||||
Release sign-off is blocked until all items below pass in the same validation
|
||||
run.
|
||||
|
||||
Enforcement points:
|
||||
|
||||
- Release sign-off checklist/process (mandatory): All gates below remain required for release sign-off.
|
||||
- CI-supported checks (current): `.github/workflows/docker-build.yml` and `.github/workflows/supply-chain-verify.yml` enforce the subset currently implemented in workflows.
|
||||
- Manual validation required until CI parity: Validate any not-yet-implemented workflow gates via VS Code tasks `Security: Full Supply Chain Audit`, `Security: Verify SBOM`, `Security: Generate SLSA Provenance`, and `Security: Sign with Cosign`.
|
||||
- Optional version-file parity check: `Utility: Check Version Match Tag` (script: `scripts/check-version-match-tag.sh`).
|
||||
|
||||
- [ ] **Digest freshness/parity:** Capture pre-push and post-push index digests
|
||||
for the target tag in GHCR and Docker Hub, confirm expected freshness,
|
||||
and confirm cross-registry index digest parity.
|
||||
- [ ] **Per-arch parity:** Confirm per-platform (`linux/amd64`, `linux/arm64`,
|
||||
and any published platform) digest parity between GHCR and Docker Hub.
|
||||
- [ ] **Immutable digest scanning:** Run SBOM and vulnerability scans against
|
||||
immutable refs only, using `image@sha256:<index-digest>`.
|
||||
- [ ] **Artifact freshness:** Confirm scan artifacts are generated after the
|
||||
push timestamp and in the same validation run.
|
||||
- [ ] **Evidence block present:** Include the mandatory evidence block fields
|
||||
listed below.
|
||||
|
||||
#### Mandatory Evidence Block Fields
|
||||
|
||||
- Tag name
|
||||
- Index digest (`sha256:...`)
|
||||
- Per-arch digests (platform -> digest)
|
||||
- Scan tool versions
|
||||
- Push timestamp and scan timestamp(s)
|
||||
- Artifact file names generated in this run
|
||||
|
||||
## Container Image Tags
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ linters:
|
||||
- ineffassign # Ineffectual assignments
|
||||
- unused # Unused code detection
|
||||
- gosec # Security checks (critical issues only)
|
||||
linters-settings:
|
||||
settings:
|
||||
govet:
|
||||
enable:
|
||||
- shadow
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# golangci-lint configuration
|
||||
version: 2
|
||||
version: "2"
|
||||
run:
|
||||
timeout: 5m
|
||||
tests: true
|
||||
@@ -14,7 +14,7 @@ linters:
|
||||
- staticcheck
|
||||
- unused
|
||||
- errcheck
|
||||
linters-settings:
|
||||
settings:
|
||||
gocritic:
|
||||
enabled-tags:
|
||||
- diagnostic
|
||||
|
||||
@@ -260,7 +260,7 @@ func main() {
|
||||
}
|
||||
|
||||
// Register import handler with config dependencies
|
||||
routes.RegisterImportHandler(router, db, cfg.CaddyBinary, cfg.ImportDir, cfg.ImportCaddyfile)
|
||||
routes.RegisterImportHandler(router, db, cfg, cfg.CaddyBinary, cfg.ImportDir, cfg.ImportCaddyfile)
|
||||
|
||||
// Check for mounted Caddyfile on startup
|
||||
if err := handlers.CheckMountedImport(db, cfg.ImportCaddyfile, cfg.CaddyBinary, cfg.ImportDir); err != nil {
|
||||
|
||||
@@ -311,7 +311,8 @@ func TestMain_DefaultStartupGracefulShutdown_Subprocess(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("find free http port: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(dbPath), 0o750); err != nil {
|
||||
err = os.MkdirAll(filepath.Dir(dbPath), 0o750)
|
||||
if err != nil {
|
||||
t.Fatalf("mkdir db dir: %v", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -64,11 +64,13 @@ func main() {
|
||||
jsonOutPath := resolvePath(repoRoot, *jsonOutFlag)
|
||||
mdOutPath := resolvePath(repoRoot, *mdOutFlag)
|
||||
|
||||
if err := assertFileExists(backendCoveragePath, "backend coverage file"); err != nil {
|
||||
err = assertFileExists(backendCoveragePath, "backend coverage file")
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := assertFileExists(frontendCoveragePath, "frontend coverage file"); err != nil {
|
||||
err = assertFileExists(frontendCoveragePath, "frontend coverage file")
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
@@ -235,7 +235,8 @@ func TestGitDiffAndWriters(t *testing.T) {
|
||||
t.Fatalf("expected empty diff for HEAD...HEAD, got: %q", diffContent)
|
||||
}
|
||||
|
||||
if _, err := gitDiff(repoRoot, "bad-baseline"); err == nil {
|
||||
_, err = gitDiff(repoRoot, "bad-baseline")
|
||||
if err == nil {
|
||||
t.Fatal("expected gitDiff failure for invalid baseline")
|
||||
}
|
||||
|
||||
@@ -263,7 +264,8 @@ func TestGitDiffAndWriters(t *testing.T) {
|
||||
}
|
||||
|
||||
jsonPath := filepath.Join(t.TempDir(), "report.json")
|
||||
if err := writeJSON(jsonPath, report); err != nil {
|
||||
err = writeJSON(jsonPath, report)
|
||||
if err != nil {
|
||||
t.Fatalf("writeJSON should succeed: %v", err)
|
||||
}
|
||||
// #nosec G304 -- Test reads artifact path created by this test.
|
||||
@@ -276,7 +278,8 @@ func TestGitDiffAndWriters(t *testing.T) {
|
||||
}
|
||||
|
||||
markdownPath := filepath.Join(t.TempDir(), "report.md")
|
||||
if err := writeMarkdown(markdownPath, report, "backend/coverage.txt", "frontend/coverage/lcov.info"); err != nil {
|
||||
err = writeMarkdown(markdownPath, report, "backend/coverage.txt", "frontend/coverage/lcov.info")
|
||||
if err != nil {
|
||||
t.Fatalf("writeMarkdown should succeed: %v", err)
|
||||
}
|
||||
// #nosec G304 -- Test reads artifact path created by this test.
|
||||
|
||||
@@ -5,7 +5,7 @@ go 1.26
|
||||
require (
|
||||
github.com/docker/docker v28.5.2+incompatible
|
||||
github.com/gin-contrib/gzip v1.2.5
|
||||
github.com/gin-gonic/gin v1.11.0
|
||||
github.com/gin-gonic/gin v1.12.0
|
||||
github.com/glebarez/sqlite v1.11.0
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/google/uuid v1.6.0
|
||||
@@ -17,7 +17,7 @@ require (
|
||||
github.com/sirupsen/logrus v1.9.4
|
||||
github.com/stretchr/testify v1.11.1
|
||||
golang.org/x/crypto v0.48.0
|
||||
golang.org/x/net v0.50.0
|
||||
golang.org/x/net v0.51.0
|
||||
golang.org/x/text v0.34.0
|
||||
golang.org/x/time v0.14.0
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
||||
@@ -29,8 +29,8 @@ require (
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/bytedance/sonic v1.14.1 // indirect
|
||||
github.com/bytedance/sonic/loader v0.3.0 // indirect
|
||||
github.com/bytedance/sonic v1.15.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/containerd/errdefs v1.0.0 // indirect
|
||||
@@ -42,16 +42,16 @@ require (
|
||||
github.com/docker/go-units v0.5.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/glebarez/go-sqlite v1.21.2 // indirect
|
||||
github.com/glebarez/go-sqlite v1.22.0 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/goccy/go-yaml v1.18.0 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
@@ -66,6 +66,7 @@ require (
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/morikuni/aec v1.0.0 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||
github.com/opencontainers/image-spec v1.1.1 // indirect
|
||||
github.com/oschwald/maxminddb-golang/v2 v2.1.1 // indirect
|
||||
@@ -73,28 +74,29 @@ require (
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.66.1 // indirect
|
||||
github.com/prometheus/procfs v0.16.1 // indirect
|
||||
github.com/prometheus/common v0.67.5 // indirect
|
||||
github.com/prometheus/procfs v0.20.1 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.59.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/stretchr/objx v0.5.2 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.0 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 // indirect
|
||||
go.opentelemetry.io/otel v1.38.0 // indirect
|
||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 // indirect
|
||||
go.opentelemetry.io/otel v1.40.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.38.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.38.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.2 // indirect
|
||||
golang.org/x/arch v0.22.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.40.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.40.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||
golang.org/x/arch v0.24.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
gotest.tools/v3 v3.5.2 // indirect
|
||||
modernc.org/libc v1.22.5 // indirect
|
||||
modernc.org/mathutil v1.5.0 // indirect
|
||||
modernc.org/memory v1.5.0 // indirect
|
||||
modernc.org/sqlite v1.23.1 // indirect
|
||||
modernc.org/libc v1.69.0 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
modernc.org/sqlite v1.46.1 // indirect
|
||||
)
|
||||
|
||||
136
backend/go.sum
136
backend/go.sum
@@ -6,10 +6,10 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||
github.com/bytedance/sonic v1.14.1 h1:FBMC0zVz5XUmE4z9wF4Jey0An5FueFvOsTKKKtwIl7w=
|
||||
github.com/bytedance/sonic v1.14.1/go.mod h1:gi6uhQLMbTdeP0muCnrjHLeCUPyb70ujhnNlhOylAFc=
|
||||
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
|
||||
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
||||
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
||||
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
||||
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
@@ -37,16 +37,16 @@ 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/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gin-contrib/gzip v1.2.5 h1:fIZs0S+l17pIu1P5XRJOo/YNqfIuPCrZZ3TWB7pjckI=
|
||||
github.com/gin-contrib/gzip v1.2.5/go.mod h1:aomRgR7ftdZV3uWY0gW/m8rChfxau0n8YVvwlOHONzw=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
|
||||
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
|
||||
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
|
||||
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
|
||||
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
||||
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
||||
github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
|
||||
github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc=
|
||||
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
|
||||
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
@@ -64,21 +64,23 @@ github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy0
|
||||
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
|
||||
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnVTyacbefKhmbLhIhU=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
@@ -118,6 +120,8 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
||||
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
|
||||
@@ -136,21 +140,20 @@ github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h
|
||||
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||
github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
|
||||
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
|
||||
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
|
||||
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
|
||||
github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
|
||||
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
|
||||
github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
|
||||
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
|
||||
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/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
||||
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
|
||||
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
|
||||
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
@@ -161,45 +164,52 @@ github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
|
||||
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 h1:RbKq8BG0FI8OiXhBfcRtqqHcZcka+gU3cskNuf05R18=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0/go.mod h1:h06DGIukJOevXaj/xrNjhi/2098RZzcLTbc0jDAUbsg=
|
||||
go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
|
||||
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
|
||||
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 h1:7iP2uCb7sGddAr30RRS6xjKy7AZ2JtTOPA3oolgVSw8=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0/go.mod h1:c7hN3ddxs/z6q9xwvfLPk+UHlWRQyaeR1LdgfL/66l0=
|
||||
go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms=
|
||||
go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 h1:GqRJVj7UmLjCVyVJ3ZFLdPRmhDUp2zFmQe3RHIOsw24=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0/go.mod h1:ri3aaHSmCTVYu2AWv44YMauwAQc0aqI9gHKIcSbI1pU=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0 h1:aTL7F04bJHUlztTsNGJ2l+6he8c+y/b//eR0jjjemT4=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0/go.mod h1:kldtb7jDTeol0l3ewcmd8SDvx3EmIE7lyvqbasU3QC4=
|
||||
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
|
||||
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
|
||||
go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E=
|
||||
go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA=
|
||||
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
|
||||
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
|
||||
go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g=
|
||||
go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc=
|
||||
go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8=
|
||||
go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg=
|
||||
go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw=
|
||||
go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA=
|
||||
go.opentelemetry.io/proto/otlp v1.7.1 h1:gTOMpGDb0WTBOP8JaO72iL3auEZhVmAQg4ipjOVAtj4=
|
||||
go.opentelemetry.io/proto/otlp v1.7.1/go.mod h1:b2rVh6rfI/s2pHWNlB7ILJcRALpcNDzKhACevjI+ZnE=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
|
||||
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
|
||||
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
|
||||
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
|
||||
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
|
||||
golang.org/x/arch v0.24.0 h1:qlJ3M9upxvFfwRM51tTg3Yl+8CP9vCC1E7vlFpgv99Y=
|
||||
golang.org/x/arch v0.24.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
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/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/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||
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/sys v0.6.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=
|
||||
@@ -207,6 +217,8 @@ golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
||||
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 h1:BIRfGDEjiHRrk0QKZe3Xv2ieMhtgRGeLcZQ0mIVn4EY=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5/go.mod h1:j3QtIyytwqGr1JUDtYXwtMXWPKsEa5LtzIFN1Wn5WvE=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5 h1:eaY8u2EuxbRv7c3NiGK0/NedzVsCcV6hDuU5qPX5EGE=
|
||||
@@ -229,11 +241,31 @@ gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
|
||||
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
|
||||
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
|
||||
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
|
||||
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
|
||||
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
|
||||
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
|
||||
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
|
||||
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
|
||||
modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
|
||||
modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
|
||||
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
||||
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.31.0 h1:/bsaxqdgX3gy/0DboxcvWrc3NpzH+6wpFfI/ZaA/hrg=
|
||||
modernc.org/ccgo/v4 v4.31.0/go.mod h1:jKe8kPBjIN/VdGTVqARTQ8N1gAziBmiISY8j5HoKwjg=
|
||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
|
||||
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.69.0 h1:YQJ5QMSReTgQ3QFmI0dudfjXIjCcYTUxcH8/9P9f0D8=
|
||||
modernc.org/libc v1.69.0/go.mod h1:YfLLduUEbodNV2xLU5JOnRHBTAHVHsVW3bVYGw0ZCV4=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU=
|
||||
modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
//go:build integration
|
||||
// +build integration
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/Wikid82/charon/backend/internal/notifications"
|
||||
)
|
||||
|
||||
func TestNotificationHTTPWrapperIntegration_RetriesOn429AndSucceeds(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var calls int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
current := atomic.AddInt32(&calls, 1)
|
||||
if current == 1 {
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"ok":true}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
wrapper := notifications.NewNotifyHTTPWrapper()
|
||||
result, err := wrapper.Send(context.Background(), notifications.HTTPWrapperRequest{
|
||||
URL: server.URL,
|
||||
Body: []byte(`{"message":"hello"}`),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("expected retry success, got error: %v", err)
|
||||
}
|
||||
if result.Attempts != 2 {
|
||||
t.Fatalf("expected 2 attempts, got %d", result.Attempts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationHTTPWrapperIntegration_DoesNotRetryOn400(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var calls int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt32(&calls, 1)
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
wrapper := notifications.NewNotifyHTTPWrapper()
|
||||
_, err := wrapper.Send(context.Background(), notifications.HTTPWrapperRequest{
|
||||
URL: server.URL,
|
||||
Body: []byte(`{"message":"hello"}`),
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("expected non-retryable 400 error")
|
||||
}
|
||||
if atomic.LoadInt32(&calls) != 1 {
|
||||
t.Fatalf("expected one request attempt, got %d", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationHTTPWrapperIntegration_RejectsTokenizedQueryWithoutEcho(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
wrapper := notifications.NewNotifyHTTPWrapper()
|
||||
secret := "pr1-secret-token-value"
|
||||
_, err := wrapper.Send(context.Background(), notifications.HTTPWrapperRequest{
|
||||
URL: "http://example.com/hook?token=" + secret,
|
||||
Body: []byte(`{"message":"hello"}`),
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("expected tokenized query rejection")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "query authentication is not allowed") {
|
||||
t.Fatalf("expected sanitized query-auth rejection, got: %v", err)
|
||||
}
|
||||
if strings.Contains(err.Error(), secret) {
|
||||
t.Fatalf("error must not echo secret token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationHTTPWrapperIntegration_HeaderAllowlistSafety(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var seenAuthHeader string
|
||||
var seenCookieHeader string
|
||||
var seenGotifyKey string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
seenAuthHeader = r.Header.Get("Authorization")
|
||||
seenCookieHeader = r.Header.Get("Cookie")
|
||||
seenGotifyKey = r.Header.Get("X-Gotify-Key")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
wrapper := notifications.NewNotifyHTTPWrapper()
|
||||
_, err := wrapper.Send(context.Background(), notifications.HTTPWrapperRequest{
|
||||
URL: server.URL,
|
||||
Headers: map[string]string{
|
||||
"Authorization": "Bearer should-not-leak",
|
||||
"Cookie": "session=should-not-leak",
|
||||
"X-Gotify-Key": "allowed-token",
|
||||
},
|
||||
Body: []byte(`{"message":"hello"}`),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("expected success, got error: %v", err)
|
||||
}
|
||||
if seenAuthHeader != "" {
|
||||
t.Fatalf("authorization header must be stripped")
|
||||
}
|
||||
if seenCookieHeader != "" {
|
||||
t.Fatalf("cookie header must be stripped")
|
||||
}
|
||||
if seenGotifyKey != "allowed-token" {
|
||||
t.Fatalf("expected X-Gotify-Key to pass through")
|
||||
}
|
||||
}
|
||||
@@ -170,6 +170,7 @@ func TestSecurityHandler_UpdateConfig_ApplyCaddyError(t *testing.T) {
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
setAdminContext(c)
|
||||
c.Request = httptest.NewRequest("PUT", "/security/config", bytes.NewBuffer(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
@@ -190,6 +191,7 @@ func TestSecurityHandler_GenerateBreakGlass_Error(t *testing.T) {
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
setAdminContext(c)
|
||||
c.Request = httptest.NewRequest("POST", "/security/breakglass", http.NoBody)
|
||||
|
||||
h.GenerateBreakGlass(c)
|
||||
@@ -252,6 +254,7 @@ func TestSecurityHandler_UpsertRuleSet_Error(t *testing.T) {
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
setAdminContext(c)
|
||||
c.Request = httptest.NewRequest("POST", "/security/rulesets", bytes.NewBuffer(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
@@ -277,6 +280,7 @@ func TestSecurityHandler_CreateDecision_LogError(t *testing.T) {
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
setAdminContext(c)
|
||||
c.Request = httptest.NewRequest("POST", "/security/decisions", bytes.NewBuffer(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
@@ -297,6 +301,7 @@ func TestSecurityHandler_DeleteRuleSet_Error(t *testing.T) {
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
setAdminContext(c)
|
||||
c.Params = gin.Params{{Key: "id", Value: "999"}}
|
||||
|
||||
h.DeleteRuleSet(c)
|
||||
|
||||
@@ -127,18 +127,20 @@ func isLocalRequest(c *gin.Context) bool {
|
||||
|
||||
// setSecureCookie sets an auth cookie with security best practices
|
||||
// - HttpOnly: prevents JavaScript access (XSS protection)
|
||||
// - Secure: derived from request scheme to allow HTTP/IP logins when needed
|
||||
// - Secure: true for HTTPS; false only for local non-HTTPS loopback flows
|
||||
// - SameSite: Strict for HTTPS, Lax for HTTP/IP to allow forward-auth redirects
|
||||
func setSecureCookie(c *gin.Context, name, value string, maxAge int) {
|
||||
scheme := requestScheme(c)
|
||||
secure := scheme == "https"
|
||||
secure := true
|
||||
sameSite := http.SameSiteStrictMode
|
||||
if scheme != "https" {
|
||||
sameSite = http.SameSiteLaxMode
|
||||
if isLocalRequest(c) {
|
||||
secure = false
|
||||
}
|
||||
}
|
||||
|
||||
if isLocalRequest(c) {
|
||||
secure = false
|
||||
sameSite = http.SameSiteLaxMode
|
||||
}
|
||||
|
||||
@@ -152,7 +154,7 @@ func setSecureCookie(c *gin.Context, name, value string, maxAge int) {
|
||||
maxAge, // maxAge in seconds
|
||||
"/", // path
|
||||
domain, // domain (empty = current host)
|
||||
secure, // secure (HTTPS only in production)
|
||||
secure, // secure (always true)
|
||||
true, // httpOnly (no JS access)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -94,10 +94,28 @@ func TestSetSecureCookie_HTTP_Lax(t *testing.T) {
|
||||
cookies := recorder.Result().Cookies()
|
||||
require.Len(t, cookies, 1)
|
||||
c := cookies[0]
|
||||
assert.False(t, c.Secure)
|
||||
assert.True(t, c.Secure)
|
||||
assert.Equal(t, http.SameSiteLaxMode, c.SameSite)
|
||||
}
|
||||
|
||||
func TestSetSecureCookie_HTTP_Loopback_Insecure(t *testing.T) {
|
||||
t.Parallel()
|
||||
gin.SetMode(gin.TestMode)
|
||||
recorder := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(recorder)
|
||||
req := httptest.NewRequest("POST", "http://127.0.0.1:8080/login", http.NoBody)
|
||||
req.Host = "127.0.0.1:8080"
|
||||
req.Header.Set("X-Forwarded-Proto", "http")
|
||||
ctx.Request = req
|
||||
|
||||
setSecureCookie(ctx, "auth_token", "abc", 60)
|
||||
cookies := recorder.Result().Cookies()
|
||||
require.Len(t, cookies, 1)
|
||||
cookie := cookies[0]
|
||||
assert.False(t, cookie.Secure)
|
||||
assert.Equal(t, http.SameSiteLaxMode, cookie.SameSite)
|
||||
}
|
||||
|
||||
func TestSetSecureCookie_ForwardedHTTPS_LocalhostForcesInsecure(t *testing.T) {
|
||||
t.Parallel()
|
||||
gin.SetMode(gin.TestMode)
|
||||
@@ -115,7 +133,7 @@ func TestSetSecureCookie_ForwardedHTTPS_LocalhostForcesInsecure(t *testing.T) {
|
||||
cookies := recorder.Result().Cookies()
|
||||
require.Len(t, cookies, 1)
|
||||
cookie := cookies[0]
|
||||
assert.False(t, cookie.Secure)
|
||||
assert.True(t, cookie.Secure)
|
||||
assert.Equal(t, http.SameSiteLaxMode, cookie.SameSite)
|
||||
}
|
||||
|
||||
@@ -136,7 +154,7 @@ func TestSetSecureCookie_ForwardedHTTPS_LoopbackForcesInsecure(t *testing.T) {
|
||||
cookies := recorder.Result().Cookies()
|
||||
require.Len(t, cookies, 1)
|
||||
cookie := cookies[0]
|
||||
assert.False(t, cookie.Secure)
|
||||
assert.True(t, cookie.Secure)
|
||||
assert.Equal(t, http.SameSiteLaxMode, cookie.SameSite)
|
||||
}
|
||||
|
||||
@@ -158,7 +176,7 @@ func TestSetSecureCookie_ForwardedHostLocalhostForcesInsecure(t *testing.T) {
|
||||
cookies := recorder.Result().Cookies()
|
||||
require.Len(t, cookies, 1)
|
||||
cookie := cookies[0]
|
||||
assert.False(t, cookie.Secure)
|
||||
assert.True(t, cookie.Secure)
|
||||
assert.Equal(t, http.SameSiteLaxMode, cookie.SameSite)
|
||||
}
|
||||
|
||||
@@ -180,7 +198,7 @@ func TestSetSecureCookie_OriginLoopbackForcesInsecure(t *testing.T) {
|
||||
cookies := recorder.Result().Cookies()
|
||||
require.Len(t, cookies, 1)
|
||||
cookie := cookies[0]
|
||||
assert.False(t, cookie.Secure)
|
||||
assert.True(t, cookie.Secure)
|
||||
assert.Equal(t, http.SameSiteLaxMode, cookie.SameSite)
|
||||
}
|
||||
|
||||
|
||||
@@ -71,10 +71,14 @@ func (h *DockerHandler) ListContainers(c *gin.Context) {
|
||||
if err != nil {
|
||||
var unavailableErr *services.DockerUnavailableError
|
||||
if errors.As(err, &unavailableErr) {
|
||||
details := unavailableErr.Details()
|
||||
if details == "" {
|
||||
details = "Cannot connect to Docker. Please ensure Docker is running and the socket is accessible (e.g., /var/run/docker.sock is mounted)."
|
||||
}
|
||||
log.WithFields(map[string]any{"server_id": util.SanitizeForLog(serverID), "host": util.SanitizeForLog(host), "error": util.SanitizeForLog(err.Error())}).Warn("docker unavailable")
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{
|
||||
"error": "Docker daemon unavailable",
|
||||
"details": "Cannot connect to Docker. Please ensure Docker is running and the socket is accessible (e.g., /var/run/docker.sock is mounted).",
|
||||
"details": details,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ func TestDockerHandler_ListContainers_DockerUnavailableMappedTo503(t *testing.T)
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
|
||||
dockerSvc := &fakeDockerService{err: services.NewDockerUnavailableError(errors.New("no docker socket"))}
|
||||
dockerSvc := &fakeDockerService{err: services.NewDockerUnavailableError(errors.New("no docker socket"), "Local Docker socket is mounted but not accessible by current process")}
|
||||
remoteSvc := &fakeRemoteServerService{}
|
||||
h := NewDockerHandler(dockerSvc, remoteSvc)
|
||||
|
||||
@@ -78,7 +78,7 @@ func TestDockerHandler_ListContainers_DockerUnavailableMappedTo503(t *testing.T)
|
||||
assert.Contains(t, w.Body.String(), "Docker daemon unavailable")
|
||||
// Verify the new details field is included in the response
|
||||
assert.Contains(t, w.Body.String(), "details")
|
||||
assert.Contains(t, w.Body.String(), "Docker is running")
|
||||
assert.Contains(t, w.Body.String(), "not accessible by current process")
|
||||
}
|
||||
|
||||
func TestDockerHandler_ListContainers_ServerIDResolvesToTCPHost(t *testing.T) {
|
||||
@@ -360,3 +360,47 @@ func TestDockerHandler_ListContainers_GenericError(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDockerHandler_ListContainers_503FallbackDetailsWhenEmpty(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
|
||||
dockerSvc := &fakeDockerService{err: services.NewDockerUnavailableError(errors.New("socket error"))}
|
||||
remoteSvc := &fakeRemoteServerService{}
|
||||
h := NewDockerHandler(dockerSvc, remoteSvc)
|
||||
|
||||
api := router.Group("/api/v1")
|
||||
h.RegisterRoutes(api)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/docker/containers", http.NoBody)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusServiceUnavailable, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "Docker daemon unavailable")
|
||||
assert.Contains(t, w.Body.String(), "docker.sock is mounted")
|
||||
}
|
||||
|
||||
func TestDockerHandler_ListContainers_503DetailsWithGroupGuidance(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
|
||||
groupDetails := `Local Docker socket is mounted but not accessible by current process (uid=1000 gid=1000). Process groups (1000) do not include socket gid 988; run container with matching supplemental group (e.g., --group-add 988 or compose group_add: ["988"]).`
|
||||
dockerSvc := &fakeDockerService{
|
||||
err: services.NewDockerUnavailableError(errors.New("EACCES"), groupDetails),
|
||||
}
|
||||
remoteSvc := &fakeRemoteServerService{}
|
||||
h := NewDockerHandler(dockerSvc, remoteSvc)
|
||||
|
||||
api := router.Group("/api/v1")
|
||||
h.RegisterRoutes(api)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/docker/containers?host=local", http.NoBody)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusServiceUnavailable, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "Docker daemon unavailable")
|
||||
assert.Contains(t, w.Body.String(), "--group-add 988")
|
||||
assert.Contains(t, w.Body.String(), "group_add")
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ var defaultFlags = []string{
|
||||
"feature.notifications.engine.notify_v1.enabled",
|
||||
"feature.notifications.service.discord.enabled",
|
||||
"feature.notifications.service.gotify.enabled",
|
||||
"feature.notifications.service.webhook.enabled",
|
||||
"feature.notifications.legacy.fallback_enabled",
|
||||
"feature.notifications.security_provider_events.enabled", // Blocker 3: Add security_provider_events gate
|
||||
}
|
||||
@@ -42,6 +43,7 @@ var defaultFlagValues = map[string]bool{
|
||||
"feature.notifications.engine.notify_v1.enabled": false,
|
||||
"feature.notifications.service.discord.enabled": false,
|
||||
"feature.notifications.service.gotify.enabled": false,
|
||||
"feature.notifications.service.webhook.enabled": false,
|
||||
"feature.notifications.legacy.fallback_enabled": false,
|
||||
"feature.notifications.security_provider_events.enabled": false, // Blocker 3: Default disabled for this stage
|
||||
}
|
||||
|
||||
@@ -93,6 +93,10 @@ func (h *ImportHandler) RegisterRoutes(router *gin.RouterGroup) {
|
||||
|
||||
// GetStatus returns current import session status.
|
||||
func (h *ImportHandler) GetStatus(c *gin.Context) {
|
||||
if !requireAuthenticatedAdmin(c) {
|
||||
return
|
||||
}
|
||||
|
||||
var session models.ImportSession
|
||||
err := h.db.Where("status IN ?", []string{"pending", "reviewing"}).
|
||||
Order("created_at DESC").
|
||||
@@ -155,6 +159,10 @@ func (h *ImportHandler) GetStatus(c *gin.Context) {
|
||||
|
||||
// GetPreview returns parsed hosts and conflicts for review.
|
||||
func (h *ImportHandler) GetPreview(c *gin.Context) {
|
||||
if !requireAuthenticatedAdmin(c) {
|
||||
return
|
||||
}
|
||||
|
||||
var session models.ImportSession
|
||||
err := h.db.Where("status IN ?", []string{"pending", "reviewing"}).
|
||||
Order("created_at DESC").
|
||||
|
||||
@@ -3,6 +3,7 @@ package handlers
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
@@ -14,6 +15,7 @@ import (
|
||||
|
||||
"github.com/Wikid82/charon/backend/internal/models"
|
||||
"github.com/Wikid82/charon/backend/internal/services"
|
||||
"github.com/Wikid82/charon/backend/internal/trace"
|
||||
)
|
||||
|
||||
func setupNotificationCoverageDB(t *testing.T) *gorm.DB {
|
||||
@@ -319,6 +321,159 @@ func TestNotificationProviderHandler_Test_InvalidJSON(t *testing.T) {
|
||||
assert.Equal(t, 400, w.Code)
|
||||
}
|
||||
|
||||
func TestNotificationProviderHandler_Test_RejectsClientSuppliedGotifyToken(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupNotificationCoverageDB(t)
|
||||
svc := services.NewNotificationService(db)
|
||||
h := NewNotificationProviderHandler(svc)
|
||||
|
||||
payload := map[string]any{
|
||||
"type": "gotify",
|
||||
"url": "https://gotify.example/message",
|
||||
"token": "super-secret-client-token",
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
setAdminContext(c)
|
||||
c.Set(string(trace.RequestIDKey), "req-token-reject-1")
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/providers/test", bytes.NewBuffer(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
h.Test(c)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
var resp map[string]any
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
assert.Equal(t, "TOKEN_WRITE_ONLY", resp["code"])
|
||||
assert.Equal(t, "validation", resp["category"])
|
||||
assert.Equal(t, "Gotify token is accepted only on provider create/update", resp["error"])
|
||||
assert.Equal(t, "req-token-reject-1", resp["request_id"])
|
||||
assert.NotContains(t, w.Body.String(), "super-secret-client-token")
|
||||
}
|
||||
|
||||
func TestNotificationProviderHandler_Test_RejectsGotifyTokenWithWhitespace(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupNotificationCoverageDB(t)
|
||||
svc := services.NewNotificationService(db)
|
||||
h := NewNotificationProviderHandler(svc)
|
||||
|
||||
payload := map[string]any{
|
||||
"type": "gotify",
|
||||
"token": " secret-with-space ",
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
setAdminContext(c)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/providers/test", bytes.NewBuffer(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
h.Test(c)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "TOKEN_WRITE_ONLY")
|
||||
assert.NotContains(t, w.Body.String(), "secret-with-space")
|
||||
}
|
||||
|
||||
func TestClassifyProviderTestFailure_NilError(t *testing.T) {
|
||||
code, category, message := classifyProviderTestFailure(nil)
|
||||
|
||||
assert.Equal(t, "PROVIDER_TEST_FAILED", code)
|
||||
assert.Equal(t, "dispatch", category)
|
||||
assert.Equal(t, "Provider test failed", message)
|
||||
}
|
||||
|
||||
func TestClassifyProviderTestFailure_DefaultStatusCode(t *testing.T) {
|
||||
code, category, message := classifyProviderTestFailure(errors.New("provider returned status 500"))
|
||||
|
||||
assert.Equal(t, "PROVIDER_TEST_REMOTE_REJECTED", code)
|
||||
assert.Equal(t, "dispatch", category)
|
||||
assert.Contains(t, message, "HTTP 500")
|
||||
}
|
||||
|
||||
func TestClassifyProviderTestFailure_GenericError(t *testing.T) {
|
||||
code, category, message := classifyProviderTestFailure(errors.New("something completely unexpected"))
|
||||
|
||||
assert.Equal(t, "PROVIDER_TEST_FAILED", code)
|
||||
assert.Equal(t, "dispatch", category)
|
||||
assert.Equal(t, "Provider test failed", message)
|
||||
}
|
||||
|
||||
func TestClassifyProviderTestFailure_InvalidDiscordWebhookURL(t *testing.T) {
|
||||
code, category, message := classifyProviderTestFailure(errors.New("invalid discord webhook url"))
|
||||
|
||||
assert.Equal(t, "PROVIDER_TEST_URL_INVALID", code)
|
||||
assert.Equal(t, "validation", category)
|
||||
assert.Contains(t, message, "Provider URL")
|
||||
}
|
||||
|
||||
func TestClassifyProviderTestFailure_URLValidation(t *testing.T) {
|
||||
code, category, message := classifyProviderTestFailure(errors.New("destination URL validation failed"))
|
||||
|
||||
assert.Equal(t, "PROVIDER_TEST_URL_INVALID", code)
|
||||
assert.Equal(t, "validation", category)
|
||||
assert.Contains(t, message, "Provider URL")
|
||||
}
|
||||
|
||||
func TestClassifyProviderTestFailure_AuthRejected(t *testing.T) {
|
||||
code, category, message := classifyProviderTestFailure(errors.New("failed to send webhook: provider returned status 401"))
|
||||
|
||||
assert.Equal(t, "PROVIDER_TEST_AUTH_REJECTED", code)
|
||||
assert.Equal(t, "dispatch", category)
|
||||
assert.Contains(t, message, "rejected authentication")
|
||||
}
|
||||
|
||||
func TestClassifyProviderTestFailure_EndpointNotFound(t *testing.T) {
|
||||
code, category, message := classifyProviderTestFailure(errors.New("failed to send webhook: provider returned status 404"))
|
||||
|
||||
assert.Equal(t, "PROVIDER_TEST_ENDPOINT_NOT_FOUND", code)
|
||||
assert.Equal(t, "dispatch", category)
|
||||
assert.Contains(t, message, "endpoint was not found")
|
||||
}
|
||||
|
||||
func TestClassifyProviderTestFailure_UnreachableEndpoint(t *testing.T) {
|
||||
code, category, message := classifyProviderTestFailure(errors.New("failed to send webhook: outbound request failed"))
|
||||
|
||||
assert.Equal(t, "PROVIDER_TEST_UNREACHABLE", code)
|
||||
assert.Equal(t, "dispatch", category)
|
||||
assert.Contains(t, message, "Could not reach provider endpoint")
|
||||
}
|
||||
|
||||
func TestClassifyProviderTestFailure_DNSLookupFailed(t *testing.T) {
|
||||
code, category, message := classifyProviderTestFailure(errors.New("failed to send webhook: outbound request failed: dns lookup failed"))
|
||||
|
||||
assert.Equal(t, "PROVIDER_TEST_DNS_FAILED", code)
|
||||
assert.Equal(t, "dispatch", category)
|
||||
assert.Contains(t, message, "DNS lookup failed")
|
||||
}
|
||||
|
||||
func TestClassifyProviderTestFailure_ConnectionRefused(t *testing.T) {
|
||||
code, category, message := classifyProviderTestFailure(errors.New("failed to send webhook: outbound request failed: connection refused"))
|
||||
|
||||
assert.Equal(t, "PROVIDER_TEST_CONNECTION_REFUSED", code)
|
||||
assert.Equal(t, "dispatch", category)
|
||||
assert.Contains(t, message, "refused the connection")
|
||||
}
|
||||
|
||||
func TestClassifyProviderTestFailure_Timeout(t *testing.T) {
|
||||
code, category, message := classifyProviderTestFailure(errors.New("failed to send webhook: outbound request failed: request timed out"))
|
||||
|
||||
assert.Equal(t, "PROVIDER_TEST_TIMEOUT", code)
|
||||
assert.Equal(t, "dispatch", category)
|
||||
assert.Contains(t, message, "timed out")
|
||||
}
|
||||
|
||||
func TestClassifyProviderTestFailure_TLSHandshakeFailed(t *testing.T) {
|
||||
code, category, message := classifyProviderTestFailure(errors.New("failed to send webhook: outbound request failed: tls handshake failed"))
|
||||
|
||||
assert.Equal(t, "PROVIDER_TEST_TLS_FAILED", code)
|
||||
assert.Equal(t, "dispatch", category)
|
||||
assert.Contains(t, message, "TLS handshake failed")
|
||||
}
|
||||
|
||||
func TestNotificationProviderHandler_Templates(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupNotificationCoverageDB(t)
|
||||
@@ -625,3 +780,258 @@ func TestNotificationTemplateHandler_Preview_InvalidTemplate(t *testing.T) {
|
||||
|
||||
assert.Equal(t, 400, w.Code)
|
||||
}
|
||||
|
||||
func TestNotificationProviderHandler_Preview_TokenWriteOnly(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupNotificationCoverageDB(t)
|
||||
svc := services.NewNotificationService(db)
|
||||
h := NewNotificationProviderHandler(svc)
|
||||
|
||||
payload := map[string]any{
|
||||
"template": "minimal",
|
||||
"token": "secret-token-value",
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
setAdminContext(c)
|
||||
c.Request = httptest.NewRequest("POST", "/providers/preview", bytes.NewBuffer(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
h.Preview(c)
|
||||
|
||||
assert.Equal(t, 400, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "TOKEN_WRITE_ONLY")
|
||||
}
|
||||
|
||||
func TestNotificationProviderHandler_Update_TypeChangeRejected(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupNotificationCoverageDB(t)
|
||||
svc := services.NewNotificationService(db)
|
||||
h := NewNotificationProviderHandler(svc)
|
||||
|
||||
existing := models.NotificationProvider{
|
||||
ID: "update-type-test",
|
||||
Name: "Discord Provider",
|
||||
Type: "discord",
|
||||
URL: "https://discord.com/api/webhooks/123/abc",
|
||||
}
|
||||
require.NoError(t, db.Create(&existing).Error)
|
||||
|
||||
payload := map[string]any{
|
||||
"name": "Changed Type Provider",
|
||||
"type": "gotify",
|
||||
"url": "https://gotify.example.com",
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
setAdminContext(c)
|
||||
c.Params = gin.Params{{Key: "id", Value: "update-type-test"}}
|
||||
c.Request = httptest.NewRequest("PUT", "/providers/update-type-test", bytes.NewBuffer(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
h.Update(c)
|
||||
|
||||
assert.Equal(t, 400, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "PROVIDER_TYPE_IMMUTABLE")
|
||||
}
|
||||
|
||||
func TestNotificationProviderHandler_Test_MissingProviderID(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupNotificationCoverageDB(t)
|
||||
svc := services.NewNotificationService(db)
|
||||
h := NewNotificationProviderHandler(svc)
|
||||
|
||||
payload := map[string]any{
|
||||
"type": "discord",
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
setAdminContext(c)
|
||||
c.Request = httptest.NewRequest("POST", "/providers/test", bytes.NewBuffer(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
h.Test(c)
|
||||
|
||||
assert.Equal(t, 400, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "MISSING_PROVIDER_ID")
|
||||
}
|
||||
|
||||
func TestNotificationProviderHandler_Test_ProviderNotFound(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupNotificationCoverageDB(t)
|
||||
svc := services.NewNotificationService(db)
|
||||
h := NewNotificationProviderHandler(svc)
|
||||
|
||||
payload := map[string]any{
|
||||
"type": "discord",
|
||||
"id": "nonexistent-provider",
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
setAdminContext(c)
|
||||
c.Request = httptest.NewRequest("POST", "/providers/test", bytes.NewBuffer(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
h.Test(c)
|
||||
|
||||
assert.Equal(t, 404, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "PROVIDER_NOT_FOUND")
|
||||
}
|
||||
|
||||
func TestNotificationProviderHandler_Test_EmptyProviderURL(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupNotificationCoverageDB(t)
|
||||
svc := services.NewNotificationService(db)
|
||||
h := NewNotificationProviderHandler(svc)
|
||||
|
||||
existing := models.NotificationProvider{
|
||||
ID: "empty-url-test",
|
||||
Name: "Empty URL Provider",
|
||||
Type: "discord",
|
||||
URL: "",
|
||||
}
|
||||
require.NoError(t, db.Create(&existing).Error)
|
||||
|
||||
payload := map[string]any{
|
||||
"type": "discord",
|
||||
"id": "empty-url-test",
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
setAdminContext(c)
|
||||
c.Request = httptest.NewRequest("POST", "/providers/test", bytes.NewBuffer(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
h.Test(c)
|
||||
|
||||
assert.Equal(t, 400, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "PROVIDER_CONFIG_MISSING")
|
||||
}
|
||||
|
||||
func TestIsProviderValidationError_Comprehensive(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
err error
|
||||
expect bool
|
||||
}{
|
||||
{"nil", nil, false},
|
||||
{"invalid_custom_template", errors.New("invalid custom template: missing field"), true},
|
||||
{"rendered_template", errors.New("rendered template exceeds maximum"), true},
|
||||
{"failed_to_parse", errors.New("failed to parse template: unexpected end"), true},
|
||||
{"failed_to_render", errors.New("failed to render template: missing key"), true},
|
||||
{"invalid_discord_webhook", errors.New("invalid Discord webhook URL"), true},
|
||||
{"unrelated_error", errors.New("database connection failed"), false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assert.Equal(t, tc.expect, isProviderValidationError(tc.err))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationProviderHandler_Update_UnsupportedType(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupNotificationCoverageDB(t)
|
||||
svc := services.NewNotificationService(db)
|
||||
h := NewNotificationProviderHandler(svc)
|
||||
|
||||
existing := models.NotificationProvider{
|
||||
ID: "unsupported-type",
|
||||
Name: "Custom Provider",
|
||||
Type: "slack",
|
||||
URL: "https://hooks.slack.com/test",
|
||||
}
|
||||
require.NoError(t, db.Create(&existing).Error)
|
||||
|
||||
payload := map[string]any{
|
||||
"name": "Updated Slack Provider",
|
||||
"url": "https://hooks.slack.com/updated",
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
setAdminContext(c)
|
||||
c.Params = gin.Params{{Key: "id", Value: "unsupported-type"}}
|
||||
c.Request = httptest.NewRequest("PUT", "/providers/unsupported-type", bytes.NewBuffer(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
h.Update(c)
|
||||
|
||||
assert.Equal(t, 400, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "UNSUPPORTED_PROVIDER_TYPE")
|
||||
}
|
||||
|
||||
func TestNotificationProviderHandler_Update_GotifyKeepsExistingToken(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupNotificationCoverageDB(t)
|
||||
svc := services.NewNotificationService(db)
|
||||
h := NewNotificationProviderHandler(svc)
|
||||
|
||||
existing := models.NotificationProvider{
|
||||
ID: "gotify-keep-token",
|
||||
Name: "Gotify Provider",
|
||||
Type: "gotify",
|
||||
URL: "https://gotify.example.com",
|
||||
Token: "existing-secret-token",
|
||||
}
|
||||
require.NoError(t, db.Create(&existing).Error)
|
||||
|
||||
payload := map[string]any{
|
||||
"name": "Updated Gotify",
|
||||
"url": "https://gotify.example.com/new",
|
||||
"template": "minimal",
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
setAdminContext(c)
|
||||
c.Params = gin.Params{{Key: "id", Value: "gotify-keep-token"}}
|
||||
c.Request = httptest.NewRequest("PUT", "/providers/gotify-keep-token", bytes.NewBuffer(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
h.Update(c)
|
||||
|
||||
assert.Equal(t, 200, w.Code)
|
||||
|
||||
var updated models.NotificationProvider
|
||||
require.NoError(t, db.Where("id = ?", "gotify-keep-token").First(&updated).Error)
|
||||
assert.Equal(t, "existing-secret-token", updated.Token)
|
||||
}
|
||||
|
||||
func TestNotificationProviderHandler_Test_ReadDBError(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupNotificationCoverageDB(t)
|
||||
svc := services.NewNotificationService(db)
|
||||
h := NewNotificationProviderHandler(svc)
|
||||
|
||||
_ = db.Migrator().DropTable(&models.NotificationProvider{})
|
||||
|
||||
payload := map[string]any{
|
||||
"type": "discord",
|
||||
"id": "some-provider",
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
setAdminContext(c)
|
||||
c.Request = httptest.NewRequest("POST", "/providers/test", bytes.NewBuffer(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
h.Test(c)
|
||||
|
||||
assert.Equal(t, 500, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "PROVIDER_READ_FAILED")
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// TestBlocker3_CreateProviderRejectsNonDiscordWithSecurityEvents tests that create rejects non-Discord providers with security events.
|
||||
// TestBlocker3_CreateProviderValidationWithSecurityEvents verifies supported/unsupported provider handling with security events enabled.
|
||||
func TestBlocker3_CreateProviderRejectsNonDiscordWithSecurityEvents(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
@@ -31,15 +31,16 @@ func TestBlocker3_CreateProviderRejectsNonDiscordWithSecurityEvents(t *testing.T
|
||||
service := services.NewNotificationService(db)
|
||||
handler := NewNotificationProviderHandler(service)
|
||||
|
||||
// Test cases: non-Discord provider types with security events enabled
|
||||
// Test cases: provider types with security events enabled
|
||||
testCases := []struct {
|
||||
name string
|
||||
providerType string
|
||||
wantStatus int
|
||||
}{
|
||||
{"webhook", "webhook"},
|
||||
{"slack", "slack"},
|
||||
{"gotify", "gotify"},
|
||||
{"email", "email"},
|
||||
{"webhook", "webhook", http.StatusCreated},
|
||||
{"gotify", "gotify", http.StatusCreated},
|
||||
{"slack", "slack", http.StatusBadRequest},
|
||||
{"email", "email", http.StatusBadRequest},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
@@ -69,14 +70,15 @@ func TestBlocker3_CreateProviderRejectsNonDiscordWithSecurityEvents(t *testing.T
|
||||
// Call Create
|
||||
handler.Create(c)
|
||||
|
||||
// Blocker 3: Should reject with 400
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code, "Should reject non-Discord provider with security events")
|
||||
assert.Equal(t, tc.wantStatus, w.Code)
|
||||
|
||||
// Verify error message
|
||||
var response map[string]interface{}
|
||||
err = json.Unmarshal(w.Body.Bytes(), &response)
|
||||
assert.NoError(t, err)
|
||||
assert.Contains(t, response["error"], "discord", "Error should mention Discord")
|
||||
if tc.wantStatus == http.StatusBadRequest {
|
||||
assert.Contains(t, response["code"], "UNSUPPORTED_PROVIDER_TYPE")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -129,8 +131,7 @@ func TestBlocker3_CreateProviderAcceptsDiscordWithSecurityEvents(t *testing.T) {
|
||||
assert.Equal(t, http.StatusCreated, w.Code, "Should accept Discord provider with security events")
|
||||
}
|
||||
|
||||
// TestBlocker3_CreateProviderAcceptsNonDiscordWithoutSecurityEvents tests that create NOW REJECTS non-Discord providers even without security events.
|
||||
// NOTE: This test was updated for Discord-only rollout (current_spec.md) - now globally rejects all non-Discord.
|
||||
// TestBlocker3_CreateProviderAcceptsNonDiscordWithoutSecurityEvents verifies webhook create without security events remains accepted.
|
||||
func TestBlocker3_CreateProviderAcceptsNonDiscordWithoutSecurityEvents(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
@@ -172,17 +173,10 @@ func TestBlocker3_CreateProviderAcceptsNonDiscordWithoutSecurityEvents(t *testin
|
||||
// Call Create
|
||||
handler.Create(c)
|
||||
|
||||
// Discord-only rollout: Now REJECTS with 400
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code, "Should reject non-Discord provider (Discord-only rollout)")
|
||||
|
||||
// Verify error message
|
||||
var response map[string]interface{}
|
||||
err = json.Unmarshal(w.Body.Bytes(), &response)
|
||||
assert.NoError(t, err)
|
||||
assert.Contains(t, response["error"], "discord", "Error should mention Discord")
|
||||
assert.Equal(t, http.StatusCreated, w.Code)
|
||||
}
|
||||
|
||||
// TestBlocker3_UpdateProviderRejectsNonDiscordWithSecurityEvents tests that update rejects non-Discord providers with security events.
|
||||
// TestBlocker3_UpdateProviderRejectsNonDiscordWithSecurityEvents verifies webhook update with security events is allowed in PR-1 scope.
|
||||
func TestBlocker3_UpdateProviderRejectsNonDiscordWithSecurityEvents(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
@@ -235,14 +229,7 @@ func TestBlocker3_UpdateProviderRejectsNonDiscordWithSecurityEvents(t *testing.T
|
||||
// Call Update
|
||||
handler.Update(c)
|
||||
|
||||
// Blocker 3: Should reject with 400
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code, "Should reject non-Discord provider update with security events")
|
||||
|
||||
// Verify error message
|
||||
var response map[string]interface{}
|
||||
err = json.Unmarshal(w.Body.Bytes(), &response)
|
||||
assert.NoError(t, err)
|
||||
assert.Contains(t, response["error"], "discord", "Error should mention Discord")
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
}
|
||||
|
||||
// TestBlocker3_UpdateProviderAcceptsDiscordWithSecurityEvents tests that update accepts Discord providers with security events.
|
||||
@@ -302,7 +289,7 @@ func TestBlocker3_UpdateProviderAcceptsDiscordWithSecurityEvents(t *testing.T) {
|
||||
assert.Equal(t, http.StatusOK, w.Code, "Should accept Discord provider update with security events")
|
||||
}
|
||||
|
||||
// TestBlocker3_MultipleSecurityEventsEnforcesDiscordOnly tests that having any security event enabled enforces Discord-only.
|
||||
// TestBlocker3_MultipleSecurityEventsEnforcesDiscordOnly tests webhook remains accepted with security flags in PR-1 scope.
|
||||
func TestBlocker3_MultipleSecurityEventsEnforcesDiscordOnly(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
@@ -353,9 +340,8 @@ func TestBlocker3_MultipleSecurityEventsEnforcesDiscordOnly(t *testing.T) {
|
||||
// Call Create
|
||||
handler.Create(c)
|
||||
|
||||
// Blocker 3: Should reject with 400
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code,
|
||||
"Should reject webhook provider with %s enabled", field)
|
||||
assert.Equal(t, http.StatusCreated, w.Code,
|
||||
"Should accept webhook provider with %s enabled", field)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -407,5 +393,5 @@ func TestBlocker3_UpdateProvider_DatabaseError(t *testing.T) {
|
||||
var response map[string]interface{}
|
||||
err = json.Unmarshal(w.Body.Bytes(), &response)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "provider not found", response["error"])
|
||||
assert.Equal(t, "Provider not found", response["error"])
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// TestDiscordOnly_CreateRejectsNonDiscord tests that create globally rejects non-Discord providers.
|
||||
// TestDiscordOnly_CreateRejectsNonDiscord verifies unsupported provider types are rejected while supported types are accepted.
|
||||
func TestDiscordOnly_CreateRejectsNonDiscord(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
@@ -30,13 +30,15 @@ func TestDiscordOnly_CreateRejectsNonDiscord(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
providerType string
|
||||
wantStatus int
|
||||
wantCode string
|
||||
}{
|
||||
{"webhook", "webhook"},
|
||||
{"slack", "slack"},
|
||||
{"gotify", "gotify"},
|
||||
{"telegram", "telegram"},
|
||||
{"generic", "generic"},
|
||||
{"email", "email"},
|
||||
{"webhook", "webhook", http.StatusCreated, ""},
|
||||
{"gotify", "gotify", http.StatusCreated, ""},
|
||||
{"slack", "slack", http.StatusBadRequest, "UNSUPPORTED_PROVIDER_TYPE"},
|
||||
{"telegram", "telegram", http.StatusBadRequest, "UNSUPPORTED_PROVIDER_TYPE"},
|
||||
{"generic", "generic", http.StatusBadRequest, "UNSUPPORTED_PROVIDER_TYPE"},
|
||||
{"email", "email", http.StatusBadRequest, "UNSUPPORTED_PROVIDER_TYPE"},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
@@ -61,13 +63,14 @@ func TestDiscordOnly_CreateRejectsNonDiscord(t *testing.T) {
|
||||
|
||||
handler.Create(c)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code, "Should reject non-Discord provider")
|
||||
assert.Equal(t, tc.wantStatus, w.Code)
|
||||
|
||||
var response map[string]interface{}
|
||||
err = json.Unmarshal(w.Body.Bytes(), &response)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "PROVIDER_TYPE_DISCORD_ONLY", response["code"])
|
||||
assert.Contains(t, response["error"], "discord")
|
||||
if tc.wantCode != "" {
|
||||
assert.Equal(t, tc.wantCode, response["code"])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -156,8 +159,8 @@ func TestDiscordOnly_UpdateRejectsTypeMutation(t *testing.T) {
|
||||
var response map[string]interface{}
|
||||
err = json.Unmarshal(w.Body.Bytes(), &response)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "DEPRECATED_PROVIDER_TYPE_IMMUTABLE", response["code"])
|
||||
assert.Contains(t, response["error"], "cannot change provider type")
|
||||
assert.Equal(t, "PROVIDER_TYPE_IMMUTABLE", response["code"])
|
||||
assert.Contains(t, response["error"], "cannot be changed")
|
||||
}
|
||||
|
||||
// TestDiscordOnly_UpdateRejectsEnable tests that update blocks enabling deprecated providers.
|
||||
@@ -205,13 +208,7 @@ func TestDiscordOnly_UpdateRejectsEnable(t *testing.T) {
|
||||
|
||||
handler.Update(c)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code, "Should reject enabling deprecated provider")
|
||||
|
||||
var response map[string]interface{}
|
||||
err = json.Unmarshal(w.Body.Bytes(), &response)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "DEPRECATED_PROVIDER_CANNOT_ENABLE", response["code"])
|
||||
assert.Contains(t, response["error"], "cannot enable deprecated")
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
}
|
||||
|
||||
// TestDiscordOnly_UpdateAllowsDisabledDeprecated tests that update allows updating disabled deprecated providers (except type/enable).
|
||||
@@ -259,8 +256,7 @@ func TestDiscordOnly_UpdateAllowsDisabledDeprecated(t *testing.T) {
|
||||
|
||||
handler.Update(c)
|
||||
|
||||
// Should still reject because type must be discord
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code, "Should reject non-Discord type even for read-only fields")
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
}
|
||||
|
||||
// TestDiscordOnly_UpdateAcceptsDiscord tests that update accepts Discord provider updates.
|
||||
@@ -360,21 +356,21 @@ func TestDiscordOnly_ErrorCodes(t *testing.T) {
|
||||
expectedCode string
|
||||
}{
|
||||
{
|
||||
name: "create_non_discord",
|
||||
name: "create_unsupported",
|
||||
setupFunc: func(db *gorm.DB) string {
|
||||
return ""
|
||||
},
|
||||
requestFunc: func(id string) (*http.Request, gin.Params) {
|
||||
payload := map[string]interface{}{
|
||||
"name": "Test",
|
||||
"type": "webhook",
|
||||
"type": "slack",
|
||||
"url": "https://example.com",
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
req, _ := http.NewRequest("POST", "/api/v1/notifications/providers", bytes.NewBuffer(body))
|
||||
return req, nil
|
||||
},
|
||||
expectedCode: "PROVIDER_TYPE_DISCORD_ONLY",
|
||||
expectedCode: "UNSUPPORTED_PROVIDER_TYPE",
|
||||
},
|
||||
{
|
||||
name: "update_type_mutation",
|
||||
@@ -399,34 +395,7 @@ func TestDiscordOnly_ErrorCodes(t *testing.T) {
|
||||
req, _ := http.NewRequest("PUT", "/api/v1/notifications/providers/"+id, bytes.NewBuffer(body))
|
||||
return req, []gin.Param{{Key: "id", Value: id}}
|
||||
},
|
||||
expectedCode: "DEPRECATED_PROVIDER_TYPE_IMMUTABLE",
|
||||
},
|
||||
{
|
||||
name: "update_enable_deprecated",
|
||||
setupFunc: func(db *gorm.DB) string {
|
||||
provider := models.NotificationProvider{
|
||||
ID: "test-id",
|
||||
Name: "Test",
|
||||
Type: "webhook",
|
||||
URL: "https://example.com",
|
||||
Enabled: false,
|
||||
MigrationState: "deprecated",
|
||||
}
|
||||
db.Create(&provider)
|
||||
return "test-id"
|
||||
},
|
||||
requestFunc: func(id string) (*http.Request, gin.Params) {
|
||||
payload := map[string]interface{}{
|
||||
"name": "Test",
|
||||
"type": "webhook",
|
||||
"url": "https://example.com",
|
||||
"enabled": true,
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
req, _ := http.NewRequest("PUT", "/api/v1/notifications/providers/"+id, bytes.NewBuffer(body))
|
||||
return req, []gin.Param{{Key: "id", Value: id}}
|
||||
},
|
||||
expectedCode: "DEPRECATED_PROVIDER_CANNOT_ENABLE",
|
||||
expectedCode: "PROVIDER_TYPE_IMMUTABLE",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -4,11 +4,13 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Wikid82/charon/backend/internal/models"
|
||||
"github.com/Wikid82/charon/backend/internal/services"
|
||||
"github.com/Wikid82/charon/backend/internal/trace"
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -25,6 +27,7 @@ type notificationProviderUpsertRequest struct {
|
||||
URL string `json:"url"`
|
||||
Config string `json:"config"`
|
||||
Template string `json:"template"`
|
||||
Token string `json:"token,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
NotifyProxyHosts bool `json:"notify_proxy_hosts"`
|
||||
NotifyRemoteServers bool `json:"notify_remote_servers"`
|
||||
@@ -37,6 +40,16 @@ type notificationProviderUpsertRequest struct {
|
||||
NotifySecurityCrowdSecDecisions bool `json:"notify_security_crowdsec_decisions"`
|
||||
}
|
||||
|
||||
type notificationProviderTestRequest struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
URL string `json:"url"`
|
||||
Config string `json:"config"`
|
||||
Template string `json:"template"`
|
||||
Token string `json:"token,omitempty"`
|
||||
}
|
||||
|
||||
func (r notificationProviderUpsertRequest) toModel() models.NotificationProvider {
|
||||
return models.NotificationProvider{
|
||||
Name: r.Name,
|
||||
@@ -44,6 +57,7 @@ func (r notificationProviderUpsertRequest) toModel() models.NotificationProvider
|
||||
URL: r.URL,
|
||||
Config: r.Config,
|
||||
Template: r.Template,
|
||||
Token: strings.TrimSpace(r.Token),
|
||||
Enabled: r.Enabled,
|
||||
NotifyProxyHosts: r.NotifyProxyHosts,
|
||||
NotifyRemoteServers: r.NotifyRemoteServers,
|
||||
@@ -57,6 +71,70 @@ func (r notificationProviderUpsertRequest) toModel() models.NotificationProvider
|
||||
}
|
||||
}
|
||||
|
||||
func providerRequestID(c *gin.Context) string {
|
||||
if value, ok := c.Get(string(trace.RequestIDKey)); ok {
|
||||
if requestID, ok := value.(string); ok {
|
||||
return requestID
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func respondSanitizedProviderError(c *gin.Context, status int, code, category, message string) {
|
||||
response := gin.H{
|
||||
"error": message,
|
||||
"code": code,
|
||||
"category": category,
|
||||
}
|
||||
if requestID := providerRequestID(c); requestID != "" {
|
||||
response["request_id"] = requestID
|
||||
}
|
||||
c.JSON(status, response)
|
||||
}
|
||||
|
||||
var providerStatusCodePattern = regexp.MustCompile(`provider returned status\s+(\d{3})`)
|
||||
|
||||
func classifyProviderTestFailure(err error) (code string, category string, message string) {
|
||||
if err == nil {
|
||||
return "PROVIDER_TEST_FAILED", "dispatch", "Provider test failed"
|
||||
}
|
||||
|
||||
errText := strings.ToLower(strings.TrimSpace(err.Error()))
|
||||
|
||||
if strings.Contains(errText, "destination url validation failed") ||
|
||||
strings.Contains(errText, "invalid webhook url") ||
|
||||
strings.Contains(errText, "invalid discord webhook url") {
|
||||
return "PROVIDER_TEST_URL_INVALID", "validation", "Provider URL is invalid or blocked. Verify the URL and try again"
|
||||
}
|
||||
|
||||
if statusMatch := providerStatusCodePattern.FindStringSubmatch(errText); len(statusMatch) == 2 {
|
||||
switch statusMatch[1] {
|
||||
case "401", "403":
|
||||
return "PROVIDER_TEST_AUTH_REJECTED", "dispatch", "Provider rejected authentication. Verify your Gotify token"
|
||||
case "404":
|
||||
return "PROVIDER_TEST_ENDPOINT_NOT_FOUND", "dispatch", "Provider endpoint was not found. Verify the provider URL path"
|
||||
default:
|
||||
return "PROVIDER_TEST_REMOTE_REJECTED", "dispatch", fmt.Sprintf("Provider rejected the test request (HTTP %s)", statusMatch[1])
|
||||
}
|
||||
}
|
||||
|
||||
if strings.Contains(errText, "outbound request failed") || strings.Contains(errText, "failed to send webhook") {
|
||||
switch {
|
||||
case strings.Contains(errText, "dns lookup failed"):
|
||||
return "PROVIDER_TEST_DNS_FAILED", "dispatch", "DNS lookup failed for provider host. Verify the hostname in the provider URL"
|
||||
case strings.Contains(errText, "connection refused"):
|
||||
return "PROVIDER_TEST_CONNECTION_REFUSED", "dispatch", "Provider host refused the connection. Verify port and service availability"
|
||||
case strings.Contains(errText, "request timed out"):
|
||||
return "PROVIDER_TEST_TIMEOUT", "dispatch", "Provider request timed out. Verify network route and provider responsiveness"
|
||||
case strings.Contains(errText, "tls handshake failed"):
|
||||
return "PROVIDER_TEST_TLS_FAILED", "dispatch", "TLS handshake failed. Verify HTTPS certificate and URL scheme"
|
||||
}
|
||||
return "PROVIDER_TEST_UNREACHABLE", "dispatch", "Could not reach provider endpoint. Verify URL, DNS, and network connectivity"
|
||||
}
|
||||
|
||||
return "PROVIDER_TEST_FAILED", "dispatch", "Provider test failed"
|
||||
}
|
||||
|
||||
func NewNotificationProviderHandler(service *services.NotificationService) *NotificationProviderHandler {
|
||||
return NewNotificationProviderHandlerWithDeps(service, nil, "")
|
||||
}
|
||||
@@ -71,6 +149,10 @@ func (h *NotificationProviderHandler) List(c *gin.Context) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list providers"})
|
||||
return
|
||||
}
|
||||
for i := range providers {
|
||||
providers[i].HasToken = providers[i].Token != ""
|
||||
providers[i].Token = ""
|
||||
}
|
||||
c.JSON(http.StatusOK, providers)
|
||||
}
|
||||
|
||||
@@ -81,16 +163,13 @@ func (h *NotificationProviderHandler) Create(c *gin.Context) {
|
||||
|
||||
var req notificationProviderUpsertRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
respondSanitizedProviderError(c, http.StatusBadRequest, "INVALID_REQUEST", "validation", "Invalid notification provider payload")
|
||||
return
|
||||
}
|
||||
|
||||
// Discord-only enforcement for this rollout
|
||||
if req.Type != "discord" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "only discord provider type is supported in this release; additional providers will be enabled in future releases after validation",
|
||||
"code": "PROVIDER_TYPE_DISCORD_ONLY",
|
||||
})
|
||||
providerType := strings.ToLower(strings.TrimSpace(req.Type))
|
||||
if providerType != "discord" && providerType != "gotify" && providerType != "webhook" {
|
||||
respondSanitizedProviderError(c, http.StatusBadRequest, "UNSUPPORTED_PROVIDER_TYPE", "validation", "Unsupported notification provider type")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -106,15 +185,17 @@ func (h *NotificationProviderHandler) Create(c *gin.Context) {
|
||||
if err := h.service.CreateProvider(&provider); err != nil {
|
||||
// If it's a validation error from template parsing, return 400
|
||||
if isProviderValidationError(err) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
respondSanitizedProviderError(c, http.StatusBadRequest, "PROVIDER_VALIDATION_FAILED", "validation", "Notification provider validation failed")
|
||||
return
|
||||
}
|
||||
if respondPermissionError(c, h.securityService, "notification_provider_save_failed", err, h.dataRoot) {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create provider"})
|
||||
respondSanitizedProviderError(c, http.StatusInternalServerError, "PROVIDER_CREATE_FAILED", "internal", "Failed to create provider")
|
||||
return
|
||||
}
|
||||
provider.HasToken = provider.Token != ""
|
||||
provider.Token = ""
|
||||
c.JSON(http.StatusCreated, provider)
|
||||
}
|
||||
|
||||
@@ -126,7 +207,7 @@ func (h *NotificationProviderHandler) Update(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var req notificationProviderUpsertRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
respondSanitizedProviderError(c, http.StatusBadRequest, "INVALID_REQUEST", "validation", "Invalid notification provider payload")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -134,39 +215,29 @@ func (h *NotificationProviderHandler) Update(c *gin.Context) {
|
||||
var existing models.NotificationProvider
|
||||
if err := h.service.DB.Where("id = ?", id).First(&existing).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "provider not found"})
|
||||
respondSanitizedProviderError(c, http.StatusNotFound, "PROVIDER_NOT_FOUND", "validation", "Provider not found")
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch provider"})
|
||||
respondSanitizedProviderError(c, http.StatusInternalServerError, "PROVIDER_READ_FAILED", "internal", "Failed to read provider")
|
||||
return
|
||||
}
|
||||
|
||||
// Block type mutation for existing non-Discord providers
|
||||
if existing.Type != "discord" && req.Type != existing.Type {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "cannot change provider type for deprecated non-discord providers; delete and recreate as discord provider instead",
|
||||
"code": "DEPRECATED_PROVIDER_TYPE_IMMUTABLE",
|
||||
})
|
||||
if strings.TrimSpace(req.Type) != "" && strings.TrimSpace(req.Type) != existing.Type {
|
||||
respondSanitizedProviderError(c, http.StatusBadRequest, "PROVIDER_TYPE_IMMUTABLE", "validation", "Provider type cannot be changed")
|
||||
return
|
||||
}
|
||||
|
||||
// Block enable mutation for existing non-Discord providers
|
||||
if existing.Type != "discord" && req.Enabled && !existing.Enabled {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "cannot enable deprecated non-discord providers; only discord providers can be enabled",
|
||||
"code": "DEPRECATED_PROVIDER_CANNOT_ENABLE",
|
||||
})
|
||||
providerType := strings.ToLower(strings.TrimSpace(existing.Type))
|
||||
if providerType != "discord" && providerType != "gotify" && providerType != "webhook" {
|
||||
respondSanitizedProviderError(c, http.StatusBadRequest, "UNSUPPORTED_PROVIDER_TYPE", "validation", "Unsupported notification provider type")
|
||||
return
|
||||
}
|
||||
|
||||
// Discord-only enforcement for this rollout (new providers or type changes)
|
||||
if req.Type != "discord" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "only discord provider type is supported in this release; additional providers will be enabled in future releases after validation",
|
||||
"code": "PROVIDER_TYPE_DISCORD_ONLY",
|
||||
})
|
||||
return
|
||||
if providerType == "gotify" && strings.TrimSpace(req.Token) == "" {
|
||||
// Keep existing token if update payload omits token
|
||||
req.Token = existing.Token
|
||||
}
|
||||
req.Type = existing.Type
|
||||
|
||||
provider := req.toModel()
|
||||
provider.ID = id
|
||||
@@ -179,15 +250,17 @@ func (h *NotificationProviderHandler) Update(c *gin.Context) {
|
||||
|
||||
if err := h.service.UpdateProvider(&provider); err != nil {
|
||||
if isProviderValidationError(err) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
respondSanitizedProviderError(c, http.StatusBadRequest, "PROVIDER_VALIDATION_FAILED", "validation", "Notification provider validation failed")
|
||||
return
|
||||
}
|
||||
if respondPermissionError(c, h.securityService, "notification_provider_save_failed", err, h.dataRoot) {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update provider"})
|
||||
respondSanitizedProviderError(c, http.StatusInternalServerError, "PROVIDER_UPDATE_FAILED", "internal", "Failed to update provider")
|
||||
return
|
||||
}
|
||||
provider.HasToken = provider.Token != ""
|
||||
provider.Token = ""
|
||||
c.JSON(http.StatusOK, provider)
|
||||
}
|
||||
|
||||
@@ -221,16 +294,44 @@ func (h *NotificationProviderHandler) Delete(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (h *NotificationProviderHandler) Test(c *gin.Context) {
|
||||
var req notificationProviderTestRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
respondSanitizedProviderError(c, http.StatusBadRequest, "INVALID_REQUEST", "validation", "Invalid test payload")
|
||||
return
|
||||
}
|
||||
|
||||
providerType := strings.ToLower(strings.TrimSpace(req.Type))
|
||||
if providerType == "gotify" && strings.TrimSpace(req.Token) != "" {
|
||||
respondSanitizedProviderError(c, http.StatusBadRequest, "TOKEN_WRITE_ONLY", "validation", "Gotify token is accepted only on provider create/update")
|
||||
return
|
||||
}
|
||||
|
||||
providerID := strings.TrimSpace(req.ID)
|
||||
if providerID == "" {
|
||||
respondSanitizedProviderError(c, http.StatusBadRequest, "MISSING_PROVIDER_ID", "validation", "Trusted provider ID is required for test dispatch")
|
||||
return
|
||||
}
|
||||
|
||||
var provider models.NotificationProvider
|
||||
if err := c.ShouldBindJSON(&provider); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
if err := h.service.DB.Where("id = ?", providerID).First(&provider).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
respondSanitizedProviderError(c, http.StatusNotFound, "PROVIDER_NOT_FOUND", "validation", "Provider not found")
|
||||
return
|
||||
}
|
||||
respondSanitizedProviderError(c, http.StatusInternalServerError, "PROVIDER_READ_FAILED", "internal", "Failed to read provider")
|
||||
return
|
||||
}
|
||||
|
||||
if strings.TrimSpace(provider.URL) == "" {
|
||||
respondSanitizedProviderError(c, http.StatusBadRequest, "PROVIDER_CONFIG_MISSING", "validation", "Trusted provider configuration is incomplete")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.service.TestProvider(provider); err != nil {
|
||||
// Create internal notification for the failure
|
||||
_, _ = h.service.Create(models.NotificationTypeError, "Test Failed", fmt.Sprintf("Provider %s test failed: %v", provider.Name, err))
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
_, _ = h.service.Create(models.NotificationTypeError, "Test Failed", fmt.Sprintf("Provider %s test failed", provider.Name))
|
||||
code, category, message := classifyProviderTestFailure(err)
|
||||
respondSanitizedProviderError(c, http.StatusBadRequest, code, category, message)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Test notification sent"})
|
||||
@@ -249,9 +350,15 @@ func (h *NotificationProviderHandler) Templates(c *gin.Context) {
|
||||
func (h *NotificationProviderHandler) Preview(c *gin.Context) {
|
||||
var raw map[string]any
|
||||
if err := c.ShouldBindJSON(&raw); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
respondSanitizedProviderError(c, http.StatusBadRequest, "INVALID_REQUEST", "validation", "Invalid preview payload")
|
||||
return
|
||||
}
|
||||
if tokenValue, ok := raw["token"]; ok {
|
||||
if tokenText, isString := tokenValue.(string); isString && strings.TrimSpace(tokenText) != "" {
|
||||
respondSanitizedProviderError(c, http.StatusBadRequest, "TOKEN_WRITE_ONLY", "validation", "Gotify token is accepted only on provider create/update")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
var provider models.NotificationProvider
|
||||
// Marshal raw into provider to get proper types
|
||||
@@ -279,7 +386,8 @@ func (h *NotificationProviderHandler) Preview(c *gin.Context) {
|
||||
|
||||
rendered, parsed, err := h.service.RenderTemplate(provider, payload)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "rendered": rendered})
|
||||
_ = rendered
|
||||
respondSanitizedProviderError(c, http.StatusBadRequest, "TEMPLATE_PREVIEW_FAILED", "validation", "Template preview failed")
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"rendered": rendered, "parsed": parsed})
|
||||
|
||||
@@ -120,25 +120,60 @@ func TestNotificationProviderHandler_Templates(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestNotificationProviderHandler_Test(t *testing.T) {
|
||||
r, _ := setupNotificationProviderTest(t)
|
||||
r, db := setupNotificationProviderTest(t)
|
||||
|
||||
// Test with invalid provider (should fail validation or service check)
|
||||
// Since we don't have notification dispatch mocked easily here,
|
||||
// we expect it might fail or pass depending on service implementation.
|
||||
// Looking at service code, TestProvider should validate and dispatch.
|
||||
// If URL is invalid, it should error.
|
||||
|
||||
provider := models.NotificationProvider{
|
||||
Type: "discord",
|
||||
URL: "invalid-url",
|
||||
stored := models.NotificationProvider{
|
||||
ID: "trusted-provider-id",
|
||||
Name: "Stored Provider",
|
||||
Type: "discord",
|
||||
URL: "invalid-url",
|
||||
Enabled: true,
|
||||
}
|
||||
body, _ := json.Marshal(provider)
|
||||
require.NoError(t, db.Create(&stored).Error)
|
||||
|
||||
payload := map[string]any{
|
||||
"id": stored.ID,
|
||||
"type": "discord",
|
||||
"url": "https://discord.com/api/webhooks/123/override",
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
req, _ := http.NewRequest("POST", "/api/v1/notifications/providers/test", bytes.NewBuffer(body))
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
// It should probably fail with 400
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "PROVIDER_TEST_URL_INVALID")
|
||||
}
|
||||
|
||||
func TestNotificationProviderHandler_Test_RequiresTrustedProviderID(t *testing.T) {
|
||||
r, _ := setupNotificationProviderTest(t)
|
||||
|
||||
payload := map[string]any{
|
||||
"type": "discord",
|
||||
"url": "https://discord.com/api/webhooks/123/abc",
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
req, _ := http.NewRequest("POST", "/api/v1/notifications/providers/test", bytes.NewBuffer(body))
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "MISSING_PROVIDER_ID")
|
||||
}
|
||||
|
||||
func TestNotificationProviderHandler_Test_ReturnsNotFoundForUnknownProvider(t *testing.T) {
|
||||
r, _ := setupNotificationProviderTest(t)
|
||||
|
||||
payload := map[string]any{
|
||||
"id": "missing-provider-id",
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
req, _ := http.NewRequest("POST", "/api/v1/notifications/providers/test", bytes.NewBuffer(body))
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "PROVIDER_NOT_FOUND")
|
||||
}
|
||||
|
||||
func TestNotificationProviderHandler_Errors(t *testing.T) {
|
||||
@@ -248,8 +283,8 @@ func TestNotificationProviderHandler_CreateRejectsDiscordIPHost(t *testing.T) {
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "invalid Discord webhook URL")
|
||||
assert.Contains(t, w.Body.String(), "IP address hosts are not allowed")
|
||||
assert.Contains(t, w.Body.String(), "PROVIDER_VALIDATION_FAILED")
|
||||
assert.Contains(t, w.Body.String(), "validation")
|
||||
}
|
||||
|
||||
func TestNotificationProviderHandler_CreateAcceptsDiscordHostname(t *testing.T) {
|
||||
@@ -378,3 +413,100 @@ func TestNotificationProviderHandler_UpdatePreservesServerManagedMigrationFields
|
||||
require.NotNil(t, dbProvider.LastMigratedAt)
|
||||
assert.Equal(t, now, dbProvider.LastMigratedAt.UTC().Round(time.Second))
|
||||
}
|
||||
|
||||
func TestNotificationProviderHandler_List_ReturnsHasTokenTrue(t *testing.T) {
|
||||
r, db := setupNotificationProviderTest(t)
|
||||
|
||||
p := models.NotificationProvider{
|
||||
ID: "tok-true",
|
||||
Name: "Gotify With Token",
|
||||
Type: "gotify",
|
||||
URL: "https://gotify.example.com",
|
||||
Token: "secret-app-token",
|
||||
}
|
||||
require.NoError(t, db.Create(&p).Error)
|
||||
|
||||
req, _ := http.NewRequest("GET", "/api/v1/notifications/providers", http.NoBody)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var raw []map[string]interface{}
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &raw))
|
||||
require.Len(t, raw, 1)
|
||||
assert.Equal(t, true, raw[0]["has_token"])
|
||||
}
|
||||
|
||||
func TestNotificationProviderHandler_List_ReturnsHasTokenFalse(t *testing.T) {
|
||||
r, db := setupNotificationProviderTest(t)
|
||||
|
||||
p := models.NotificationProvider{
|
||||
ID: "tok-false",
|
||||
Name: "Discord No Token",
|
||||
Type: "discord",
|
||||
URL: "https://discord.com/api/webhooks/123/abc",
|
||||
}
|
||||
require.NoError(t, db.Create(&p).Error)
|
||||
|
||||
req, _ := http.NewRequest("GET", "/api/v1/notifications/providers", http.NoBody)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var raw []map[string]interface{}
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &raw))
|
||||
require.Len(t, raw, 1)
|
||||
assert.Equal(t, false, raw[0]["has_token"])
|
||||
}
|
||||
|
||||
func TestNotificationProviderHandler_List_NeverExposesRawToken(t *testing.T) {
|
||||
r, db := setupNotificationProviderTest(t)
|
||||
|
||||
p := models.NotificationProvider{
|
||||
ID: "tok-hidden",
|
||||
Name: "Secret Gotify",
|
||||
Type: "gotify",
|
||||
URL: "https://gotify.example.com",
|
||||
Token: "super-secret-value",
|
||||
}
|
||||
require.NoError(t, db.Create(&p).Error)
|
||||
|
||||
req, _ := http.NewRequest("GET", "/api/v1/notifications/providers", http.NoBody)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
assert.NotContains(t, w.Body.String(), "super-secret-value")
|
||||
|
||||
var raw []map[string]interface{}
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &raw))
|
||||
require.Len(t, raw, 1)
|
||||
_, hasTokenField := raw[0]["token"]
|
||||
assert.False(t, hasTokenField, "raw token field must not appear in JSON response")
|
||||
}
|
||||
|
||||
func TestNotificationProviderHandler_Create_ResponseHasHasToken(t *testing.T) {
|
||||
r, _ := setupNotificationProviderTest(t)
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"name": "New Gotify",
|
||||
"type": "gotify",
|
||||
"url": "https://gotify.example.com",
|
||||
"token": "app-token-123",
|
||||
"template": "minimal",
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
req, _ := http.NewRequest("POST", "/api/v1/notifications/providers", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusCreated, w.Code)
|
||||
|
||||
var raw map[string]interface{}
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &raw))
|
||||
assert.Equal(t, true, raw["has_token"])
|
||||
assert.NotContains(t, w.Body.String(), "app-token-123")
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ func TestUpdate_BlockTypeMutationForNonDiscord(t *testing.T) {
|
||||
err = json.Unmarshal(w.Body.Bytes(), &response)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "DEPRECATED_PROVIDER_TYPE_IMMUTABLE", response["code"])
|
||||
assert.Equal(t, "PROVIDER_TYPE_IMMUTABLE", response["code"])
|
||||
}
|
||||
|
||||
// TestUpdate_AllowTypeMutationForDiscord verifies Discord can be updated
|
||||
|
||||
@@ -24,6 +24,17 @@ func requireAdmin(c *gin.Context) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func requireAuthenticatedAdmin(c *gin.Context) bool {
|
||||
if _, exists := c.Get("userID"); !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"error": "Authorization header required",
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
return requireAdmin(c)
|
||||
}
|
||||
|
||||
func isAdmin(c *gin.Context) bool {
|
||||
role, _ := c.Get("role")
|
||||
roleStr, _ := role.(string)
|
||||
|
||||
@@ -168,3 +168,34 @@ func TestLogPermissionAudit_ActorFallback(t *testing.T) {
|
||||
assert.Equal(t, "permissions", audit.EventCategory)
|
||||
assert.Contains(t, audit.Details, fmt.Sprintf("\"admin\":%v", false))
|
||||
}
|
||||
|
||||
func TestRequireAuthenticatedAdmin_NoUserID(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx, rec := newTestContextWithRequest()
|
||||
result := requireAuthenticatedAdmin(ctx)
|
||||
assert.False(t, result)
|
||||
assert.Equal(t, http.StatusUnauthorized, rec.Code)
|
||||
assert.Contains(t, rec.Body.String(), "Authorization header required")
|
||||
}
|
||||
|
||||
func TestRequireAuthenticatedAdmin_UserIDPresentAndAdmin(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx, _ := newTestContextWithRequest()
|
||||
ctx.Set("userID", uint(1))
|
||||
ctx.Set("role", "admin")
|
||||
result := requireAuthenticatedAdmin(ctx)
|
||||
assert.True(t, result)
|
||||
}
|
||||
|
||||
func TestRequireAuthenticatedAdmin_UserIDPresentButNotAdmin(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx, rec := newTestContextWithRequest()
|
||||
ctx.Set("userID", uint(1))
|
||||
ctx.Set("role", "user")
|
||||
result := requireAuthenticatedAdmin(ctx)
|
||||
assert.False(t, result)
|
||||
assert.Equal(t, http.StatusForbidden, rec.Code)
|
||||
}
|
||||
|
||||
@@ -130,6 +130,7 @@ func generateForwardHostWarnings(forwardHost string) []ProxyHostWarning {
|
||||
// ProxyHostHandler handles CRUD operations for proxy hosts.
|
||||
type ProxyHostHandler struct {
|
||||
service *services.ProxyHostService
|
||||
db *gorm.DB
|
||||
caddyManager *caddy.Manager
|
||||
notificationService *services.NotificationService
|
||||
uptimeService *services.UptimeService
|
||||
@@ -183,6 +184,74 @@ func parseNullableUintField(value any, fieldName string) (*uint, bool, error) {
|
||||
}
|
||||
}
|
||||
|
||||
func (h *ProxyHostHandler) resolveAccessListReference(value any) (*uint, error) {
|
||||
if value == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
parsedID, _, parseErr := parseNullableUintField(value, "access_list_id")
|
||||
if parseErr == nil {
|
||||
return parsedID, nil
|
||||
}
|
||||
|
||||
uuidValue, isString := value.(string)
|
||||
if !isString {
|
||||
return nil, parseErr
|
||||
}
|
||||
|
||||
trimmed := strings.TrimSpace(uuidValue)
|
||||
if trimmed == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var acl models.AccessList
|
||||
if err := h.db.Select("id").Where("uuid = ?", trimmed).First(&acl).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, fmt.Errorf("access list not found")
|
||||
}
|
||||
return nil, fmt.Errorf("failed to resolve access list")
|
||||
}
|
||||
|
||||
id := acl.ID
|
||||
return &id, nil
|
||||
}
|
||||
|
||||
func (h *ProxyHostHandler) resolveSecurityHeaderProfileReference(value any) (*uint, error) {
|
||||
if value == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
parsedID, _, parseErr := parseNullableUintField(value, "security_header_profile_id")
|
||||
if parseErr == nil {
|
||||
return parsedID, nil
|
||||
}
|
||||
|
||||
uuidValue, isString := value.(string)
|
||||
if !isString {
|
||||
return nil, parseErr
|
||||
}
|
||||
|
||||
trimmed := strings.TrimSpace(uuidValue)
|
||||
if trimmed == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if _, err := uuid.Parse(trimmed); err != nil {
|
||||
return nil, parseErr
|
||||
}
|
||||
|
||||
var profile models.SecurityHeaderProfile
|
||||
if err := h.db.Select("id").Where("uuid = ?", trimmed).First(&profile).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, fmt.Errorf("security header profile not found")
|
||||
}
|
||||
return nil, fmt.Errorf("failed to resolve security header profile")
|
||||
}
|
||||
|
||||
id := profile.ID
|
||||
return &id, nil
|
||||
}
|
||||
|
||||
func parseForwardPortField(value any) (int, error) {
|
||||
switch v := value.(type) {
|
||||
case float64:
|
||||
@@ -221,6 +290,7 @@ func parseForwardPortField(value any) (int, error) {
|
||||
func NewProxyHostHandler(db *gorm.DB, caddyManager *caddy.Manager, ns *services.NotificationService, uptimeService *services.UptimeService) *ProxyHostHandler {
|
||||
return &ProxyHostHandler{
|
||||
service: services.NewProxyHostService(db),
|
||||
db: db,
|
||||
caddyManager: caddyManager,
|
||||
notificationService: ns,
|
||||
uptimeService: uptimeService,
|
||||
@@ -252,8 +322,38 @@ func (h *ProxyHostHandler) List(c *gin.Context) {
|
||||
|
||||
// Create creates a new proxy host.
|
||||
func (h *ProxyHostHandler) Create(c *gin.Context) {
|
||||
var payload map[string]any
|
||||
if err := c.ShouldBindJSON(&payload); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if rawAccessListRef, ok := payload["access_list_id"]; ok {
|
||||
resolvedAccessListID, resolveErr := h.resolveAccessListReference(rawAccessListRef)
|
||||
if resolveErr != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": resolveErr.Error()})
|
||||
return
|
||||
}
|
||||
payload["access_list_id"] = resolvedAccessListID
|
||||
}
|
||||
|
||||
if rawSecurityHeaderRef, ok := payload["security_header_profile_id"]; ok {
|
||||
resolvedSecurityHeaderID, resolveErr := h.resolveSecurityHeaderProfileReference(rawSecurityHeaderRef)
|
||||
if resolveErr != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": resolveErr.Error()})
|
||||
return
|
||||
}
|
||||
payload["security_header_profile_id"] = resolvedSecurityHeaderID
|
||||
}
|
||||
|
||||
payloadBytes, marshalErr := json.Marshal(payload)
|
||||
if marshalErr != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request payload"})
|
||||
return
|
||||
}
|
||||
|
||||
var host models.ProxyHost
|
||||
if err := c.ShouldBindJSON(&host); err != nil {
|
||||
if err := json.Unmarshal(payloadBytes, &host); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
@@ -313,6 +413,11 @@ func (h *ProxyHostHandler) Create(c *gin.Context) {
|
||||
)
|
||||
}
|
||||
|
||||
// Trigger immediate uptime monitor creation + health check (non-blocking)
|
||||
if h.uptimeService != nil {
|
||||
go h.uptimeService.SyncAndCheckForHost(host.ID)
|
||||
}
|
||||
|
||||
// Generate advisory warnings for private/Docker IPs
|
||||
warnings := generateForwardHostWarnings(host.ForwardHost)
|
||||
|
||||
@@ -430,12 +535,12 @@ func (h *ProxyHostHandler) Update(c *gin.Context) {
|
||||
host.CertificateID = parsedID
|
||||
}
|
||||
if v, ok := payload["access_list_id"]; ok {
|
||||
parsedID, _, parseErr := parseNullableUintField(v, "access_list_id")
|
||||
if parseErr != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": parseErr.Error()})
|
||||
resolvedAccessListID, resolveErr := h.resolveAccessListReference(v)
|
||||
if resolveErr != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": resolveErr.Error()})
|
||||
return
|
||||
}
|
||||
host.AccessListID = parsedID
|
||||
host.AccessListID = resolvedAccessListID
|
||||
}
|
||||
|
||||
if v, ok := payload["dns_provider_id"]; ok {
|
||||
@@ -453,54 +558,12 @@ func (h *ProxyHostHandler) Update(c *gin.Context) {
|
||||
|
||||
// Security Header Profile: update only if provided
|
||||
if v, ok := payload["security_header_profile_id"]; ok {
|
||||
logger := middleware.GetRequestLogger(c)
|
||||
// Sanitize user-provided values for log injection protection (CWE-117)
|
||||
safeUUID := sanitizeForLog(uuidStr)
|
||||
logger.WithField("host_uuid", safeUUID).WithField("raw_value", sanitizeForLog(fmt.Sprintf("%v", v))).Debug("Processing security_header_profile_id update")
|
||||
|
||||
if v == nil {
|
||||
logger.WithField("host_uuid", safeUUID).Debug("Setting security_header_profile_id to nil")
|
||||
host.SecurityHeaderProfileID = nil
|
||||
} else {
|
||||
conversionSuccess := false
|
||||
switch t := v.(type) {
|
||||
case float64:
|
||||
logger.Debug("Received security_header_profile_id as float64")
|
||||
if id, ok := safeFloat64ToUint(t); ok {
|
||||
host.SecurityHeaderProfileID = &id
|
||||
conversionSuccess = true
|
||||
logger.Info("Successfully converted security_header_profile_id from float64")
|
||||
} else {
|
||||
logger.Warn("Failed to convert security_header_profile_id from float64: value is negative or not a valid uint")
|
||||
}
|
||||
case int:
|
||||
logger.Debug("Received security_header_profile_id as int")
|
||||
if id, ok := safeIntToUint(t); ok {
|
||||
host.SecurityHeaderProfileID = &id
|
||||
conversionSuccess = true
|
||||
logger.Info("Successfully converted security_header_profile_id from int")
|
||||
} else {
|
||||
logger.Warn("Failed to convert security_header_profile_id from int: value is negative")
|
||||
}
|
||||
case string:
|
||||
logger.Debug("Received security_header_profile_id as string")
|
||||
if n, err := strconv.ParseUint(t, 10, 32); err == nil {
|
||||
id := uint(n)
|
||||
host.SecurityHeaderProfileID = &id
|
||||
conversionSuccess = true
|
||||
logger.WithField("host_uuid", safeUUID).WithField("profile_id", id).Info("Successfully converted security_header_profile_id from string")
|
||||
} else {
|
||||
logger.Warn("Failed to parse security_header_profile_id from string")
|
||||
}
|
||||
default:
|
||||
logger.Warn("Unsupported type for security_header_profile_id")
|
||||
}
|
||||
|
||||
if !conversionSuccess {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid security_header_profile_id: unable to convert value %v of type %T to uint", v, v)})
|
||||
return
|
||||
}
|
||||
resolvedSecurityHeaderID, resolveErr := h.resolveSecurityHeaderProfileReference(v)
|
||||
if resolveErr != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": resolveErr.Error()})
|
||||
return
|
||||
}
|
||||
host.SecurityHeaderProfileID = resolvedSecurityHeaderID
|
||||
}
|
||||
|
||||
// Locations: replace only if provided
|
||||
@@ -587,11 +650,10 @@ func (h *ProxyHostHandler) Delete(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// check if we should also delete associated uptime monitors (query param: delete_uptime=true)
|
||||
deleteUptime := c.DefaultQuery("delete_uptime", "false") == "true"
|
||||
|
||||
if deleteUptime && h.uptimeService != nil {
|
||||
// Find all monitors referencing this proxy host and delete each
|
||||
// Always clean up associated uptime monitors when deleting a proxy host.
|
||||
// The query param delete_uptime=true is kept for backward compatibility but
|
||||
// cleanup now runs unconditionally to prevent orphaned monitors.
|
||||
if h.uptimeService != nil {
|
||||
var monitors []models.UptimeMonitor
|
||||
if err := h.uptimeService.DB.Where("proxy_host_id = ?", host.ID).Find(&monitors).Error; err == nil {
|
||||
for _, m := range monitors {
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
@@ -44,6 +45,219 @@ func setupTestRouter(t *testing.T) (*gin.Engine, *gorm.DB) {
|
||||
return r, db
|
||||
}
|
||||
|
||||
func setupTestRouterWithReferenceTables(t *testing.T) (*gin.Engine, *gorm.DB) {
|
||||
t.Helper()
|
||||
|
||||
dsn := "file:" + t.Name() + "?mode=memory&cache=shared"
|
||||
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.AutoMigrate(
|
||||
&models.ProxyHost{},
|
||||
&models.Location{},
|
||||
&models.AccessList{},
|
||||
&models.SecurityHeaderProfile{},
|
||||
&models.Notification{},
|
||||
&models.NotificationProvider{},
|
||||
))
|
||||
|
||||
ns := services.NewNotificationService(db)
|
||||
h := NewProxyHostHandler(db, nil, ns, nil)
|
||||
r := gin.New()
|
||||
api := r.Group("/api/v1")
|
||||
h.RegisterRoutes(api)
|
||||
|
||||
return r, db
|
||||
}
|
||||
|
||||
func setupTestRouterWithUptime(t *testing.T) (*gin.Engine, *gorm.DB) {
|
||||
t.Helper()
|
||||
|
||||
dsn := "file:" + t.Name() + "?mode=memory&cache=shared"
|
||||
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.AutoMigrate(
|
||||
&models.ProxyHost{},
|
||||
&models.Location{},
|
||||
&models.Notification{},
|
||||
&models.NotificationProvider{},
|
||||
&models.UptimeMonitor{},
|
||||
&models.UptimeHeartbeat{},
|
||||
&models.UptimeHost{},
|
||||
&models.Setting{},
|
||||
))
|
||||
|
||||
ns := services.NewNotificationService(db)
|
||||
us := services.NewUptimeService(db, ns)
|
||||
h := NewProxyHostHandler(db, nil, ns, us)
|
||||
r := gin.New()
|
||||
api := r.Group("/api/v1")
|
||||
h.RegisterRoutes(api)
|
||||
|
||||
return r, db
|
||||
}
|
||||
|
||||
func TestProxyHostHandler_ResolveAccessListReference_TargetedBranches(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, db := setupTestRouterWithReferenceTables(t)
|
||||
h := NewProxyHostHandler(db, nil, services.NewNotificationService(db), nil)
|
||||
|
||||
resolved, err := h.resolveAccessListReference(true)
|
||||
require.Error(t, err)
|
||||
require.Nil(t, resolved)
|
||||
require.Contains(t, err.Error(), "invalid access_list_id")
|
||||
|
||||
resolved, err = h.resolveAccessListReference(" ")
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, resolved)
|
||||
|
||||
acl := models.AccessList{UUID: uuid.NewString(), Name: "resolve-acl", Type: "ip", Enabled: true}
|
||||
require.NoError(t, db.Create(&acl).Error)
|
||||
|
||||
resolved, err = h.resolveAccessListReference(acl.UUID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resolved)
|
||||
require.Equal(t, acl.ID, *resolved)
|
||||
}
|
||||
|
||||
func TestProxyHostHandler_ResolveSecurityHeaderReference_TargetedBranches(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, db := setupTestRouterWithReferenceTables(t)
|
||||
h := NewProxyHostHandler(db, nil, services.NewNotificationService(db), nil)
|
||||
|
||||
resolved, err := h.resolveSecurityHeaderProfileReference(" ")
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, resolved)
|
||||
|
||||
profile := models.SecurityHeaderProfile{
|
||||
UUID: uuid.NewString(),
|
||||
Name: "resolve-security-profile",
|
||||
IsPreset: false,
|
||||
SecurityScore: 90,
|
||||
}
|
||||
require.NoError(t, db.Create(&profile).Error)
|
||||
|
||||
resolved, err = h.resolveSecurityHeaderProfileReference(profile.UUID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resolved)
|
||||
require.Equal(t, profile.ID, *resolved)
|
||||
|
||||
resolved, err = h.resolveSecurityHeaderProfileReference(uuid.NewString())
|
||||
require.Error(t, err)
|
||||
require.Nil(t, resolved)
|
||||
require.Contains(t, err.Error(), "security header profile not found")
|
||||
|
||||
require.NoError(t, db.Migrator().DropTable(&models.SecurityHeaderProfile{}))
|
||||
resolved, err = h.resolveSecurityHeaderProfileReference(uuid.NewString())
|
||||
require.Error(t, err)
|
||||
require.Nil(t, resolved)
|
||||
require.Contains(t, err.Error(), "failed to resolve security header profile")
|
||||
}
|
||||
|
||||
func TestProxyHostCreate_ReferenceResolution_TargetedBranches(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
router, db := setupTestRouterWithReferenceTables(t)
|
||||
|
||||
acl := models.AccessList{UUID: uuid.NewString(), Name: "create-acl", Type: "ip", Enabled: true}
|
||||
require.NoError(t, db.Create(&acl).Error)
|
||||
|
||||
profile := models.SecurityHeaderProfile{
|
||||
UUID: uuid.NewString(),
|
||||
Name: "create-security-profile",
|
||||
IsPreset: false,
|
||||
SecurityScore: 85,
|
||||
}
|
||||
require.NoError(t, db.Create(&profile).Error)
|
||||
|
||||
t.Run("creates host when references are valid UUIDs", func(t *testing.T) {
|
||||
body := map[string]any{
|
||||
"name": "Create Ref Success",
|
||||
"domain_names": "create-ref-success.example.com",
|
||||
"forward_scheme": "http",
|
||||
"forward_host": "localhost",
|
||||
"forward_port": 8080,
|
||||
"enabled": true,
|
||||
"access_list_id": acl.UUID,
|
||||
"security_header_profile_id": profile.UUID,
|
||||
}
|
||||
payload, err := json.Marshal(body)
|
||||
require.NoError(t, err)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/proxy-hosts", bytes.NewReader(payload))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp := httptest.NewRecorder()
|
||||
router.ServeHTTP(resp, req)
|
||||
require.Equal(t, http.StatusCreated, resp.Code)
|
||||
|
||||
var created models.ProxyHost
|
||||
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &created))
|
||||
require.NotNil(t, created.AccessListID)
|
||||
require.Equal(t, acl.ID, *created.AccessListID)
|
||||
require.NotNil(t, created.SecurityHeaderProfileID)
|
||||
require.Equal(t, profile.ID, *created.SecurityHeaderProfileID)
|
||||
})
|
||||
|
||||
t.Run("returns bad request for invalid access list reference type", func(t *testing.T) {
|
||||
body := `{"name":"Create ACL Type Error","domain_names":"create-acl-type-error.example.com","forward_scheme":"http","forward_host":"localhost","forward_port":8080,"enabled":true,"access_list_id":true}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/proxy-hosts", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp := httptest.NewRecorder()
|
||||
router.ServeHTTP(resp, req)
|
||||
require.Equal(t, http.StatusBadRequest, resp.Code)
|
||||
})
|
||||
|
||||
t.Run("returns bad request for missing security header profile", func(t *testing.T) {
|
||||
body := map[string]any{
|
||||
"name": "Create Security Missing",
|
||||
"domain_names": "create-security-missing.example.com",
|
||||
"forward_scheme": "http",
|
||||
"forward_host": "localhost",
|
||||
"forward_port": 8080,
|
||||
"enabled": true,
|
||||
"security_header_profile_id": uuid.NewString(),
|
||||
}
|
||||
payload, err := json.Marshal(body)
|
||||
require.NoError(t, err)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/proxy-hosts", bytes.NewReader(payload))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp := httptest.NewRecorder()
|
||||
router.ServeHTTP(resp, req)
|
||||
require.Equal(t, http.StatusBadRequest, resp.Code)
|
||||
})
|
||||
}
|
||||
|
||||
func TestProxyHostCreate_TriggersAsyncUptimeSyncWhenServiceConfigured(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
router, db := setupTestRouterWithUptime(t)
|
||||
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
t.Cleanup(upstream.Close)
|
||||
|
||||
domain := strings.TrimPrefix(upstream.URL, "http://")
|
||||
body := fmt.Sprintf(`{"name":"Uptime Hook","domain_names":"%s","forward_scheme":"http","forward_host":"app-service","forward_port":8080,"enabled":true}`, domain)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/proxy-hosts", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp := httptest.NewRecorder()
|
||||
router.ServeHTTP(resp, req)
|
||||
require.Equal(t, http.StatusCreated, resp.Code)
|
||||
|
||||
var created models.ProxyHost
|
||||
require.NoError(t, db.Where("domain_names = ?", domain).First(&created).Error)
|
||||
|
||||
var count int64
|
||||
require.Eventually(t, func() bool {
|
||||
db.Model(&models.UptimeMonitor{}).Where("proxy_host_id = ?", created.ID).Count(&count)
|
||||
return count > 0
|
||||
}, 3*time.Second, 50*time.Millisecond)
|
||||
}
|
||||
|
||||
func TestProxyHostLifecycle(t *testing.T) {
|
||||
t.Parallel()
|
||||
router, _ := setupTestRouter(t)
|
||||
|
||||
@@ -75,6 +75,203 @@ func createTestSecurityHeaderProfile(t *testing.T, db *gorm.DB, name string) mod
|
||||
return profile
|
||||
}
|
||||
|
||||
// createTestAccessList creates an access list for testing.
|
||||
func createTestAccessList(t *testing.T, db *gorm.DB, name string) models.AccessList {
|
||||
t.Helper()
|
||||
acl := models.AccessList{
|
||||
UUID: uuid.NewString(),
|
||||
Name: name,
|
||||
Type: "ip",
|
||||
Enabled: true,
|
||||
}
|
||||
require.NoError(t, db.Create(&acl).Error)
|
||||
return acl
|
||||
}
|
||||
|
||||
func TestProxyHostUpdate_AccessListID_Transitions_NoUnrelatedMutation(t *testing.T) {
|
||||
t.Parallel()
|
||||
router, db := setupUpdateTestRouter(t)
|
||||
|
||||
aclOne := createTestAccessList(t, db, "ACL One")
|
||||
aclTwo := createTestAccessList(t, db, "ACL Two")
|
||||
|
||||
host := models.ProxyHost{
|
||||
UUID: uuid.NewString(),
|
||||
Name: "Access List Transition Host",
|
||||
DomainNames: "acl-transition.test.com",
|
||||
ForwardScheme: "http",
|
||||
ForwardHost: "localhost",
|
||||
ForwardPort: 8080,
|
||||
Enabled: true,
|
||||
SSLForced: true,
|
||||
Application: "none",
|
||||
AccessListID: &aclOne.ID,
|
||||
}
|
||||
require.NoError(t, db.Create(&host).Error)
|
||||
|
||||
assertUnrelatedFields := func(t *testing.T, current models.ProxyHost) {
|
||||
t.Helper()
|
||||
assert.Equal(t, "Access List Transition Host", current.Name)
|
||||
assert.Equal(t, "acl-transition.test.com", current.DomainNames)
|
||||
assert.Equal(t, "localhost", current.ForwardHost)
|
||||
assert.Equal(t, 8080, current.ForwardPort)
|
||||
assert.True(t, current.SSLForced)
|
||||
assert.Equal(t, "none", current.Application)
|
||||
}
|
||||
|
||||
runUpdate := func(t *testing.T, update map[string]any) {
|
||||
t.Helper()
|
||||
body, _ := json.Marshal(update)
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/v1/proxy-hosts/"+host.UUID, bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp := httptest.NewRecorder()
|
||||
router.ServeHTTP(resp, req)
|
||||
require.Equal(t, http.StatusOK, resp.Code)
|
||||
}
|
||||
|
||||
// value -> value
|
||||
runUpdate(t, map[string]any{"access_list_id": aclTwo.ID})
|
||||
var updated models.ProxyHost
|
||||
require.NoError(t, db.First(&updated, "uuid = ?", host.UUID).Error)
|
||||
require.NotNil(t, updated.AccessListID)
|
||||
assert.Equal(t, aclTwo.ID, *updated.AccessListID)
|
||||
assertUnrelatedFields(t, updated)
|
||||
|
||||
// value -> null
|
||||
runUpdate(t, map[string]any{"access_list_id": nil})
|
||||
require.NoError(t, db.First(&updated, "uuid = ?", host.UUID).Error)
|
||||
assert.Nil(t, updated.AccessListID)
|
||||
assertUnrelatedFields(t, updated)
|
||||
|
||||
// null -> value
|
||||
runUpdate(t, map[string]any{"access_list_id": aclOne.ID})
|
||||
require.NoError(t, db.First(&updated, "uuid = ?", host.UUID).Error)
|
||||
require.NotNil(t, updated.AccessListID)
|
||||
assert.Equal(t, aclOne.ID, *updated.AccessListID)
|
||||
assertUnrelatedFields(t, updated)
|
||||
}
|
||||
|
||||
func TestProxyHostUpdate_AccessListID_UUIDNotFound_ReturnsBadRequest(t *testing.T) {
|
||||
t.Parallel()
|
||||
router, db := setupUpdateTestRouter(t)
|
||||
|
||||
host := createTestProxyHost(t, db, "acl-uuid-not-found")
|
||||
|
||||
updateBody := map[string]any{
|
||||
"name": "ACL UUID Not Found",
|
||||
"domain_names": "acl-uuid-not-found.test.com",
|
||||
"forward_scheme": "http",
|
||||
"forward_host": "localhost",
|
||||
"forward_port": 8080,
|
||||
"access_list_id": uuid.NewString(),
|
||||
}
|
||||
body, _ := json.Marshal(updateBody)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/v1/proxy-hosts/"+host.UUID, bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp := httptest.NewRecorder()
|
||||
router.ServeHTTP(resp, req)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, resp.Code)
|
||||
|
||||
var result map[string]any
|
||||
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &result))
|
||||
assert.Contains(t, result["error"], "access list not found")
|
||||
}
|
||||
|
||||
func TestProxyHostUpdate_AccessListID_ResolveQueryFailure_ReturnsBadRequest(t *testing.T) {
|
||||
t.Parallel()
|
||||
router, db := setupUpdateTestRouter(t)
|
||||
|
||||
host := createTestProxyHost(t, db, "acl-resolve-query-failure")
|
||||
|
||||
require.NoError(t, db.Migrator().DropTable(&models.AccessList{}))
|
||||
|
||||
updateBody := map[string]any{
|
||||
"name": "ACL Resolve Query Failure",
|
||||
"domain_names": "acl-resolve-query-failure.test.com",
|
||||
"forward_scheme": "http",
|
||||
"forward_host": "localhost",
|
||||
"forward_port": 8080,
|
||||
"access_list_id": uuid.NewString(),
|
||||
}
|
||||
body, _ := json.Marshal(updateBody)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/v1/proxy-hosts/"+host.UUID, bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp := httptest.NewRecorder()
|
||||
router.ServeHTTP(resp, req)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, resp.Code)
|
||||
|
||||
var result map[string]any
|
||||
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &result))
|
||||
assert.Contains(t, result["error"], "failed to resolve access list")
|
||||
}
|
||||
|
||||
func TestProxyHostUpdate_SecurityHeaderProfileID_Transitions_NoUnrelatedMutation(t *testing.T) {
|
||||
t.Parallel()
|
||||
router, db := setupUpdateTestRouter(t)
|
||||
|
||||
profileOne := createTestSecurityHeaderProfile(t, db, "Security Profile One")
|
||||
profileTwo := createTestSecurityHeaderProfile(t, db, "Security Profile Two")
|
||||
|
||||
host := models.ProxyHost{
|
||||
UUID: uuid.NewString(),
|
||||
Name: "Security Profile Transition Host",
|
||||
DomainNames: "security-transition.test.com",
|
||||
ForwardScheme: "http",
|
||||
ForwardHost: "localhost",
|
||||
ForwardPort: 9090,
|
||||
Enabled: true,
|
||||
SSLForced: true,
|
||||
Application: "none",
|
||||
SecurityHeaderProfileID: &profileOne.ID,
|
||||
}
|
||||
require.NoError(t, db.Create(&host).Error)
|
||||
|
||||
assertUnrelatedFields := func(t *testing.T, current models.ProxyHost) {
|
||||
t.Helper()
|
||||
assert.Equal(t, "Security Profile Transition Host", current.Name)
|
||||
assert.Equal(t, "security-transition.test.com", current.DomainNames)
|
||||
assert.Equal(t, "localhost", current.ForwardHost)
|
||||
assert.Equal(t, 9090, current.ForwardPort)
|
||||
assert.True(t, current.SSLForced)
|
||||
assert.Equal(t, "none", current.Application)
|
||||
}
|
||||
|
||||
runUpdate := func(t *testing.T, update map[string]any) {
|
||||
t.Helper()
|
||||
body, _ := json.Marshal(update)
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/v1/proxy-hosts/"+host.UUID, bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp := httptest.NewRecorder()
|
||||
router.ServeHTTP(resp, req)
|
||||
require.Equal(t, http.StatusOK, resp.Code)
|
||||
}
|
||||
|
||||
// value -> value
|
||||
runUpdate(t, map[string]any{"security_header_profile_id": fmt.Sprintf("%d", profileTwo.ID)})
|
||||
var updated models.ProxyHost
|
||||
require.NoError(t, db.First(&updated, "uuid = ?", host.UUID).Error)
|
||||
require.NotNil(t, updated.SecurityHeaderProfileID)
|
||||
assert.Equal(t, profileTwo.ID, *updated.SecurityHeaderProfileID)
|
||||
assertUnrelatedFields(t, updated)
|
||||
|
||||
// value -> null
|
||||
runUpdate(t, map[string]any{"security_header_profile_id": ""})
|
||||
require.NoError(t, db.First(&updated, "uuid = ?", host.UUID).Error)
|
||||
assert.Nil(t, updated.SecurityHeaderProfileID)
|
||||
assertUnrelatedFields(t, updated)
|
||||
|
||||
// null -> value
|
||||
runUpdate(t, map[string]any{"security_header_profile_id": fmt.Sprintf("%d", profileOne.ID)})
|
||||
require.NoError(t, db.First(&updated, "uuid = ?", host.UUID).Error)
|
||||
require.NotNil(t, updated.SecurityHeaderProfileID)
|
||||
assert.Equal(t, profileOne.ID, *updated.SecurityHeaderProfileID)
|
||||
assertUnrelatedFields(t, updated)
|
||||
}
|
||||
|
||||
// TestProxyHostUpdate_EnableStandardHeaders_Null tests updating enable_standard_headers to null.
|
||||
func TestProxyHostUpdate_EnableStandardHeaders_Null(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -59,6 +59,10 @@ func TestSecurityHandler_ReloadGeoIP_NotInitialized(t *testing.T) {
|
||||
|
||||
h := NewSecurityHandler(config.SecurityConfig{}, nil, nil)
|
||||
r := gin.New()
|
||||
r.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
r.POST("/security/geoip/reload", h.ReloadGeoIP)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
@@ -75,6 +79,10 @@ func TestSecurityHandler_ReloadGeoIP_LoadError(t *testing.T) {
|
||||
h.SetGeoIPService(&services.GeoIPService{}) // dbPath empty => Load() will error
|
||||
|
||||
r := gin.New()
|
||||
r.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
r.POST("/security/geoip/reload", h.ReloadGeoIP)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
@@ -90,6 +98,10 @@ func TestSecurityHandler_LookupGeoIP_MissingIPAddress(t *testing.T) {
|
||||
|
||||
h := NewSecurityHandler(config.SecurityConfig{}, nil, nil)
|
||||
r := gin.New()
|
||||
r.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
r.POST("/security/geoip/lookup", h.LookupGeoIP)
|
||||
|
||||
payload := []byte(`{}`)
|
||||
@@ -109,6 +121,10 @@ func TestSecurityHandler_LookupGeoIP_ServiceUnavailable(t *testing.T) {
|
||||
h.SetGeoIPService(&services.GeoIPService{}) // present but not loaded
|
||||
|
||||
r := gin.New()
|
||||
r.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
r.POST("/security/geoip/lookup", h.LookupGeoIP)
|
||||
|
||||
payload, _ := json.Marshal(map[string]string{"ip_address": "8.8.8.8"})
|
||||
|
||||
@@ -261,6 +261,10 @@ func (h *SecurityHandler) GetConfig(c *gin.Context) {
|
||||
|
||||
// UpdateConfig creates or updates the SecurityConfig in DB
|
||||
func (h *SecurityHandler) UpdateConfig(c *gin.Context) {
|
||||
if !requireAdmin(c) {
|
||||
return
|
||||
}
|
||||
|
||||
var payload models.SecurityConfig
|
||||
if err := c.ShouldBindJSON(&payload); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
|
||||
@@ -290,6 +294,10 @@ func (h *SecurityHandler) UpdateConfig(c *gin.Context) {
|
||||
|
||||
// GenerateBreakGlass generates a break-glass token and returns the plaintext token once
|
||||
func (h *SecurityHandler) GenerateBreakGlass(c *gin.Context) {
|
||||
if !requireAdmin(c) {
|
||||
return
|
||||
}
|
||||
|
||||
token, err := h.svc.GenerateBreakGlassToken("default")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate break-glass token"})
|
||||
@@ -316,6 +324,10 @@ func (h *SecurityHandler) ListDecisions(c *gin.Context) {
|
||||
|
||||
// CreateDecision creates a manual decision (override) - for now no checks besides payload
|
||||
func (h *SecurityHandler) CreateDecision(c *gin.Context) {
|
||||
if !requireAdmin(c) {
|
||||
return
|
||||
}
|
||||
|
||||
var payload models.SecurityDecision
|
||||
if err := c.ShouldBindJSON(&payload); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
|
||||
@@ -371,6 +383,10 @@ func (h *SecurityHandler) ListRuleSets(c *gin.Context) {
|
||||
|
||||
// UpsertRuleSet uploads or updates a ruleset
|
||||
func (h *SecurityHandler) UpsertRuleSet(c *gin.Context) {
|
||||
if !requireAdmin(c) {
|
||||
return
|
||||
}
|
||||
|
||||
var payload models.SecurityRuleSet
|
||||
if err := c.ShouldBindJSON(&payload); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
|
||||
@@ -401,6 +417,10 @@ func (h *SecurityHandler) UpsertRuleSet(c *gin.Context) {
|
||||
|
||||
// DeleteRuleSet removes a ruleset by id
|
||||
func (h *SecurityHandler) DeleteRuleSet(c *gin.Context) {
|
||||
if !requireAdmin(c) {
|
||||
return
|
||||
}
|
||||
|
||||
idParam := c.Param("id")
|
||||
if idParam == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "id is required"})
|
||||
@@ -610,6 +630,10 @@ func (h *SecurityHandler) GetGeoIPStatus(c *gin.Context) {
|
||||
|
||||
// ReloadGeoIP reloads the GeoIP database from disk.
|
||||
func (h *SecurityHandler) ReloadGeoIP(c *gin.Context) {
|
||||
if !requireAdmin(c) {
|
||||
return
|
||||
}
|
||||
|
||||
if h.geoipSvc == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{
|
||||
"error": "GeoIP service not initialized",
|
||||
@@ -641,6 +665,10 @@ func (h *SecurityHandler) ReloadGeoIP(c *gin.Context) {
|
||||
|
||||
// LookupGeoIP performs a GeoIP lookup for a given IP address.
|
||||
func (h *SecurityHandler) LookupGeoIP(c *gin.Context) {
|
||||
if !requireAdmin(c) {
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
IPAddress string `json:"ip_address" binding:"required"`
|
||||
}
|
||||
@@ -707,6 +735,10 @@ func (h *SecurityHandler) GetWAFExclusions(c *gin.Context) {
|
||||
|
||||
// AddWAFExclusion adds a rule exclusion to the WAF configuration
|
||||
func (h *SecurityHandler) AddWAFExclusion(c *gin.Context) {
|
||||
if !requireAdmin(c) {
|
||||
return
|
||||
}
|
||||
|
||||
var req WAFExclusionRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "rule_id is required"})
|
||||
@@ -786,6 +818,10 @@ func (h *SecurityHandler) AddWAFExclusion(c *gin.Context) {
|
||||
|
||||
// DeleteWAFExclusion removes a rule exclusion by rule_id
|
||||
func (h *SecurityHandler) DeleteWAFExclusion(c *gin.Context) {
|
||||
if !requireAdmin(c) {
|
||||
return
|
||||
}
|
||||
|
||||
ruleIDParam := c.Param("rule_id")
|
||||
if ruleIDParam == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "rule_id is required"})
|
||||
|
||||
@@ -100,6 +100,10 @@ func TestSecurityHandler_CreateDecision_SQLInjection(t *testing.T) {
|
||||
h := NewSecurityHandler(cfg, db, nil)
|
||||
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
router.POST("/api/v1/security/decisions", h.CreateDecision)
|
||||
|
||||
// Attempt SQL injection via payload fields
|
||||
@@ -143,6 +147,10 @@ func TestSecurityHandler_UpsertRuleSet_MassivePayload(t *testing.T) {
|
||||
h := NewSecurityHandler(cfg, db, nil)
|
||||
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
router.POST("/api/v1/security/rulesets", h.UpsertRuleSet)
|
||||
|
||||
// Try to submit a 3MB payload (should be rejected by service)
|
||||
@@ -175,6 +183,10 @@ func TestSecurityHandler_UpsertRuleSet_EmptyName(t *testing.T) {
|
||||
h := NewSecurityHandler(cfg, db, nil)
|
||||
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
router.POST("/api/v1/security/rulesets", h.UpsertRuleSet)
|
||||
|
||||
payload := map[string]any{
|
||||
@@ -203,6 +215,10 @@ func TestSecurityHandler_CreateDecision_EmptyFields(t *testing.T) {
|
||||
h := NewSecurityHandler(cfg, db, nil)
|
||||
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
router.POST("/api/v1/security/decisions", h.CreateDecision)
|
||||
|
||||
testCases := []struct {
|
||||
@@ -347,6 +363,10 @@ func TestSecurityAudit_DeleteRuleSet_InvalidID(t *testing.T) {
|
||||
h := NewSecurityHandler(cfg, db, nil)
|
||||
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
router.DELETE("/api/v1/security/rulesets/:id", h.DeleteRuleSet)
|
||||
|
||||
testCases := []struct {
|
||||
@@ -388,6 +408,10 @@ func TestSecurityHandler_UpsertRuleSet_XSSInContent(t *testing.T) {
|
||||
h := NewSecurityHandler(cfg, db, nil)
|
||||
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
router.POST("/api/v1/security/rulesets", h.UpsertRuleSet)
|
||||
router.GET("/api/v1/security/rulesets", h.ListRuleSets)
|
||||
|
||||
@@ -433,6 +457,10 @@ func TestSecurityHandler_UpdateConfig_RateLimitBounds(t *testing.T) {
|
||||
h := NewSecurityHandler(cfg, db, nil)
|
||||
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
router.PUT("/api/v1/security/config", h.UpdateConfig)
|
||||
|
||||
testCases := []struct {
|
||||
|
||||
58
backend/internal/api/handlers/security_handler_authz_test.go
Normal file
58
backend/internal/api/handlers/security_handler_authz_test.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/Wikid82/charon/backend/internal/config"
|
||||
"github.com/Wikid82/charon/backend/internal/models"
|
||||
)
|
||||
|
||||
func TestSecurityHandler_MutatorsRequireAdmin(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupTestDB(t)
|
||||
require.NoError(t, db.AutoMigrate(&models.SecurityConfig{}, &models.SecurityRuleSet{}, &models.SecurityDecision{}, &models.SecurityAudit{}))
|
||||
|
||||
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("userID", uint(123))
|
||||
c.Set("role", "user")
|
||||
c.Next()
|
||||
})
|
||||
|
||||
router.POST("/security/config", handler.UpdateConfig)
|
||||
router.POST("/security/breakglass/generate", handler.GenerateBreakGlass)
|
||||
router.POST("/security/decisions", handler.CreateDecision)
|
||||
router.POST("/security/rulesets", handler.UpsertRuleSet)
|
||||
router.DELETE("/security/rulesets/:id", handler.DeleteRuleSet)
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
method string
|
||||
url string
|
||||
body string
|
||||
}{
|
||||
{name: "update-config", method: http.MethodPost, url: "/security/config", body: `{"name":"default"}`},
|
||||
{name: "generate-breakglass", method: http.MethodPost, url: "/security/breakglass/generate", body: `{}`},
|
||||
{name: "create-decision", method: http.MethodPost, url: "/security/decisions", body: `{"ip":"1.2.3.4","action":"block"}`},
|
||||
{name: "upsert-ruleset", method: http.MethodPost, url: "/security/rulesets", body: `{"name":"owasp-crs","mode":"block","content":"x"}`},
|
||||
{name: "delete-ruleset", method: http.MethodDelete, url: "/security/rulesets/1", body: ""},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(tc.method, tc.url, bytes.NewBufferString(tc.body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusForbidden, w.Code)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -120,6 +120,10 @@ func TestSecurityHandler_GenerateBreakGlass_ReturnsToken(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
router.POST("/security/breakglass/generate", handler.GenerateBreakGlass)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
@@ -251,6 +255,10 @@ func TestSecurityHandler_Enable_Disable_WithAdminWhitelistAndToken(t *testing.T)
|
||||
|
||||
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
api := router.Group("/api/v1")
|
||||
api.POST("/security/enable", handler.Enable)
|
||||
api.POST("/security/disable", handler.Disable)
|
||||
|
||||
@@ -27,6 +27,10 @@ func TestSecurityHandler_UpdateConfig_Success(t *testing.T) {
|
||||
|
||||
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
router.POST("/security/config", handler.UpdateConfig)
|
||||
|
||||
payload := map[string]any{
|
||||
@@ -55,6 +59,10 @@ func TestSecurityHandler_UpdateConfig_DefaultName(t *testing.T) {
|
||||
|
||||
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
router.POST("/security/config", handler.UpdateConfig)
|
||||
|
||||
// Payload without name - should default to "default"
|
||||
@@ -78,6 +86,10 @@ func TestSecurityHandler_UpdateConfig_InvalidPayload(t *testing.T) {
|
||||
|
||||
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
router.POST("/security/config", handler.UpdateConfig)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
@@ -193,6 +205,10 @@ func TestSecurityHandler_CreateDecision_Success(t *testing.T) {
|
||||
|
||||
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
router.POST("/security/decisions", handler.CreateDecision)
|
||||
|
||||
payload := map[string]any{
|
||||
@@ -218,6 +234,10 @@ func TestSecurityHandler_CreateDecision_MissingIP(t *testing.T) {
|
||||
|
||||
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
router.POST("/security/decisions", handler.CreateDecision)
|
||||
|
||||
payload := map[string]any{
|
||||
@@ -240,6 +260,10 @@ func TestSecurityHandler_CreateDecision_MissingAction(t *testing.T) {
|
||||
|
||||
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
router.POST("/security/decisions", handler.CreateDecision)
|
||||
|
||||
payload := map[string]any{
|
||||
@@ -262,6 +286,10 @@ func TestSecurityHandler_CreateDecision_InvalidPayload(t *testing.T) {
|
||||
|
||||
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
router.POST("/security/decisions", handler.CreateDecision)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
@@ -306,6 +334,10 @@ func TestSecurityHandler_UpsertRuleSet_Success(t *testing.T) {
|
||||
|
||||
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
router.POST("/security/rulesets", handler.UpsertRuleSet)
|
||||
|
||||
payload := map[string]any{
|
||||
@@ -330,6 +362,10 @@ func TestSecurityHandler_UpsertRuleSet_MissingName(t *testing.T) {
|
||||
|
||||
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
router.POST("/security/rulesets", handler.UpsertRuleSet)
|
||||
|
||||
payload := map[string]any{
|
||||
@@ -353,6 +389,10 @@ func TestSecurityHandler_UpsertRuleSet_InvalidPayload(t *testing.T) {
|
||||
|
||||
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
router.POST("/security/rulesets", handler.UpsertRuleSet)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
@@ -375,6 +415,10 @@ func TestSecurityHandler_DeleteRuleSet_Success(t *testing.T) {
|
||||
|
||||
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
router.DELETE("/security/rulesets/:id", handler.DeleteRuleSet)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
@@ -395,6 +439,10 @@ func TestSecurityHandler_DeleteRuleSet_NotFound(t *testing.T) {
|
||||
|
||||
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
router.DELETE("/security/rulesets/:id", handler.DeleteRuleSet)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
@@ -411,6 +459,10 @@ func TestSecurityHandler_DeleteRuleSet_InvalidID(t *testing.T) {
|
||||
|
||||
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
router.DELETE("/security/rulesets/:id", handler.DeleteRuleSet)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
@@ -427,6 +479,10 @@ func TestSecurityHandler_DeleteRuleSet_EmptyID(t *testing.T) {
|
||||
|
||||
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
// Note: This route pattern won't match empty ID, but testing the handler directly
|
||||
router.DELETE("/security/rulesets/:id", handler.DeleteRuleSet)
|
||||
|
||||
@@ -509,6 +565,10 @@ func TestSecurityHandler_Enable_WithValidBreakGlassToken(t *testing.T) {
|
||||
|
||||
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
router.POST("/security/breakglass/generate", handler.GenerateBreakGlass)
|
||||
router.POST("/security/enable", handler.Enable)
|
||||
|
||||
@@ -600,6 +660,10 @@ func TestSecurityHandler_Disable_FromRemoteWithToken(t *testing.T) {
|
||||
|
||||
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
router.POST("/security/breakglass/generate", handler.GenerateBreakGlass)
|
||||
router.POST("/security/disable", func(c *gin.Context) {
|
||||
c.Request.RemoteAddr = "192.168.1.100:12345" // Remote IP
|
||||
@@ -689,6 +753,10 @@ func TestSecurityHandler_GenerateBreakGlass_NoConfig(t *testing.T) {
|
||||
|
||||
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
router.POST("/security/breakglass/generate", handler.GenerateBreakGlass)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
@@ -30,6 +30,10 @@ func setupSecurityTestRouterWithExtras(t *testing.T) (*gin.Engine, *gorm.DB) {
|
||||
require.NoError(t, db.AutoMigrate(&models.ProxyHost{}, &models.Location{}, &models.Setting{}, &models.CaddyConfig{}, &models.SSLCertificate{}, &models.AccessList{}, &models.SecurityConfig{}, &models.SecurityDecision{}, &models.SecurityAudit{}, &models.SecurityRuleSet{}))
|
||||
|
||||
r := gin.New()
|
||||
r.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
api := r.Group("/api/v1")
|
||||
cfg := config.SecurityConfig{}
|
||||
h := NewSecurityHandler(cfg, db, nil)
|
||||
@@ -148,6 +152,10 @@ func TestSecurityHandler_UpsertDeleteTriggersApplyConfig(t *testing.T) {
|
||||
m := caddy.NewManager(client, db, tmp, "", false, config.SecurityConfig{CerberusEnabled: true, WAFMode: "block"})
|
||||
|
||||
r := gin.New()
|
||||
r.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
api := r.Group("/api/v1")
|
||||
cfg := config.SecurityConfig{}
|
||||
h := NewSecurityHandler(cfg, db, m)
|
||||
|
||||
@@ -110,6 +110,10 @@ func TestSecurityHandler_AddWAFExclusion_Success(t *testing.T) {
|
||||
|
||||
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
router.POST("/security/waf/exclusions", handler.AddWAFExclusion)
|
||||
|
||||
payload := map[string]any{
|
||||
@@ -140,6 +144,10 @@ func TestSecurityHandler_AddWAFExclusion_WithTarget(t *testing.T) {
|
||||
|
||||
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
router.POST("/security/waf/exclusions", handler.AddWAFExclusion)
|
||||
|
||||
payload := map[string]any{
|
||||
@@ -175,6 +183,10 @@ func TestSecurityHandler_AddWAFExclusion_ToExistingConfig(t *testing.T) {
|
||||
|
||||
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
router.POST("/security/waf/exclusions", handler.AddWAFExclusion)
|
||||
router.GET("/security/waf/exclusions", handler.GetWAFExclusions)
|
||||
|
||||
@@ -215,6 +227,10 @@ func TestSecurityHandler_AddWAFExclusion_Duplicate(t *testing.T) {
|
||||
|
||||
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
router.POST("/security/waf/exclusions", handler.AddWAFExclusion)
|
||||
|
||||
// Try to add duplicate
|
||||
@@ -244,6 +260,10 @@ func TestSecurityHandler_AddWAFExclusion_DuplicateWithDifferentTarget(t *testing
|
||||
|
||||
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
router.POST("/security/waf/exclusions", handler.AddWAFExclusion)
|
||||
|
||||
// Add same rule_id with different target - should succeed
|
||||
@@ -268,6 +288,10 @@ func TestSecurityHandler_AddWAFExclusion_MissingRuleID(t *testing.T) {
|
||||
|
||||
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
router.POST("/security/waf/exclusions", handler.AddWAFExclusion)
|
||||
|
||||
payload := map[string]any{
|
||||
@@ -290,6 +314,10 @@ func TestSecurityHandler_AddWAFExclusion_InvalidRuleID(t *testing.T) {
|
||||
|
||||
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
router.POST("/security/waf/exclusions", handler.AddWAFExclusion)
|
||||
|
||||
// Zero rule_id
|
||||
@@ -313,6 +341,10 @@ func TestSecurityHandler_AddWAFExclusion_NegativeRuleID(t *testing.T) {
|
||||
|
||||
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
router.POST("/security/waf/exclusions", handler.AddWAFExclusion)
|
||||
|
||||
payload := map[string]any{
|
||||
@@ -335,6 +367,10 @@ func TestSecurityHandler_AddWAFExclusion_InvalidPayload(t *testing.T) {
|
||||
|
||||
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
router.POST("/security/waf/exclusions", handler.AddWAFExclusion)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
@@ -358,6 +394,10 @@ func TestSecurityHandler_DeleteWAFExclusion_Success(t *testing.T) {
|
||||
|
||||
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
router.DELETE("/security/waf/exclusions/:rule_id", handler.DeleteWAFExclusion)
|
||||
router.GET("/security/waf/exclusions", handler.GetWAFExclusions)
|
||||
|
||||
@@ -394,6 +434,10 @@ func TestSecurityHandler_DeleteWAFExclusion_WithTarget(t *testing.T) {
|
||||
|
||||
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
router.DELETE("/security/waf/exclusions/:rule_id", handler.DeleteWAFExclusion)
|
||||
router.GET("/security/waf/exclusions", handler.GetWAFExclusions)
|
||||
|
||||
@@ -430,6 +474,10 @@ func TestSecurityHandler_DeleteWAFExclusion_NotFound(t *testing.T) {
|
||||
|
||||
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
router.DELETE("/security/waf/exclusions/:rule_id", handler.DeleteWAFExclusion)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
@@ -446,6 +494,10 @@ func TestSecurityHandler_DeleteWAFExclusion_NoConfig(t *testing.T) {
|
||||
|
||||
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
router.DELETE("/security/waf/exclusions/:rule_id", handler.DeleteWAFExclusion)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
@@ -462,6 +514,10 @@ func TestSecurityHandler_DeleteWAFExclusion_InvalidRuleID(t *testing.T) {
|
||||
|
||||
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
router.DELETE("/security/waf/exclusions/:rule_id", handler.DeleteWAFExclusion)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
@@ -478,6 +534,10 @@ func TestSecurityHandler_DeleteWAFExclusion_ZeroRuleID(t *testing.T) {
|
||||
|
||||
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
router.DELETE("/security/waf/exclusions/:rule_id", handler.DeleteWAFExclusion)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
@@ -494,6 +554,10 @@ func TestSecurityHandler_DeleteWAFExclusion_NegativeRuleID(t *testing.T) {
|
||||
|
||||
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
router.DELETE("/security/waf/exclusions/:rule_id", handler.DeleteWAFExclusion)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
@@ -533,6 +597,10 @@ func TestSecurityHandler_WAFExclusion_FullWorkflow(t *testing.T) {
|
||||
|
||||
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
router.GET("/security/waf/exclusions", handler.GetWAFExclusions)
|
||||
router.POST("/security/waf/exclusions", handler.AddWAFExclusion)
|
||||
router.DELETE("/security/waf/exclusions/:rule_id", handler.DeleteWAFExclusion)
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -37,6 +38,15 @@ type SettingsHandler struct {
|
||||
DataRoot string
|
||||
}
|
||||
|
||||
const (
|
||||
settingCaddyKeepaliveIdle = "caddy.keepalive_idle"
|
||||
settingCaddyKeepaliveCount = "caddy.keepalive_count"
|
||||
minCaddyKeepaliveIdleDuration = time.Second
|
||||
maxCaddyKeepaliveIdleDuration = 24 * time.Hour
|
||||
minCaddyKeepaliveCount = 1
|
||||
maxCaddyKeepaliveCount = 100
|
||||
)
|
||||
|
||||
func NewSettingsHandler(db *gorm.DB) *SettingsHandler {
|
||||
return &SettingsHandler{
|
||||
DB: db,
|
||||
@@ -65,14 +75,43 @@ func (h *SettingsHandler) GetSettings(c *gin.Context) {
|
||||
}
|
||||
|
||||
// Convert to map for easier frontend consumption
|
||||
settingsMap := make(map[string]string)
|
||||
settingsMap := make(map[string]any)
|
||||
for _, s := range settings {
|
||||
if isSensitiveSettingKey(s.Key) {
|
||||
hasSecret := strings.TrimSpace(s.Value) != ""
|
||||
settingsMap[s.Key] = "********"
|
||||
settingsMap[s.Key+".has_secret"] = hasSecret
|
||||
settingsMap[s.Key+".last_updated"] = s.UpdatedAt.UTC().Format(time.RFC3339)
|
||||
continue
|
||||
}
|
||||
|
||||
settingsMap[s.Key] = s.Value
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, settingsMap)
|
||||
}
|
||||
|
||||
func isSensitiveSettingKey(key string) bool {
|
||||
normalizedKey := strings.ToLower(strings.TrimSpace(key))
|
||||
|
||||
sensitiveFragments := []string{
|
||||
"password",
|
||||
"secret",
|
||||
"token",
|
||||
"api_key",
|
||||
"apikey",
|
||||
"webhook",
|
||||
}
|
||||
|
||||
for _, fragment := range sensitiveFragments {
|
||||
if strings.Contains(normalizedKey, fragment) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
type UpdateSettingRequest struct {
|
||||
Key string `json:"key" binding:"required"`
|
||||
Value string `json:"value" binding:"required"`
|
||||
@@ -109,6 +148,11 @@ func (h *SettingsHandler) UpdateSetting(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
if err := validateOptionalKeepaliveSetting(req.Key, req.Value); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
setting := models.Setting{
|
||||
Key: req.Key,
|
||||
Value: req.Value,
|
||||
@@ -247,6 +291,10 @@ func (h *SettingsHandler) PatchConfig(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
if err := validateOptionalKeepaliveSetting(key, value); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
setting := models.Setting{
|
||||
Key: key,
|
||||
Value: value,
|
||||
@@ -284,6 +332,10 @@ func (h *SettingsHandler) PatchConfig(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid admin_whitelist"})
|
||||
return
|
||||
}
|
||||
if strings.Contains(err.Error(), "invalid caddy.keepalive_idle") || strings.Contains(err.Error(), "invalid caddy.keepalive_count") {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if respondPermissionError(c, h.SecuritySvc, "settings_save_failed", err, h.DataRoot) {
|
||||
return
|
||||
}
|
||||
@@ -401,6 +453,53 @@ func validateAdminWhitelist(whitelist string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateOptionalKeepaliveSetting(key, value string) error {
|
||||
switch key {
|
||||
case settingCaddyKeepaliveIdle:
|
||||
return validateKeepaliveIdleValue(value)
|
||||
case settingCaddyKeepaliveCount:
|
||||
return validateKeepaliveCountValue(value)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func validateKeepaliveIdleValue(value string) error {
|
||||
idle := strings.TrimSpace(value)
|
||||
if idle == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
d, err := time.ParseDuration(idle)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid caddy.keepalive_idle")
|
||||
}
|
||||
|
||||
if d < minCaddyKeepaliveIdleDuration || d > maxCaddyKeepaliveIdleDuration {
|
||||
return fmt.Errorf("invalid caddy.keepalive_idle")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateKeepaliveCountValue(value string) error {
|
||||
raw := strings.TrimSpace(value)
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
count, err := strconv.Atoi(raw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid caddy.keepalive_count")
|
||||
}
|
||||
|
||||
if count < minCaddyKeepaliveCount || count > maxCaddyKeepaliveCount {
|
||||
return fmt.Errorf("invalid caddy.keepalive_count")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *SettingsHandler) syncAdminWhitelist(whitelist string) error {
|
||||
return h.syncAdminWhitelistWithDB(h.DB, whitelist)
|
||||
}
|
||||
@@ -433,6 +532,10 @@ type SMTPConfigRequest struct {
|
||||
|
||||
// GetSMTPConfig returns the current SMTP configuration.
|
||||
func (h *SettingsHandler) GetSMTPConfig(c *gin.Context) {
|
||||
if !requireAdmin(c) {
|
||||
return
|
||||
}
|
||||
|
||||
config, err := h.MailService.GetSMTPConfig()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch SMTP configuration"})
|
||||
|
||||
@@ -182,6 +182,31 @@ func TestSettingsHandler_GetSettings(t *testing.T) {
|
||||
assert.Equal(t, "test_value", response["test_key"])
|
||||
}
|
||||
|
||||
func TestSettingsHandler_GetSettings_MasksSensitiveValues(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupSettingsTestDB(t)
|
||||
|
||||
db.Create(&models.Setting{Key: "smtp_password", Value: "super-secret-password", Category: "smtp", Type: "string"})
|
||||
|
||||
handler := handlers.NewSettingsHandler(db)
|
||||
router := newAdminRouter()
|
||||
router.GET("/settings", handler.GetSettings)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("GET", "/settings", http.NoBody)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var response map[string]any
|
||||
err := json.Unmarshal(w.Body.Bytes(), &response)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "********", response["smtp_password"])
|
||||
assert.Equal(t, true, response["smtp_password.has_secret"])
|
||||
_, hasRaw := response["super-secret-password"]
|
||||
assert.False(t, hasRaw)
|
||||
}
|
||||
|
||||
func TestSettingsHandler_GetSettings_DatabaseError(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupSettingsTestDB(t)
|
||||
@@ -413,6 +438,58 @@ func TestSettingsHandler_UpdateSetting_InvalidAdminWhitelist(t *testing.T) {
|
||||
assert.Contains(t, w.Body.String(), "Invalid admin_whitelist")
|
||||
}
|
||||
|
||||
func TestSettingsHandler_UpdateSetting_InvalidKeepaliveIdle(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupSettingsTestDB(t)
|
||||
|
||||
handler := handlers.NewSettingsHandler(db)
|
||||
router := newAdminRouter()
|
||||
router.POST("/settings", handler.UpdateSetting)
|
||||
|
||||
payload := map[string]string{
|
||||
"key": "caddy.keepalive_idle",
|
||||
"value": "bad-duration",
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("POST", "/settings", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "invalid caddy.keepalive_idle")
|
||||
}
|
||||
|
||||
func TestSettingsHandler_UpdateSetting_ValidKeepaliveCount(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupSettingsTestDB(t)
|
||||
|
||||
handler := handlers.NewSettingsHandler(db)
|
||||
router := newAdminRouter()
|
||||
router.POST("/settings", handler.UpdateSetting)
|
||||
|
||||
payload := map[string]string{
|
||||
"key": "caddy.keepalive_count",
|
||||
"value": "9",
|
||||
"category": "caddy",
|
||||
"type": "number",
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("POST", "/settings", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var setting models.Setting
|
||||
err := db.Where("key = ?", "caddy.keepalive_count").First(&setting).Error
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "9", setting.Value)
|
||||
}
|
||||
|
||||
func TestSettingsHandler_UpdateSetting_SecurityKeyInvalidatesCache(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupSettingsTestDB(t)
|
||||
@@ -538,6 +615,64 @@ func TestSettingsHandler_PatchConfig_InvalidAdminWhitelist(t *testing.T) {
|
||||
assert.Contains(t, w.Body.String(), "Invalid admin_whitelist")
|
||||
}
|
||||
|
||||
func TestSettingsHandler_PatchConfig_InvalidKeepaliveCount(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupSettingsTestDB(t)
|
||||
|
||||
handler := handlers.NewSettingsHandler(db)
|
||||
router := newAdminRouter()
|
||||
router.PATCH("/config", handler.PatchConfig)
|
||||
|
||||
payload := map[string]any{
|
||||
"caddy": map[string]any{
|
||||
"keepalive_count": 0,
|
||||
},
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest(http.MethodPatch, "/config", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "invalid caddy.keepalive_count")
|
||||
}
|
||||
|
||||
func TestSettingsHandler_PatchConfig_ValidKeepaliveSettings(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupSettingsTestDB(t)
|
||||
|
||||
handler := handlers.NewSettingsHandler(db)
|
||||
router := newAdminRouter()
|
||||
router.PATCH("/config", handler.PatchConfig)
|
||||
|
||||
payload := map[string]any{
|
||||
"caddy": map[string]any{
|
||||
"keepalive_idle": "30s",
|
||||
"keepalive_count": 12,
|
||||
},
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest(http.MethodPatch, "/config", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var idle models.Setting
|
||||
err := db.Where("key = ?", "caddy.keepalive_idle").First(&idle).Error
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "30s", idle.Value)
|
||||
|
||||
var count models.Setting
|
||||
err = db.Where("key = ?", "caddy.keepalive_count").First(&count).Error
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "12", count.Value)
|
||||
}
|
||||
|
||||
func TestSettingsHandler_PatchConfig_ReloadFailureReturns500(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupSettingsTestDB(t)
|
||||
@@ -864,6 +999,25 @@ func TestSettingsHandler_GetSMTPConfig_DatabaseError(t *testing.T) {
|
||||
assert.Equal(t, http.StatusInternalServerError, w.Code)
|
||||
}
|
||||
|
||||
func TestSettingsHandler_GetSMTPConfig_NonAdminForbidden(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
handler, _ := setupSettingsHandlerWithMail(t)
|
||||
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("role", "user")
|
||||
c.Set("userID", uint(2))
|
||||
c.Next()
|
||||
})
|
||||
router.GET("/api/v1/settings/smtp", handler.GetSMTPConfig)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("GET", "/api/v1/settings/smtp", http.NoBody)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusForbidden, w.Code)
|
||||
}
|
||||
|
||||
func TestSettingsHandler_UpdateSMTPConfig_NonAdmin(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
handler, _ := setupSettingsHandlerWithMail(t)
|
||||
|
||||
@@ -103,6 +103,18 @@ type SetupRequest struct {
|
||||
Password string `json:"password" binding:"required,min=8"`
|
||||
}
|
||||
|
||||
func isSetupConflictError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
errText := strings.ToLower(err.Error())
|
||||
return strings.Contains(errText, "unique constraint failed") ||
|
||||
strings.Contains(errText, "duplicate key") ||
|
||||
strings.Contains(errText, "database is locked") ||
|
||||
strings.Contains(errText, "database table is locked")
|
||||
}
|
||||
|
||||
// Setup creates the initial admin user and configures the ACME email.
|
||||
func (h *UserHandler) Setup(c *gin.Context) {
|
||||
// 1. Check if setup is allowed
|
||||
@@ -160,6 +172,17 @@ func (h *UserHandler) Setup(c *gin.Context) {
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
var postTxCount int64
|
||||
if countErr := h.DB.Model(&models.User{}).Count(&postTxCount).Error; countErr == nil && postTxCount > 0 {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Setup already completed"})
|
||||
return
|
||||
}
|
||||
|
||||
if isSetupConflictError(err) {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "Setup conflict: setup already in progress or completed"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to complete setup: " + err.Error()})
|
||||
return
|
||||
}
|
||||
@@ -189,7 +212,12 @@ func (h *UserHandler) RegenerateAPIKey(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"api_key": apiKey})
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "API key regenerated successfully",
|
||||
"has_api_key": true,
|
||||
"api_key_masked": maskSecretForResponse(apiKey),
|
||||
"api_key_updated": time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
// GetProfile returns the current user's profile including API key.
|
||||
@@ -207,11 +235,12 @@ func (h *UserHandler) GetProfile(c *gin.Context) {
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"id": user.ID,
|
||||
"email": user.Email,
|
||||
"name": user.Name,
|
||||
"role": user.Role,
|
||||
"api_key": user.APIKey,
|
||||
"id": user.ID,
|
||||
"email": user.Email,
|
||||
"name": user.Name,
|
||||
"role": user.Role,
|
||||
"has_api_key": strings.TrimSpace(user.APIKey) != "",
|
||||
"api_key_masked": maskSecretForResponse(user.APIKey),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -548,14 +577,14 @@ func (h *UserHandler) InviteUser(c *gin.Context) {
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"id": user.ID,
|
||||
"uuid": user.UUID,
|
||||
"email": user.Email,
|
||||
"role": user.Role,
|
||||
"invite_token": inviteToken, // Return token in case email fails
|
||||
"invite_url": inviteURL,
|
||||
"email_sent": emailSent,
|
||||
"expires_at": inviteExpires,
|
||||
"id": user.ID,
|
||||
"uuid": user.UUID,
|
||||
"email": user.Email,
|
||||
"role": user.Role,
|
||||
"invite_token_masked": maskSecretForResponse(inviteToken),
|
||||
"invite_url": redactInviteURL(inviteURL),
|
||||
"email_sent": emailSent,
|
||||
"expires_at": inviteExpires,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -862,16 +891,32 @@ func (h *UserHandler) ResendInvite(c *gin.Context) {
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"id": user.ID,
|
||||
"uuid": user.UUID,
|
||||
"email": user.Email,
|
||||
"role": user.Role,
|
||||
"invite_token": inviteToken,
|
||||
"email_sent": emailSent,
|
||||
"expires_at": inviteExpires,
|
||||
"id": user.ID,
|
||||
"uuid": user.UUID,
|
||||
"email": user.Email,
|
||||
"role": user.Role,
|
||||
"invite_token_masked": maskSecretForResponse(inviteToken),
|
||||
"email_sent": emailSent,
|
||||
"expires_at": inviteExpires,
|
||||
})
|
||||
}
|
||||
|
||||
func maskSecretForResponse(value string) string {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
return "********"
|
||||
}
|
||||
|
||||
func redactInviteURL(inviteURL string) string {
|
||||
if strings.TrimSpace(inviteURL) == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
return "[REDACTED]"
|
||||
}
|
||||
|
||||
// UpdateUserPermissions updates a user's permission mode and host exceptions (admin only).
|
||||
func (h *UserHandler) UpdateUserPermissions(c *gin.Context) {
|
||||
role, _ := c.Get("role")
|
||||
|
||||
@@ -3,9 +3,11 @@ package handlers
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -15,15 +17,11 @@ import (
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func setupUserHandler(t *testing.T) (*UserHandler, *gorm.DB) {
|
||||
// Use unique DB for each test to avoid pollution
|
||||
dbName := "file:" + t.Name() + "?mode=memory&cache=shared"
|
||||
db, err := gorm.Open(sqlite.Open(dbName), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
db := OpenTestDB(t)
|
||||
_ = db.AutoMigrate(&models.User{}, &models.Setting{}, &models.SecurityAudit{})
|
||||
return NewUserHandler(db), db
|
||||
}
|
||||
@@ -131,6 +129,224 @@ func TestUserHandler_Setup(t *testing.T) {
|
||||
assert.Equal(t, http.StatusForbidden, w.Code)
|
||||
}
|
||||
|
||||
func TestUserHandler_Setup_OneWayInvariant_ReentryRejectedAndSingleUser(t *testing.T) {
|
||||
handler, db := setupUserHandler(t)
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.POST("/setup", handler.Setup)
|
||||
|
||||
initialBody := map[string]string{
|
||||
"name": "Admin",
|
||||
"email": "admin@example.com",
|
||||
"password": "password123",
|
||||
}
|
||||
initialJSON, _ := json.Marshal(initialBody)
|
||||
|
||||
firstReq := httptest.NewRequest(http.MethodPost, "/setup", bytes.NewBuffer(initialJSON))
|
||||
firstReq.Header.Set("Content-Type", "application/json")
|
||||
firstResp := httptest.NewRecorder()
|
||||
r.ServeHTTP(firstResp, firstReq)
|
||||
require.Equal(t, http.StatusCreated, firstResp.Code)
|
||||
|
||||
secondBody := map[string]string{
|
||||
"name": "Different Admin",
|
||||
"email": "different@example.com",
|
||||
"password": "password123",
|
||||
}
|
||||
secondJSON, _ := json.Marshal(secondBody)
|
||||
secondReq := httptest.NewRequest(http.MethodPost, "/setup", bytes.NewBuffer(secondJSON))
|
||||
secondReq.Header.Set("Content-Type", "application/json")
|
||||
secondResp := httptest.NewRecorder()
|
||||
r.ServeHTTP(secondResp, secondReq)
|
||||
|
||||
require.Equal(t, http.StatusForbidden, secondResp.Code)
|
||||
|
||||
var userCount int64
|
||||
require.NoError(t, db.Model(&models.User{}).Count(&userCount).Error)
|
||||
assert.Equal(t, int64(1), userCount)
|
||||
}
|
||||
|
||||
func TestUserHandler_Setup_ConcurrentAttemptInvariant(t *testing.T) {
|
||||
handler, db := setupUserHandler(t)
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.POST("/setup", handler.Setup)
|
||||
|
||||
concurrency := 6
|
||||
start := make(chan struct{})
|
||||
statuses := make(chan int, concurrency)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < concurrency; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
|
||||
body := map[string]string{
|
||||
"name": "Admin",
|
||||
"email": "admin@example.com",
|
||||
"password": "password123",
|
||||
}
|
||||
jsonBody, _ := json.Marshal(body)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/setup", bytes.NewBuffer(jsonBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp := httptest.NewRecorder()
|
||||
r.ServeHTTP(resp, req)
|
||||
statuses <- resp.Code
|
||||
}()
|
||||
}
|
||||
|
||||
close(start)
|
||||
wg.Wait()
|
||||
close(statuses)
|
||||
|
||||
createdCount := 0
|
||||
forbiddenOrConflictCount := 0
|
||||
for status := range statuses {
|
||||
if status == http.StatusCreated {
|
||||
createdCount++
|
||||
continue
|
||||
}
|
||||
|
||||
if status == http.StatusForbidden || status == http.StatusConflict {
|
||||
forbiddenOrConflictCount++
|
||||
continue
|
||||
}
|
||||
|
||||
t.Fatalf("unexpected setup concurrency status: %d", status)
|
||||
}
|
||||
|
||||
assert.Equal(t, 1, createdCount)
|
||||
assert.Equal(t, concurrency-1, forbiddenOrConflictCount)
|
||||
|
||||
var userCount int64
|
||||
require.NoError(t, db.Model(&models.User{}).Count(&userCount).Error)
|
||||
assert.Equal(t, int64(1), userCount)
|
||||
}
|
||||
|
||||
func TestUserHandler_Setup_ResponseSecretEchoContract(t *testing.T) {
|
||||
handler, _ := setupUserHandler(t)
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.POST("/setup", handler.Setup)
|
||||
|
||||
body := map[string]string{
|
||||
"name": "Admin",
|
||||
"email": "admin@example.com",
|
||||
"password": "password123",
|
||||
}
|
||||
jsonBody, _ := json.Marshal(body)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/setup", bytes.NewBuffer(jsonBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp := httptest.NewRecorder()
|
||||
r.ServeHTTP(resp, req)
|
||||
require.Equal(t, http.StatusCreated, resp.Code)
|
||||
|
||||
var payload map[string]any
|
||||
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &payload))
|
||||
|
||||
userValue, ok := payload["user"]
|
||||
require.True(t, ok)
|
||||
userMap, ok := userValue.(map[string]any)
|
||||
require.True(t, ok)
|
||||
|
||||
_, hasAPIKey := userMap["api_key"]
|
||||
_, hasPassword := userMap["password"]
|
||||
_, hasPasswordHash := userMap["password_hash"]
|
||||
_, hasInviteToken := userMap["invite_token"]
|
||||
|
||||
assert.False(t, hasAPIKey)
|
||||
assert.False(t, hasPassword)
|
||||
assert.False(t, hasPasswordHash)
|
||||
assert.False(t, hasInviteToken)
|
||||
}
|
||||
|
||||
func TestUserHandler_GetProfile_SecretEchoContract(t *testing.T) {
|
||||
handler, db := setupUserHandler(t)
|
||||
|
||||
user := &models.User{
|
||||
UUID: uuid.NewString(),
|
||||
Email: "profile@example.com",
|
||||
Name: "Profile User",
|
||||
APIKey: "real-secret-api-key",
|
||||
InviteToken: "invite-secret-token",
|
||||
PasswordHash: "hashed-password-value",
|
||||
}
|
||||
require.NoError(t, db.Create(user).Error)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.Use(func(c *gin.Context) {
|
||||
c.Set("userID", user.ID)
|
||||
c.Next()
|
||||
})
|
||||
r.GET("/profile", handler.GetProfile)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/profile", http.NoBody)
|
||||
resp := httptest.NewRecorder()
|
||||
r.ServeHTTP(resp, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, resp.Code)
|
||||
var payload map[string]any
|
||||
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &payload))
|
||||
|
||||
_, hasAPIKey := payload["api_key"]
|
||||
_, hasPassword := payload["password"]
|
||||
_, hasPasswordHash := payload["password_hash"]
|
||||
_, hasInviteToken := payload["invite_token"]
|
||||
|
||||
assert.False(t, hasAPIKey)
|
||||
assert.False(t, hasPassword)
|
||||
assert.False(t, hasPasswordHash)
|
||||
assert.False(t, hasInviteToken)
|
||||
assert.Equal(t, "********", payload["api_key_masked"])
|
||||
}
|
||||
|
||||
func TestUserHandler_ListUsers_SecretEchoContract(t *testing.T) {
|
||||
handler, db := setupUserHandlerWithProxyHosts(t)
|
||||
|
||||
user := &models.User{
|
||||
UUID: uuid.NewString(),
|
||||
Email: "user@example.com",
|
||||
Name: "User",
|
||||
Role: "user",
|
||||
APIKey: "raw-api-key",
|
||||
InviteToken: "raw-invite-token",
|
||||
PasswordHash: "raw-password-hash",
|
||||
}
|
||||
require.NoError(t, db.Create(user).Error)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
r.GET("/users", handler.ListUsers)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/users", http.NoBody)
|
||||
resp := httptest.NewRecorder()
|
||||
r.ServeHTTP(resp, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, resp.Code)
|
||||
var users []map[string]any
|
||||
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &users))
|
||||
require.Len(t, users, 1)
|
||||
|
||||
_, hasAPIKey := users[0]["api_key"]
|
||||
_, hasPassword := users[0]["password"]
|
||||
_, hasPasswordHash := users[0]["password_hash"]
|
||||
_, hasInviteToken := users[0]["invite_token"]
|
||||
|
||||
assert.False(t, hasAPIKey)
|
||||
assert.False(t, hasPassword)
|
||||
assert.False(t, hasPasswordHash)
|
||||
assert.False(t, hasInviteToken)
|
||||
}
|
||||
|
||||
func TestUserHandler_Setup_DBError(t *testing.T) {
|
||||
// Can't easily mock DB error with sqlite memory unless we close it or something.
|
||||
// But we can try to insert duplicate email if we had a unique constraint and pre-seeded data,
|
||||
@@ -162,15 +378,16 @@ func TestUserHandler_RegenerateAPIKey(t *testing.T) {
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
var resp map[string]string
|
||||
var resp map[string]any
|
||||
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
require.NoError(t, err, "Failed to unmarshal response")
|
||||
assert.NotEmpty(t, resp["api_key"])
|
||||
assert.Equal(t, "API key regenerated successfully", resp["message"])
|
||||
assert.Equal(t, "********", resp["api_key_masked"])
|
||||
|
||||
// Verify DB
|
||||
var updatedUser models.User
|
||||
db.First(&updatedUser, user.ID)
|
||||
assert.Equal(t, resp["api_key"], updatedUser.APIKey)
|
||||
assert.NotEmpty(t, updatedUser.APIKey)
|
||||
}
|
||||
|
||||
func TestUserHandler_GetProfile(t *testing.T) {
|
||||
@@ -442,9 +659,7 @@ func TestUserHandler_UpdateProfile_Errors(t *testing.T) {
|
||||
// ============= User Management Tests (Admin functions) =============
|
||||
|
||||
func setupUserHandlerWithProxyHosts(t *testing.T) (*UserHandler, *gorm.DB) {
|
||||
dbName := "file:" + t.Name() + "?mode=memory&cache=shared"
|
||||
db, err := gorm.Open(sqlite.Open(dbName), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
db := OpenTestDB(t)
|
||||
_ = db.AutoMigrate(&models.User{}, &models.Setting{}, &models.ProxyHost{}, &models.SecurityAudit{})
|
||||
return NewUserHandler(db), db
|
||||
}
|
||||
@@ -1376,7 +1591,7 @@ func TestUserHandler_InviteUser_Success(t *testing.T) {
|
||||
var resp map[string]any
|
||||
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
require.NoError(t, err, "Failed to unmarshal response")
|
||||
assert.NotEmpty(t, resp["invite_token"])
|
||||
assert.Equal(t, "********", resp["invite_token_masked"])
|
||||
assert.Equal(t, "", resp["invite_url"])
|
||||
// email_sent is false because no SMTP is configured
|
||||
assert.Equal(t, false, resp["email_sent"].(bool))
|
||||
@@ -1500,7 +1715,7 @@ func TestUserHandler_InviteUser_WithSMTPConfigured(t *testing.T) {
|
||||
var resp map[string]any
|
||||
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
require.NoError(t, err, "Failed to unmarshal response")
|
||||
assert.NotEmpty(t, resp["invite_token"])
|
||||
assert.Equal(t, "********", resp["invite_token_masked"])
|
||||
assert.Equal(t, "", resp["invite_url"])
|
||||
assert.Equal(t, false, resp["email_sent"].(bool))
|
||||
}
|
||||
@@ -1553,8 +1768,8 @@ func TestUserHandler_InviteUser_WithSMTPAndConfiguredPublicURL_IncludesInviteURL
|
||||
var resp map[string]any
|
||||
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
require.NoError(t, err, "Failed to unmarshal response")
|
||||
token := resp["invite_token"].(string)
|
||||
assert.Equal(t, "https://charon.example.com/accept-invite?token="+token, resp["invite_url"])
|
||||
assert.Equal(t, "********", resp["invite_token_masked"])
|
||||
assert.Equal(t, "[REDACTED]", resp["invite_url"])
|
||||
assert.Equal(t, true, resp["email_sent"].(bool))
|
||||
}
|
||||
|
||||
@@ -1606,7 +1821,7 @@ func TestUserHandler_InviteUser_WithSMTPAndMalformedPublicURL_DoesNotExposeInvit
|
||||
var resp map[string]any
|
||||
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
require.NoError(t, err, "Failed to unmarshal response")
|
||||
assert.NotEmpty(t, resp["invite_token"])
|
||||
assert.Equal(t, "********", resp["invite_token_masked"])
|
||||
assert.Equal(t, "", resp["invite_url"])
|
||||
assert.Equal(t, false, resp["email_sent"].(bool))
|
||||
}
|
||||
@@ -1668,7 +1883,7 @@ func TestUserHandler_InviteUser_WithSMTPConfigured_DefaultAppName(t *testing.T)
|
||||
var resp map[string]any
|
||||
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
require.NoError(t, err, "Failed to unmarshal response")
|
||||
assert.NotEmpty(t, resp["invite_token"])
|
||||
assert.Equal(t, "********", resp["invite_token_masked"])
|
||||
}
|
||||
|
||||
// Note: TestGetBaseURL and TestGetAppName have been removed as these internal helper
|
||||
@@ -2372,8 +2587,7 @@ func TestResendInvite_Success(t *testing.T) {
|
||||
var resp map[string]any
|
||||
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
require.NoError(t, err, "Failed to unmarshal response")
|
||||
assert.NotEmpty(t, resp["invite_token"])
|
||||
assert.NotEqual(t, "oldtoken123", resp["invite_token"])
|
||||
assert.Equal(t, "********", resp["invite_token_masked"])
|
||||
assert.Equal(t, "pending-user@example.com", resp["email"])
|
||||
assert.Equal(t, false, resp["email_sent"].(bool)) // No SMTP configured
|
||||
|
||||
@@ -2381,7 +2595,7 @@ func TestResendInvite_Success(t *testing.T) {
|
||||
var updatedUser models.User
|
||||
db.First(&updatedUser, user.ID)
|
||||
assert.NotEqual(t, "oldtoken123", updatedUser.InviteToken)
|
||||
assert.Equal(t, resp["invite_token"], updatedUser.InviteToken)
|
||||
assert.NotEmpty(t, updatedUser.InviteToken)
|
||||
}
|
||||
|
||||
func TestResendInvite_WithExpiredInvite(t *testing.T) {
|
||||
@@ -2419,11 +2633,75 @@ func TestResendInvite_WithExpiredInvite(t *testing.T) {
|
||||
var resp map[string]any
|
||||
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
require.NoError(t, err, "Failed to unmarshal response")
|
||||
assert.NotEmpty(t, resp["invite_token"])
|
||||
assert.NotEqual(t, "expiredtoken", resp["invite_token"])
|
||||
assert.Equal(t, "********", resp["invite_token_masked"])
|
||||
|
||||
// Verify new expiration is in the future
|
||||
var updatedUser models.User
|
||||
db.First(&updatedUser, user.ID)
|
||||
assert.True(t, updatedUser.InviteExpires.After(time.Now()))
|
||||
}
|
||||
|
||||
// ===== Additional coverage for uncovered utility functions =====
|
||||
|
||||
func TestIsSetupConflictError(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
expected bool
|
||||
}{
|
||||
{"nil error", nil, false},
|
||||
{"unique constraint failed", errors.New("UNIQUE constraint failed: users.email"), true},
|
||||
{"duplicate key", errors.New("duplicate key value violates unique constraint"), true},
|
||||
{"database is locked", errors.New("database is locked"), true},
|
||||
{"database table is locked", errors.New("database table is locked"), true},
|
||||
{"case insensitive", errors.New("UNIQUE CONSTRAINT FAILED"), true},
|
||||
{"unrelated error", errors.New("connection refused"), false},
|
||||
{"empty error", errors.New(""), false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := isSetupConflictError(tt.err)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaskSecretForResponse(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{"non-empty secret", "my-secret-key", "********"},
|
||||
{"empty string", "", ""},
|
||||
{"whitespace only", " ", ""},
|
||||
{"single char", "x", "********"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := maskSecretForResponse(tt.input)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedactInviteURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{"non-empty url", "https://example.com/invite/abc123", "[REDACTED]"},
|
||||
{"empty string", "", ""},
|
||||
{"whitespace only", " ", ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := redactInviteURL(tt.input)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,29 @@ import (
|
||||
_ "github.com/Wikid82/charon/backend/pkg/dnsprovider/custom"
|
||||
)
|
||||
|
||||
type uptimeBootstrapService interface {
|
||||
CleanupStaleFailureCounts() error
|
||||
SyncMonitors() error
|
||||
CheckAll()
|
||||
}
|
||||
|
||||
func runInitialUptimeBootstrap(enabled bool, uptimeService uptimeBootstrapService, logWarn func(error, string), logError func(error, string)) {
|
||||
if !enabled {
|
||||
return
|
||||
}
|
||||
|
||||
if err := uptimeService.CleanupStaleFailureCounts(); err != nil && logWarn != nil {
|
||||
logWarn(err, "Failed to cleanup stale failure counts")
|
||||
}
|
||||
|
||||
if err := uptimeService.SyncMonitors(); err != nil && logError != nil {
|
||||
logError(err, "Failed to sync monitors")
|
||||
}
|
||||
|
||||
// Run initial check immediately after sync to avoid the 90s blind window.
|
||||
uptimeService.CheckAll()
|
||||
}
|
||||
|
||||
// Register wires up API routes and performs automatic migrations.
|
||||
func Register(router *gin.Engine, db *gorm.DB, cfg config.Config) error {
|
||||
// Caddy Manager - created early so it can be used by settings handlers for config reload
|
||||
@@ -277,7 +300,7 @@ func RegisterWithDeps(router *gin.Engine, db *gorm.DB, cfg config.Config, caddyM
|
||||
protected.PATCH("/config", settingsHandler.PatchConfig) // Bulk configuration update
|
||||
|
||||
// SMTP Configuration
|
||||
protected.GET("/settings/smtp", settingsHandler.GetSMTPConfig)
|
||||
protected.GET("/settings/smtp", middleware.RequireRole("admin"), settingsHandler.GetSMTPConfig)
|
||||
protected.POST("/settings/smtp", settingsHandler.UpdateSMTPConfig)
|
||||
protected.POST("/settings/smtp/test", settingsHandler.TestSMTPConfig)
|
||||
protected.POST("/settings/smtp/test-email", settingsHandler.SendTestEmail)
|
||||
@@ -410,9 +433,10 @@ func RegisterWithDeps(router *gin.Engine, db *gorm.DB, cfg config.Config, caddyM
|
||||
dockerHandler := handlers.NewDockerHandler(dockerService, remoteServerService)
|
||||
dockerHandler.RegisterRoutes(protected)
|
||||
|
||||
// Uptime Service
|
||||
uptimeSvc := services.NewUptimeService(db, notificationService)
|
||||
uptimeHandler := handlers.NewUptimeHandler(uptimeSvc)
|
||||
// Uptime Service — reuse the single uptimeService instance (defined above)
|
||||
// to share in-memory state (mutexes, notification batching) between
|
||||
// background checker, ProxyHostHandler, and API handlers.
|
||||
uptimeHandler := handlers.NewUptimeHandler(uptimeService)
|
||||
protected.GET("/uptime/monitors", uptimeHandler.List)
|
||||
protected.POST("/uptime/monitors", uptimeHandler.Create)
|
||||
protected.GET("/uptime/monitors/:id/history", uptimeHandler.GetHistory)
|
||||
@@ -463,11 +487,12 @@ func RegisterWithDeps(router *gin.Engine, db *gorm.DB, cfg config.Config, caddyM
|
||||
enabled = s.Value == "true"
|
||||
}
|
||||
|
||||
if enabled {
|
||||
if err := uptimeService.SyncMonitors(); err != nil {
|
||||
logger.Log().WithError(err).Error("Failed to sync monitors")
|
||||
}
|
||||
}
|
||||
runInitialUptimeBootstrap(
|
||||
enabled,
|
||||
uptimeService,
|
||||
func(err error, msg string) { logger.Log().WithError(err).Warn(msg) },
|
||||
func(err error, msg string) { logger.Log().WithError(err).Error(msg) },
|
||||
)
|
||||
|
||||
ticker := time.NewTicker(1 * time.Minute)
|
||||
for range ticker.C {
|
||||
@@ -520,40 +545,43 @@ func RegisterWithDeps(router *gin.Engine, db *gorm.DB, cfg config.Config, caddyM
|
||||
protected.GET("/security/status", securityHandler.GetStatus)
|
||||
// Security Config management
|
||||
protected.GET("/security/config", securityHandler.GetConfig)
|
||||
protected.POST("/security/config", securityHandler.UpdateConfig)
|
||||
protected.POST("/security/enable", securityHandler.Enable)
|
||||
protected.POST("/security/disable", securityHandler.Disable)
|
||||
protected.POST("/security/breakglass/generate", securityHandler.GenerateBreakGlass)
|
||||
protected.GET("/security/decisions", securityHandler.ListDecisions)
|
||||
protected.POST("/security/decisions", securityHandler.CreateDecision)
|
||||
protected.GET("/security/rulesets", securityHandler.ListRuleSets)
|
||||
protected.POST("/security/rulesets", securityHandler.UpsertRuleSet)
|
||||
protected.DELETE("/security/rulesets/:id", securityHandler.DeleteRuleSet)
|
||||
protected.GET("/security/rate-limit/presets", securityHandler.GetRateLimitPresets)
|
||||
// GeoIP endpoints
|
||||
protected.GET("/security/geoip/status", securityHandler.GetGeoIPStatus)
|
||||
protected.POST("/security/geoip/reload", securityHandler.ReloadGeoIP)
|
||||
protected.POST("/security/geoip/lookup", securityHandler.LookupGeoIP)
|
||||
// WAF exclusion endpoints
|
||||
protected.GET("/security/waf/exclusions", securityHandler.GetWAFExclusions)
|
||||
protected.POST("/security/waf/exclusions", securityHandler.AddWAFExclusion)
|
||||
protected.DELETE("/security/waf/exclusions/:rule_id", securityHandler.DeleteWAFExclusion)
|
||||
|
||||
securityAdmin := protected.Group("/security")
|
||||
securityAdmin.Use(middleware.RequireRole("admin"))
|
||||
securityAdmin.POST("/config", securityHandler.UpdateConfig)
|
||||
securityAdmin.POST("/enable", securityHandler.Enable)
|
||||
securityAdmin.POST("/disable", securityHandler.Disable)
|
||||
securityAdmin.POST("/breakglass/generate", securityHandler.GenerateBreakGlass)
|
||||
securityAdmin.POST("/decisions", securityHandler.CreateDecision)
|
||||
securityAdmin.POST("/rulesets", securityHandler.UpsertRuleSet)
|
||||
securityAdmin.DELETE("/rulesets/:id", securityHandler.DeleteRuleSet)
|
||||
securityAdmin.POST("/geoip/reload", securityHandler.ReloadGeoIP)
|
||||
securityAdmin.POST("/geoip/lookup", securityHandler.LookupGeoIP)
|
||||
securityAdmin.POST("/waf/exclusions", securityHandler.AddWAFExclusion)
|
||||
securityAdmin.DELETE("/waf/exclusions/:rule_id", securityHandler.DeleteWAFExclusion)
|
||||
|
||||
// Security module enable/disable endpoints (granular control)
|
||||
protected.POST("/security/acl/enable", securityHandler.EnableACL)
|
||||
protected.POST("/security/acl/disable", securityHandler.DisableACL)
|
||||
protected.PATCH("/security/acl", securityHandler.PatchACL) // E2E tests use PATCH
|
||||
protected.POST("/security/waf/enable", securityHandler.EnableWAF)
|
||||
protected.POST("/security/waf/disable", securityHandler.DisableWAF)
|
||||
protected.PATCH("/security/waf", securityHandler.PatchWAF) // E2E tests use PATCH
|
||||
protected.POST("/security/cerberus/enable", securityHandler.EnableCerberus)
|
||||
protected.POST("/security/cerberus/disable", securityHandler.DisableCerberus)
|
||||
protected.POST("/security/crowdsec/enable", securityHandler.EnableCrowdSec)
|
||||
protected.POST("/security/crowdsec/disable", securityHandler.DisableCrowdSec)
|
||||
protected.PATCH("/security/crowdsec", securityHandler.PatchCrowdSec) // E2E tests use PATCH
|
||||
protected.POST("/security/rate-limit/enable", securityHandler.EnableRateLimit)
|
||||
protected.POST("/security/rate-limit/disable", securityHandler.DisableRateLimit)
|
||||
protected.PATCH("/security/rate-limit", securityHandler.PatchRateLimit) // E2E tests use PATCH
|
||||
securityAdmin.POST("/acl/enable", securityHandler.EnableACL)
|
||||
securityAdmin.POST("/acl/disable", securityHandler.DisableACL)
|
||||
securityAdmin.PATCH("/acl", securityHandler.PatchACL) // E2E tests use PATCH
|
||||
securityAdmin.POST("/waf/enable", securityHandler.EnableWAF)
|
||||
securityAdmin.POST("/waf/disable", securityHandler.DisableWAF)
|
||||
securityAdmin.PATCH("/waf", securityHandler.PatchWAF) // E2E tests use PATCH
|
||||
securityAdmin.POST("/cerberus/enable", securityHandler.EnableCerberus)
|
||||
securityAdmin.POST("/cerberus/disable", securityHandler.DisableCerberus)
|
||||
securityAdmin.POST("/crowdsec/enable", securityHandler.EnableCrowdSec)
|
||||
securityAdmin.POST("/crowdsec/disable", securityHandler.DisableCrowdSec)
|
||||
securityAdmin.PATCH("/crowdsec", securityHandler.PatchCrowdSec) // E2E tests use PATCH
|
||||
securityAdmin.POST("/rate-limit/enable", securityHandler.EnableRateLimit)
|
||||
securityAdmin.POST("/rate-limit/disable", securityHandler.DisableRateLimit)
|
||||
securityAdmin.PATCH("/rate-limit", securityHandler.PatchRateLimit) // E2E tests use PATCH
|
||||
|
||||
// CrowdSec process management and import
|
||||
// Data dir for crowdsec (persisted on host via volumes)
|
||||
@@ -635,7 +663,7 @@ func RegisterWithDeps(router *gin.Engine, db *gorm.DB, cfg config.Config, caddyM
|
||||
proxyHostHandler.RegisterRoutes(protected)
|
||||
|
||||
remoteServerHandler := handlers.NewRemoteServerHandler(remoteServerService, notificationService)
|
||||
remoteServerHandler.RegisterRoutes(api)
|
||||
remoteServerHandler.RegisterRoutes(protected)
|
||||
|
||||
// Initial Caddy Config Sync
|
||||
go func() {
|
||||
@@ -674,17 +702,20 @@ func RegisterWithDeps(router *gin.Engine, db *gorm.DB, cfg config.Config, caddyM
|
||||
}
|
||||
|
||||
// RegisterImportHandler wires up import routes with config dependencies.
|
||||
func RegisterImportHandler(router *gin.Engine, db *gorm.DB, caddyBinary, importDir, mountPath string) {
|
||||
func RegisterImportHandler(router *gin.Engine, db *gorm.DB, cfg config.Config, caddyBinary, importDir, mountPath string) {
|
||||
securityService := services.NewSecurityService(db)
|
||||
importHandler := handlers.NewImportHandlerWithDeps(db, caddyBinary, importDir, mountPath, securityService)
|
||||
api := router.Group("/api/v1")
|
||||
importHandler.RegisterRoutes(api)
|
||||
authService := services.NewAuthService(db, cfg)
|
||||
authenticatedAdmin := api.Group("/")
|
||||
authenticatedAdmin.Use(middleware.AuthMiddleware(authService), middleware.RequireRole("admin"))
|
||||
importHandler.RegisterRoutes(authenticatedAdmin)
|
||||
|
||||
// NPM Import Handler - supports Nginx Proxy Manager export format
|
||||
npmImportHandler := handlers.NewNPMImportHandler(db)
|
||||
npmImportHandler.RegisterRoutes(api)
|
||||
npmImportHandler.RegisterRoutes(authenticatedAdmin)
|
||||
|
||||
// JSON Import Handler - supports both Charon and NPM export formats
|
||||
jsonImportHandler := handlers.NewJSONImportHandler(db)
|
||||
jsonImportHandler.RegisterRoutes(api)
|
||||
jsonImportHandler.RegisterRoutes(authenticatedAdmin)
|
||||
}
|
||||
|
||||
@@ -73,3 +73,55 @@ func TestRegister_LegacyMigrationErrorIsNonFatal(t *testing.T) {
|
||||
}
|
||||
require.True(t, hasHealth)
|
||||
}
|
||||
|
||||
func TestRegister_UptimeFeatureFlagDefaultErrorIsNonFatal(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
|
||||
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared&_test_uptime_flag_warn"), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
const cbName = "routes:test_force_settings_query_error"
|
||||
err = db.Callback().Query().Before("gorm:query").Register(cbName, func(tx *gorm.DB) {
|
||||
if tx.Statement != nil && tx.Statement.Table == "settings" {
|
||||
_ = tx.AddError(errors.New("forced settings query failure"))
|
||||
}
|
||||
})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
_ = db.Callback().Query().Remove(cbName)
|
||||
})
|
||||
|
||||
cfg := config.Config{JWTSecret: "test-secret"}
|
||||
|
||||
err = Register(router, db, cfg)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestRegister_SecurityHeaderPresetInitErrorIsNonFatal(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
|
||||
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared&_test_sec_header_presets_warn"), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
const cbName = "routes:test_force_security_header_profile_query_error"
|
||||
err = db.Callback().Query().Before("gorm:query").Register(cbName, func(tx *gorm.DB) {
|
||||
if tx.Statement != nil && tx.Statement.Table == "security_header_profiles" {
|
||||
_ = tx.AddError(errors.New("forced security_header_profiles query failure"))
|
||||
}
|
||||
})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
_ = db.Callback().Query().Remove(cbName)
|
||||
})
|
||||
|
||||
cfg := config.Config{JWTSecret: "test-secret"}
|
||||
|
||||
err = Register(router, db, cfg)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
package routes_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/Wikid82/charon/backend/internal/api/routes"
|
||||
"github.com/Wikid82/charon/backend/internal/config"
|
||||
"github.com/Wikid82/charon/backend/internal/models"
|
||||
"github.com/Wikid82/charon/backend/internal/services"
|
||||
)
|
||||
|
||||
func setupTestImportDB(t *testing.T) *gorm.DB {
|
||||
@@ -27,7 +32,7 @@ func TestRegisterImportHandler(t *testing.T) {
|
||||
db := setupTestImportDB(t)
|
||||
|
||||
router := gin.New()
|
||||
routes.RegisterImportHandler(router, db, "echo", "/tmp", "/import/Caddyfile")
|
||||
routes.RegisterImportHandler(router, db, config.Config{JWTSecret: "test-secret"}, "echo", "/tmp", "/import/Caddyfile")
|
||||
|
||||
// Verify routes are registered by checking the routes list
|
||||
routeInfo := router.Routes()
|
||||
@@ -53,3 +58,30 @@ func TestRegisterImportHandler(t *testing.T) {
|
||||
assert.True(t, found, "route %s should be registered", route)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterImportHandler_AuthzGuards(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupTestImportDB(t)
|
||||
require.NoError(t, db.AutoMigrate(&models.User{}))
|
||||
|
||||
cfg := config.Config{JWTSecret: "test-secret"}
|
||||
router := gin.New()
|
||||
routes.RegisterImportHandler(router, db, cfg, "echo", "/tmp", "/import/Caddyfile")
|
||||
|
||||
unauthReq := httptest.NewRequest(http.MethodGet, "/api/v1/import/status", http.NoBody)
|
||||
unauthW := httptest.NewRecorder()
|
||||
router.ServeHTTP(unauthW, unauthReq)
|
||||
assert.Equal(t, http.StatusUnauthorized, unauthW.Code)
|
||||
|
||||
nonAdmin := &models.User{Email: "user@example.com", Role: "user", Enabled: true}
|
||||
require.NoError(t, db.Create(nonAdmin).Error)
|
||||
authSvc := services.NewAuthService(db, cfg)
|
||||
token, err := authSvc.GenerateToken(nonAdmin)
|
||||
require.NoError(t, err)
|
||||
|
||||
nonAdminReq := httptest.NewRequest(http.MethodGet, "/api/v1/import/preview", http.NoBody)
|
||||
nonAdminReq.Header.Set("Authorization", "Bearer "+token)
|
||||
nonAdminW := httptest.NewRecorder()
|
||||
router.ServeHTTP(nonAdminW, nonAdminReq)
|
||||
assert.Equal(t, http.StatusForbidden, nonAdminW.Code)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
@@ -16,6 +17,16 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func materializeRoutePath(path string) string {
|
||||
segments := strings.Split(path, "/")
|
||||
for i, segment := range segments {
|
||||
if strings.HasPrefix(segment, ":") {
|
||||
segments[i] = "1"
|
||||
}
|
||||
}
|
||||
return strings.Join(segments, "/")
|
||||
}
|
||||
|
||||
func TestRegister(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
@@ -103,11 +114,13 @@ func TestRegisterImportHandler(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
|
||||
cfg := config.Config{JWTSecret: "test-secret"}
|
||||
|
||||
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared&_test_import"), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
|
||||
// RegisterImportHandler should not panic
|
||||
RegisterImportHandler(router, db, "/usr/bin/caddy", "/tmp/imports", "/tmp/mount")
|
||||
RegisterImportHandler(router, db, cfg, "/usr/bin/caddy", "/tmp/imports", "/tmp/mount")
|
||||
|
||||
// Verify import routes exist
|
||||
routes := router.Routes()
|
||||
@@ -177,6 +190,70 @@ func TestRegister_ProxyHostsRequireAuth(t *testing.T) {
|
||||
assert.Contains(t, w.Body.String(), "Authorization header required")
|
||||
}
|
||||
|
||||
func TestRegister_StateChangingRoutesDenyByDefaultWithExplicitAllowlist(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
|
||||
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared&_test_mutation_auth_guard"), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
|
||||
cfg := config.Config{JWTSecret: "test-secret"}
|
||||
require.NoError(t, Register(router, db, cfg))
|
||||
|
||||
mutatingMethods := map[string]bool{
|
||||
http.MethodPost: true,
|
||||
http.MethodPut: true,
|
||||
http.MethodPatch: true,
|
||||
http.MethodDelete: true,
|
||||
}
|
||||
|
||||
publicMutationAllowlist := map[string]bool{
|
||||
http.MethodPost + " /api/v1/auth/login": true,
|
||||
http.MethodPost + " /api/v1/auth/register": true,
|
||||
http.MethodPost + " /api/v1/setup": true,
|
||||
http.MethodPost + " /api/v1/invite/accept": true,
|
||||
http.MethodPost + " /api/v1/security/events": true,
|
||||
http.MethodPost + " /api/v1/emergency/security-reset": true,
|
||||
}
|
||||
|
||||
for _, route := range router.Routes() {
|
||||
if !strings.HasPrefix(route.Path, "/api/v1/") {
|
||||
continue
|
||||
}
|
||||
if !mutatingMethods[route.Method] {
|
||||
continue
|
||||
}
|
||||
|
||||
key := route.Method + " " + route.Path
|
||||
if publicMutationAllowlist[key] {
|
||||
continue
|
||||
}
|
||||
|
||||
requestPath := materializeRoutePath(route.Path)
|
||||
var body io.Reader = http.NoBody
|
||||
if route.Method == http.MethodPost || route.Method == http.MethodPut || route.Method == http.MethodPatch {
|
||||
body = strings.NewReader("{}")
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(route.Method, requestPath, body)
|
||||
if route.Method == http.MethodPost || route.Method == http.MethodPut || route.Method == http.MethodPatch {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Contains(
|
||||
t,
|
||||
[]int{http.StatusUnauthorized, http.StatusForbidden},
|
||||
w.Code,
|
||||
"state-changing endpoint must deny unauthenticated access unless explicitly allowlisted: %s (materialized path: %s)",
|
||||
key,
|
||||
requestPath,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegister_DNSProviders_NotRegisteredWhenEncryptionKeyMissing(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
@@ -362,6 +439,42 @@ func TestRegister_AuthenticatedRoutes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegister_StateChangingRoutesRequireAuthentication(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
|
||||
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared&_test_mutating_auth_routes"), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
|
||||
cfg := config.Config{JWTSecret: "test-secret"}
|
||||
require.NoError(t, Register(router, db, cfg))
|
||||
|
||||
stateChangingPaths := []struct {
|
||||
method string
|
||||
path string
|
||||
}{
|
||||
{http.MethodPost, "/api/v1/backups"},
|
||||
{http.MethodPost, "/api/v1/settings"},
|
||||
{http.MethodPatch, "/api/v1/settings"},
|
||||
{http.MethodPatch, "/api/v1/config"},
|
||||
{http.MethodPost, "/api/v1/user/profile"},
|
||||
{http.MethodPost, "/api/v1/remote-servers"},
|
||||
{http.MethodPost, "/api/v1/remote-servers/test"},
|
||||
{http.MethodPut, "/api/v1/remote-servers/1"},
|
||||
{http.MethodDelete, "/api/v1/remote-servers/1"},
|
||||
{http.MethodPost, "/api/v1/remote-servers/1/test"},
|
||||
}
|
||||
|
||||
for _, tc := range stateChangingPaths {
|
||||
t.Run(tc.method+"_"+tc.path, func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(tc.method, tc.path, nil)
|
||||
router.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusUnauthorized, w.Code, "State-changing route %s %s should require auth", tc.method, tc.path)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegister_AdminRoutes(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
@@ -915,10 +1028,12 @@ func TestRegisterImportHandler_RoutesExist(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
|
||||
cfg := config.Config{JWTSecret: "test-secret"}
|
||||
|
||||
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared&_test_import_routes"), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
|
||||
RegisterImportHandler(router, db, "/usr/bin/caddy", "/tmp/imports", "/tmp/mount")
|
||||
RegisterImportHandler(router, db, cfg, "/usr/bin/caddy", "/tmp/imports", "/tmp/mount")
|
||||
|
||||
routes := router.Routes()
|
||||
routeMap := make(map[string]bool)
|
||||
|
||||
107
backend/internal/api/routes/routes_uptime_bootstrap_test.go
Normal file
107
backend/internal/api/routes/routes_uptime_bootstrap_test.go
Normal file
@@ -0,0 +1,107 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
type testUptimeBootstrapService struct {
|
||||
cleanupErr error
|
||||
syncErr error
|
||||
|
||||
cleanupCalls int
|
||||
syncCalls int
|
||||
checkAllCalls int
|
||||
}
|
||||
|
||||
func (s *testUptimeBootstrapService) CleanupStaleFailureCounts() error {
|
||||
s.cleanupCalls++
|
||||
return s.cleanupErr
|
||||
}
|
||||
|
||||
func (s *testUptimeBootstrapService) SyncMonitors() error {
|
||||
s.syncCalls++
|
||||
return s.syncErr
|
||||
}
|
||||
|
||||
func (s *testUptimeBootstrapService) CheckAll() {
|
||||
s.checkAllCalls++
|
||||
}
|
||||
|
||||
func TestRunInitialUptimeBootstrap_Disabled_DoesNothing(t *testing.T) {
|
||||
svc := &testUptimeBootstrapService{}
|
||||
|
||||
warnLogs := 0
|
||||
errorLogs := 0
|
||||
runInitialUptimeBootstrap(
|
||||
false,
|
||||
svc,
|
||||
func(err error, msg string) { warnLogs++ },
|
||||
func(err error, msg string) { errorLogs++ },
|
||||
)
|
||||
|
||||
assert.Equal(t, 0, svc.cleanupCalls)
|
||||
assert.Equal(t, 0, svc.syncCalls)
|
||||
assert.Equal(t, 0, svc.checkAllCalls)
|
||||
assert.Equal(t, 0, warnLogs)
|
||||
assert.Equal(t, 0, errorLogs)
|
||||
}
|
||||
|
||||
func TestRunInitialUptimeBootstrap_Enabled_HappyPath(t *testing.T) {
|
||||
svc := &testUptimeBootstrapService{}
|
||||
|
||||
warnLogs := 0
|
||||
errorLogs := 0
|
||||
runInitialUptimeBootstrap(
|
||||
true,
|
||||
svc,
|
||||
func(err error, msg string) { warnLogs++ },
|
||||
func(err error, msg string) { errorLogs++ },
|
||||
)
|
||||
|
||||
assert.Equal(t, 1, svc.cleanupCalls)
|
||||
assert.Equal(t, 1, svc.syncCalls)
|
||||
assert.Equal(t, 1, svc.checkAllCalls)
|
||||
assert.Equal(t, 0, warnLogs)
|
||||
assert.Equal(t, 0, errorLogs)
|
||||
}
|
||||
|
||||
func TestRunInitialUptimeBootstrap_Enabled_CleanupError_StillProceeds(t *testing.T) {
|
||||
svc := &testUptimeBootstrapService{cleanupErr: errors.New("cleanup failed")}
|
||||
|
||||
warnLogs := 0
|
||||
errorLogs := 0
|
||||
runInitialUptimeBootstrap(
|
||||
true,
|
||||
svc,
|
||||
func(err error, msg string) { warnLogs++ },
|
||||
func(err error, msg string) { errorLogs++ },
|
||||
)
|
||||
|
||||
assert.Equal(t, 1, svc.cleanupCalls)
|
||||
assert.Equal(t, 1, svc.syncCalls)
|
||||
assert.Equal(t, 1, svc.checkAllCalls)
|
||||
assert.Equal(t, 1, warnLogs)
|
||||
assert.Equal(t, 0, errorLogs)
|
||||
}
|
||||
|
||||
func TestRunInitialUptimeBootstrap_Enabled_SyncError_StillChecksAll(t *testing.T) {
|
||||
svc := &testUptimeBootstrapService{syncErr: errors.New("sync failed")}
|
||||
|
||||
warnLogs := 0
|
||||
errorLogs := 0
|
||||
runInitialUptimeBootstrap(
|
||||
true,
|
||||
svc,
|
||||
func(err error, msg string) { warnLogs++ },
|
||||
func(err error, msg string) { errorLogs++ },
|
||||
)
|
||||
|
||||
assert.Equal(t, 1, svc.cleanupCalls)
|
||||
assert.Equal(t, 1, svc.syncCalls)
|
||||
assert.Equal(t, 1, svc.checkAllCalls)
|
||||
assert.Equal(t, 0, warnLogs)
|
||||
assert.Equal(t, 1, errorLogs)
|
||||
}
|
||||
@@ -100,7 +100,10 @@ func TestInviteToken_MustBeUnguessable(t *testing.T) {
|
||||
var resp map[string]any
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
|
||||
token := resp["invite_token"].(string)
|
||||
var invitedUser models.User
|
||||
require.NoError(t, db.Where("email = ?", "user@test.com").First(&invitedUser).Error)
|
||||
token := invitedUser.InviteToken
|
||||
require.NotEmpty(t, token)
|
||||
|
||||
// Token MUST be at least 32 chars (64 hex = 32 bytes = 256 bits)
|
||||
assert.GreaterOrEqual(t, len(token), 64, "Invite token must be at least 64 hex chars (256 bits)")
|
||||
|
||||
@@ -857,6 +857,27 @@ func normalizeHeaderOps(headerOps map[string]any) {
|
||||
}
|
||||
}
|
||||
|
||||
func applyOptionalServerKeepalive(conf *Config, keepaliveIdle string, keepaliveCount int) {
|
||||
if conf == nil || conf.Apps.HTTP == nil || conf.Apps.HTTP.Servers == nil {
|
||||
return
|
||||
}
|
||||
|
||||
server, ok := conf.Apps.HTTP.Servers["charon_server"]
|
||||
if !ok || server == nil {
|
||||
return
|
||||
}
|
||||
|
||||
idle := strings.TrimSpace(keepaliveIdle)
|
||||
if idle != "" {
|
||||
server.KeepaliveIdle = &idle
|
||||
}
|
||||
|
||||
if keepaliveCount > 0 {
|
||||
count := keepaliveCount
|
||||
server.KeepaliveCount = &count
|
||||
}
|
||||
}
|
||||
|
||||
// NormalizeAdvancedConfig traverses a parsed JSON advanced config (map or array)
|
||||
// and normalizes any headers blocks so that header values are arrays of strings.
|
||||
// It returns the modified config object which can be JSON marshaled again.
|
||||
|
||||
@@ -103,3 +103,43 @@ func TestGenerateConfig_EmergencyRoutesBypassSecurity(t *testing.T) {
|
||||
require.NotEqual(t, "crowdsec", name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyOptionalServerKeepalive_OmitsWhenUnset(t *testing.T) {
|
||||
cfg := &Config{
|
||||
Apps: Apps{
|
||||
HTTP: &HTTPApp{Servers: map[string]*Server{
|
||||
"charon_server": {
|
||||
Listen: []string{":80", ":443"},
|
||||
Routes: []*Route{},
|
||||
},
|
||||
}},
|
||||
},
|
||||
}
|
||||
|
||||
applyOptionalServerKeepalive(cfg, "", 0)
|
||||
|
||||
server := cfg.Apps.HTTP.Servers["charon_server"]
|
||||
require.Nil(t, server.KeepaliveIdle)
|
||||
require.Nil(t, server.KeepaliveCount)
|
||||
}
|
||||
|
||||
func TestApplyOptionalServerKeepalive_AppliesValidValues(t *testing.T) {
|
||||
cfg := &Config{
|
||||
Apps: Apps{
|
||||
HTTP: &HTTPApp{Servers: map[string]*Server{
|
||||
"charon_server": {
|
||||
Listen: []string{":80", ":443"},
|
||||
Routes: []*Route{},
|
||||
},
|
||||
}},
|
||||
},
|
||||
}
|
||||
|
||||
applyOptionalServerKeepalive(cfg, "45s", 7)
|
||||
|
||||
server := cfg.Apps.HTTP.Servers["charon_server"]
|
||||
require.NotNil(t, server.KeepaliveIdle)
|
||||
require.Equal(t, "45s", *server.KeepaliveIdle)
|
||||
require.NotNil(t, server.KeepaliveCount)
|
||||
require.Equal(t, 7, *server.KeepaliveCount)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -33,6 +34,15 @@ var (
|
||||
validateConfigFunc = Validate
|
||||
)
|
||||
|
||||
const (
|
||||
minKeepaliveIdleDuration = time.Second
|
||||
maxKeepaliveIdleDuration = 24 * time.Hour
|
||||
minKeepaliveCount = 1
|
||||
maxKeepaliveCount = 100
|
||||
settingCaddyKeepaliveIdle = "caddy.keepalive_idle"
|
||||
settingCaddyKeepaliveCnt = "caddy.keepalive_count"
|
||||
)
|
||||
|
||||
// DNSProviderConfig contains a DNS provider with its decrypted credentials
|
||||
// for use in Caddy DNS challenge configuration generation
|
||||
type DNSProviderConfig struct {
|
||||
@@ -277,6 +287,18 @@ func (m *Manager) ApplyConfig(ctx context.Context) error {
|
||||
// Compute effective security flags (re-read runtime overrides)
|
||||
_, aclEnabled, wafEnabled, rateLimitEnabled, crowdsecEnabled := m.computeEffectiveFlags(ctx)
|
||||
|
||||
keepaliveIdle := ""
|
||||
var keepaliveIdleSetting models.Setting
|
||||
if err := m.db.Where("key = ?", settingCaddyKeepaliveIdle).First(&keepaliveIdleSetting).Error; err == nil {
|
||||
keepaliveIdle = sanitizeKeepaliveIdle(keepaliveIdleSetting.Value)
|
||||
}
|
||||
|
||||
keepaliveCount := 0
|
||||
var keepaliveCountSetting models.Setting
|
||||
if err := m.db.Where("key = ?", settingCaddyKeepaliveCnt).First(&keepaliveCountSetting).Error; err == nil {
|
||||
keepaliveCount = sanitizeKeepaliveCount(keepaliveCountSetting.Value)
|
||||
}
|
||||
|
||||
// Safety check: if Cerberus is enabled in DB and no admin whitelist configured,
|
||||
// warn but allow initial startup to proceed. This prevents total lockout when
|
||||
// the user has enabled Cerberus but hasn't configured admin_whitelist yet.
|
||||
@@ -401,6 +423,8 @@ func (m *Manager) ApplyConfig(ctx context.Context) error {
|
||||
return fmt.Errorf("generate config: %w", err)
|
||||
}
|
||||
|
||||
applyOptionalServerKeepalive(generatedConfig, keepaliveIdle, keepaliveCount)
|
||||
|
||||
// Debug logging: WAF configuration state for troubleshooting integration issues
|
||||
logger.Log().WithFields(map[string]any{
|
||||
"waf_enabled": wafEnabled,
|
||||
@@ -467,6 +491,42 @@ func (m *Manager) ApplyConfig(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func sanitizeKeepaliveIdle(value string) string {
|
||||
idle := strings.TrimSpace(value)
|
||||
if idle == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
d, err := time.ParseDuration(idle)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
if d < minKeepaliveIdleDuration || d > maxKeepaliveIdleDuration {
|
||||
return ""
|
||||
}
|
||||
|
||||
return idle
|
||||
}
|
||||
|
||||
func sanitizeKeepaliveCount(value string) int {
|
||||
raw := strings.TrimSpace(value)
|
||||
if raw == "" {
|
||||
return 0
|
||||
}
|
||||
|
||||
count, err := strconv.Atoi(raw)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
if count < minKeepaliveCount || count > maxKeepaliveCount {
|
||||
return 0
|
||||
}
|
||||
|
||||
return count
|
||||
}
|
||||
|
||||
// saveSnapshot stores the config to disk with timestamp.
|
||||
func (m *Manager) saveSnapshot(conf *Config) (string, error) {
|
||||
timestamp := time.Now().Unix()
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package caddy
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
@@ -185,3 +187,93 @@ func TestManagerApplyConfig_DNSProviders_SkipsDecryptOrJSONFailures(t *testing.T
|
||||
require.Len(t, captured, 1)
|
||||
require.Equal(t, uint(24), captured[0].ID)
|
||||
}
|
||||
|
||||
func TestManagerApplyConfig_MapsKeepaliveSettingsToGeneratedServer(t *testing.T) {
|
||||
var loadBody []byte
|
||||
caddyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/load" && r.Method == http.MethodPost {
|
||||
payload, _ := io.ReadAll(r.Body)
|
||||
loadBody = append([]byte(nil), payload...)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer caddyServer.Close()
|
||||
|
||||
dsn := "file:" + t.Name() + "?mode=memory&cache=shared"
|
||||
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.AutoMigrate(
|
||||
&models.ProxyHost{},
|
||||
&models.Location{},
|
||||
&models.Setting{},
|
||||
&models.CaddyConfig{},
|
||||
&models.SSLCertificate{},
|
||||
&models.SecurityConfig{},
|
||||
&models.SecurityRuleSet{},
|
||||
&models.SecurityDecision{},
|
||||
&models.DNSProvider{},
|
||||
))
|
||||
|
||||
db.Create(&models.ProxyHost{DomainNames: "keepalive.example.com", ForwardHost: "127.0.0.1", ForwardPort: 8080, Enabled: true})
|
||||
db.Create(&models.SecurityConfig{Name: "default", Enabled: true})
|
||||
db.Create(&models.Setting{Key: settingCaddyKeepaliveIdle, Value: "45s"})
|
||||
db.Create(&models.Setting{Key: settingCaddyKeepaliveCnt, Value: "8"})
|
||||
|
||||
origVal := validateConfigFunc
|
||||
defer func() { validateConfigFunc = origVal }()
|
||||
validateConfigFunc = func(_ *Config) error { return nil }
|
||||
|
||||
manager := NewManager(newTestClient(t, caddyServer.URL), db, t.TempDir(), "", false, config.SecurityConfig{CerberusEnabled: true})
|
||||
require.NoError(t, manager.ApplyConfig(context.Background()))
|
||||
require.NotEmpty(t, loadBody)
|
||||
|
||||
require.True(t, bytes.Contains(loadBody, []byte(`"keepalive_idle":"45s"`)))
|
||||
require.True(t, bytes.Contains(loadBody, []byte(`"keepalive_count":8`)))
|
||||
}
|
||||
|
||||
func TestManagerApplyConfig_InvalidKeepaliveSettingsFallbackToDefaults(t *testing.T) {
|
||||
var loadBody []byte
|
||||
caddyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/load" && r.Method == http.MethodPost {
|
||||
payload, _ := io.ReadAll(r.Body)
|
||||
loadBody = append([]byte(nil), payload...)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer caddyServer.Close()
|
||||
|
||||
dsn := "file:" + t.Name() + "_invalid?mode=memory&cache=shared"
|
||||
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.AutoMigrate(
|
||||
&models.ProxyHost{},
|
||||
&models.Location{},
|
||||
&models.Setting{},
|
||||
&models.CaddyConfig{},
|
||||
&models.SSLCertificate{},
|
||||
&models.SecurityConfig{},
|
||||
&models.SecurityRuleSet{},
|
||||
&models.SecurityDecision{},
|
||||
&models.DNSProvider{},
|
||||
))
|
||||
|
||||
db.Create(&models.ProxyHost{DomainNames: "invalid-keepalive.example.com", ForwardHost: "127.0.0.1", ForwardPort: 8080, Enabled: true})
|
||||
db.Create(&models.SecurityConfig{Name: "default", Enabled: true})
|
||||
db.Create(&models.Setting{Key: settingCaddyKeepaliveIdle, Value: "bad"})
|
||||
db.Create(&models.Setting{Key: settingCaddyKeepaliveCnt, Value: "-1"})
|
||||
|
||||
origVal := validateConfigFunc
|
||||
defer func() { validateConfigFunc = origVal }()
|
||||
validateConfigFunc = func(_ *Config) error { return nil }
|
||||
|
||||
manager := NewManager(newTestClient(t, caddyServer.URL), db, t.TempDir(), "", false, config.SecurityConfig{CerberusEnabled: true})
|
||||
require.NoError(t, manager.ApplyConfig(context.Background()))
|
||||
require.NotEmpty(t, loadBody)
|
||||
|
||||
require.False(t, bytes.Contains(loadBody, []byte(`"keepalive_idle"`)))
|
||||
require.False(t, bytes.Contains(loadBody, []byte(`"keepalive_count"`)))
|
||||
}
|
||||
|
||||
@@ -83,6 +83,8 @@ type Server struct {
|
||||
AutoHTTPS *AutoHTTPSConfig `json:"automatic_https,omitempty"`
|
||||
Logs *ServerLogs `json:"logs,omitempty"`
|
||||
TrustedProxies *TrustedProxies `json:"trusted_proxies,omitempty"`
|
||||
KeepaliveIdle *string `json:"keepalive_idle,omitempty"`
|
||||
KeepaliveCount *int `json:"keepalive_count,omitempty"`
|
||||
}
|
||||
|
||||
// TrustedProxies defines the module for configuring trusted proxy IP ranges.
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/Wikid82/charon/backend/internal/security"
|
||||
)
|
||||
|
||||
// Config captures runtime configuration sourced from environment variables.
|
||||
@@ -106,6 +108,17 @@ func Load() (Config, error) {
|
||||
Debug: getEnvAny("false", "CHARON_DEBUG", "CPM_DEBUG") == "true",
|
||||
}
|
||||
|
||||
allowedInternalHosts := security.InternalServiceHostAllowlist()
|
||||
normalizedCaddyAdminURL, err := security.ValidateInternalServiceBaseURL(
|
||||
cfg.CaddyAdminAPI,
|
||||
2019,
|
||||
allowedInternalHosts,
|
||||
)
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("validate caddy admin api url: %w", err)
|
||||
}
|
||||
cfg.CaddyAdminAPI = normalizedCaddyAdminURL.String()
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(cfg.DatabasePath), 0o700); err != nil {
|
||||
return Config{}, fmt.Errorf("ensure data directory: %w", err)
|
||||
}
|
||||
|
||||
@@ -258,6 +258,32 @@ func TestLoad_EmergencyConfig(t *testing.T) {
|
||||
assert.Equal(t, "testpass", cfg.Emergency.BasicAuthPassword)
|
||||
}
|
||||
|
||||
func TestLoad_CaddyAdminAPIValidationAndNormalization(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
t.Setenv("CHARON_DB_PATH", filepath.Join(tempDir, "test.db"))
|
||||
t.Setenv("CHARON_CADDY_CONFIG_DIR", filepath.Join(tempDir, "caddy"))
|
||||
t.Setenv("CHARON_IMPORT_DIR", filepath.Join(tempDir, "imports"))
|
||||
t.Setenv("CHARON_SSRF_INTERNAL_HOST_ALLOWLIST", "")
|
||||
t.Setenv("CHARON_CADDY_ADMIN_API", "http://localhost:2019/config/")
|
||||
|
||||
cfg, err := Load()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "http://localhost:2019", cfg.CaddyAdminAPI)
|
||||
}
|
||||
|
||||
func TestLoad_CaddyAdminAPIValidationRejectsNonAllowlistedHost(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
t.Setenv("CHARON_DB_PATH", filepath.Join(tempDir, "test.db"))
|
||||
t.Setenv("CHARON_CADDY_CONFIG_DIR", filepath.Join(tempDir, "caddy"))
|
||||
t.Setenv("CHARON_IMPORT_DIR", filepath.Join(tempDir, "imports"))
|
||||
t.Setenv("CHARON_SSRF_INTERNAL_HOST_ALLOWLIST", "")
|
||||
t.Setenv("CHARON_CADDY_ADMIN_API", "http://example.com:2019")
|
||||
|
||||
_, err := Load()
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "validate caddy admin api url")
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// splitAndTrim Tests
|
||||
// ============================================
|
||||
|
||||
@@ -14,6 +14,7 @@ type NotificationProvider struct {
|
||||
Type string `json:"type" gorm:"index"` // discord (only supported type in current rollout)
|
||||
URL string `json:"url"` // Discord webhook URL (HTTPS format required)
|
||||
Token string `json:"-"` // Auth token for providers (e.g., Gotify) - never exposed in API
|
||||
HasToken bool `json:"has_token" gorm:"-"` // Computed: indicates whether a token is set (never exposes raw value)
|
||||
Engine string `json:"engine,omitempty" gorm:"index"` // notify_v1 (notify-only runtime)
|
||||
Config string `json:"config"` // JSON payload template for custom webhooks
|
||||
ServiceConfig string `json:"service_config,omitempty" gorm:"type:text"` // JSON blob for typed service config
|
||||
|
||||
@@ -4,5 +4,6 @@ const (
|
||||
FlagNotifyEngineEnabled = "feature.notifications.engine.notify_v1.enabled"
|
||||
FlagDiscordServiceEnabled = "feature.notifications.service.discord.enabled"
|
||||
FlagGotifyServiceEnabled = "feature.notifications.service.gotify.enabled"
|
||||
FlagWebhookServiceEnabled = "feature.notifications.service.webhook.enabled"
|
||||
FlagSecurityProviderEventsEnabled = "feature.notifications.security_provider_events.enabled"
|
||||
)
|
||||
|
||||
7
backend/internal/notifications/http_client_executor.go
Normal file
7
backend/internal/notifications/http_client_executor.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package notifications
|
||||
|
||||
import "net/http"
|
||||
|
||||
func executeNotifyRequest(client *http.Client, req *http.Request) (*http.Response, error) {
|
||||
return client.Do(req)
|
||||
}
|
||||
507
backend/internal/notifications/http_wrapper.go
Normal file
507
backend/internal/notifications/http_wrapper.go
Normal file
@@ -0,0 +1,507 @@
|
||||
package notifications
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
crand "crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/big"
|
||||
"net"
|
||||
"net/http"
|
||||
neturl "net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Wikid82/charon/backend/internal/network"
|
||||
"github.com/Wikid82/charon/backend/internal/security"
|
||||
)
|
||||
|
||||
const (
|
||||
MaxNotifyRequestBodyBytes = 256 * 1024
|
||||
MaxNotifyResponseBodyBytes = 1024 * 1024
|
||||
)
|
||||
|
||||
type RetryPolicy struct {
|
||||
MaxAttempts int
|
||||
BaseDelay time.Duration
|
||||
MaxDelay time.Duration
|
||||
}
|
||||
|
||||
type HTTPWrapperRequest struct {
|
||||
URL string
|
||||
Headers map[string]string
|
||||
Body []byte
|
||||
}
|
||||
|
||||
type HTTPWrapperResult struct {
|
||||
StatusCode int
|
||||
ResponseBody []byte
|
||||
Attempts int
|
||||
}
|
||||
|
||||
type HTTPWrapper struct {
|
||||
retryPolicy RetryPolicy
|
||||
allowHTTP bool
|
||||
maxRedirects int
|
||||
httpClientFactory func(allowHTTP bool, maxRedirects int) *http.Client
|
||||
sleep func(time.Duration)
|
||||
jitterNanos func(int64) int64
|
||||
}
|
||||
|
||||
func NewNotifyHTTPWrapper() *HTTPWrapper {
|
||||
return &HTTPWrapper{
|
||||
retryPolicy: RetryPolicy{
|
||||
MaxAttempts: 3,
|
||||
BaseDelay: 200 * time.Millisecond,
|
||||
MaxDelay: 2 * time.Second,
|
||||
},
|
||||
allowHTTP: allowNotifyHTTPOverride(),
|
||||
maxRedirects: notifyMaxRedirects(),
|
||||
httpClientFactory: func(allowHTTP bool, maxRedirects int) *http.Client {
|
||||
opts := []network.Option{network.WithTimeout(10 * time.Second), network.WithMaxRedirects(maxRedirects)}
|
||||
if allowHTTP {
|
||||
opts = append(opts, network.WithAllowLocalhost())
|
||||
}
|
||||
return network.NewSafeHTTPClient(opts...)
|
||||
},
|
||||
sleep: time.Sleep,
|
||||
}
|
||||
}
|
||||
|
||||
func (w *HTTPWrapper) Send(ctx context.Context, request HTTPWrapperRequest) (*HTTPWrapperResult, error) {
|
||||
if len(request.Body) > MaxNotifyRequestBodyBytes {
|
||||
return nil, fmt.Errorf("request payload exceeds maximum size")
|
||||
}
|
||||
|
||||
validatedURL, err := w.validateURL(request.URL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
parsedValidatedURL, err := neturl.Parse(validatedURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("destination URL validation failed")
|
||||
}
|
||||
|
||||
validationOptions := []security.ValidationOption{}
|
||||
if w.allowHTTP {
|
||||
validationOptions = append(validationOptions, security.WithAllowHTTP(), security.WithAllowLocalhost())
|
||||
}
|
||||
|
||||
safeURL, safeURLErr := security.ValidateExternalURL(parsedValidatedURL.String(), validationOptions...)
|
||||
if safeURLErr != nil {
|
||||
return nil, fmt.Errorf("destination URL validation failed")
|
||||
}
|
||||
|
||||
safeParsedURL, safeParseErr := neturl.Parse(safeURL)
|
||||
if safeParseErr != nil {
|
||||
return nil, fmt.Errorf("destination URL validation failed")
|
||||
}
|
||||
|
||||
if err := w.guardDestination(safeParsedURL); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
safeRequestURL, hostHeader, safeRequestErr := w.buildSafeRequestURL(safeParsedURL)
|
||||
if safeRequestErr != nil {
|
||||
return nil, safeRequestErr
|
||||
}
|
||||
|
||||
headers := sanitizeOutboundHeaders(request.Headers)
|
||||
client := w.httpClientFactory(w.allowHTTP, w.maxRedirects)
|
||||
w.applyRedirectGuard(client)
|
||||
|
||||
var lastErr error
|
||||
for attempt := 1; attempt <= w.retryPolicy.MaxAttempts; attempt++ {
|
||||
httpReq, reqErr := http.NewRequestWithContext(ctx, http.MethodPost, safeRequestURL.String(), bytes.NewReader(request.Body))
|
||||
if reqErr != nil {
|
||||
return nil, fmt.Errorf("create outbound request: %w", reqErr)
|
||||
}
|
||||
|
||||
httpReq.Host = hostHeader
|
||||
|
||||
for key, value := range headers {
|
||||
httpReq.Header.Set(key, value)
|
||||
}
|
||||
|
||||
if httpReq.Header.Get("Content-Type") == "" {
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
resp, doErr := executeNotifyRequest(client, httpReq)
|
||||
if doErr != nil {
|
||||
lastErr = doErr
|
||||
if attempt < w.retryPolicy.MaxAttempts && shouldRetry(nil, doErr) {
|
||||
w.waitBeforeRetry(attempt)
|
||||
continue
|
||||
}
|
||||
return nil, fmt.Errorf("outbound request failed: %s", sanitizeTransportErrorReason(doErr))
|
||||
}
|
||||
|
||||
body, bodyErr := readCappedResponseBody(resp.Body)
|
||||
closeErr := resp.Body.Close()
|
||||
if bodyErr != nil {
|
||||
return nil, bodyErr
|
||||
}
|
||||
if closeErr != nil {
|
||||
return nil, fmt.Errorf("close response body: %w", closeErr)
|
||||
}
|
||||
|
||||
if shouldRetry(resp, nil) && attempt < w.retryPolicy.MaxAttempts {
|
||||
w.waitBeforeRetry(attempt)
|
||||
continue
|
||||
}
|
||||
|
||||
if resp.StatusCode >= http.StatusBadRequest {
|
||||
return nil, fmt.Errorf("provider returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
return &HTTPWrapperResult{
|
||||
StatusCode: resp.StatusCode,
|
||||
ResponseBody: body,
|
||||
Attempts: attempt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
if lastErr != nil {
|
||||
return nil, fmt.Errorf("provider request failed after retries: %s", sanitizeTransportErrorReason(lastErr))
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("provider request failed")
|
||||
}
|
||||
|
||||
func sanitizeTransportErrorReason(err error) string {
|
||||
if err == nil {
|
||||
return "connection failed"
|
||||
}
|
||||
|
||||
errText := strings.ToLower(strings.TrimSpace(err.Error()))
|
||||
|
||||
switch {
|
||||
case strings.Contains(errText, "no such host"):
|
||||
return "dns lookup failed"
|
||||
case strings.Contains(errText, "connection refused"):
|
||||
return "connection refused"
|
||||
case strings.Contains(errText, "no route to host") || strings.Contains(errText, "network is unreachable"):
|
||||
return "network unreachable"
|
||||
case strings.Contains(errText, "timeout") || strings.Contains(errText, "deadline exceeded"):
|
||||
return "request timed out"
|
||||
case strings.Contains(errText, "tls") || strings.Contains(errText, "certificate") || strings.Contains(errText, "x509"):
|
||||
return "tls handshake failed"
|
||||
default:
|
||||
return "connection failed"
|
||||
}
|
||||
}
|
||||
|
||||
func (w *HTTPWrapper) applyRedirectGuard(client *http.Client) {
|
||||
if client == nil {
|
||||
return
|
||||
}
|
||||
|
||||
originalCheckRedirect := client.CheckRedirect
|
||||
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
||||
if originalCheckRedirect != nil {
|
||||
if err := originalCheckRedirect(req, via); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return w.guardOutboundRequestURL(req)
|
||||
}
|
||||
}
|
||||
|
||||
func (w *HTTPWrapper) validateURL(rawURL string) (string, error) {
|
||||
parsedURL, err := neturl.Parse(rawURL)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid destination URL")
|
||||
}
|
||||
|
||||
if hasDisallowedQueryAuthKey(parsedURL.Query()) {
|
||||
return "", fmt.Errorf("destination URL query authentication is not allowed")
|
||||
}
|
||||
|
||||
options := []security.ValidationOption{}
|
||||
if w.allowHTTP {
|
||||
options = append(options, security.WithAllowHTTP(), security.WithAllowLocalhost())
|
||||
}
|
||||
|
||||
validatedURL, err := security.ValidateExternalURL(rawURL, options...)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("destination URL validation failed")
|
||||
}
|
||||
|
||||
return validatedURL, nil
|
||||
}
|
||||
|
||||
func hasDisallowedQueryAuthKey(query neturl.Values) bool {
|
||||
for key := range query {
|
||||
normalizedKey := strings.ToLower(strings.TrimSpace(key))
|
||||
switch normalizedKey {
|
||||
case "token", "auth", "apikey", "api_key":
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (w *HTTPWrapper) guardOutboundRequestURL(httpReq *http.Request) error {
|
||||
if httpReq == nil || httpReq.URL == nil {
|
||||
return fmt.Errorf("destination URL validation failed")
|
||||
}
|
||||
|
||||
reqURL := httpReq.URL.String()
|
||||
validatedURL, err := w.validateURL(reqURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
parsedValidatedURL, err := neturl.Parse(validatedURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("destination URL validation failed")
|
||||
}
|
||||
|
||||
return w.guardDestination(parsedValidatedURL)
|
||||
}
|
||||
|
||||
func (w *HTTPWrapper) guardDestination(destinationURL *neturl.URL) error {
|
||||
if destinationURL == nil {
|
||||
return fmt.Errorf("destination URL validation failed")
|
||||
}
|
||||
|
||||
if destinationURL.User != nil || destinationURL.Fragment != "" {
|
||||
return fmt.Errorf("destination URL validation failed")
|
||||
}
|
||||
|
||||
hostname := strings.TrimSpace(destinationURL.Hostname())
|
||||
if hostname == "" {
|
||||
return fmt.Errorf("destination URL validation failed")
|
||||
}
|
||||
|
||||
if parsedIP := net.ParseIP(hostname); parsedIP != nil {
|
||||
if !w.isAllowedDestinationIP(hostname, parsedIP) {
|
||||
return fmt.Errorf("destination URL validation failed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
resolvedIPs, err := net.LookupIP(hostname)
|
||||
if err != nil || len(resolvedIPs) == 0 {
|
||||
return fmt.Errorf("destination URL validation failed")
|
||||
}
|
||||
|
||||
for _, resolvedIP := range resolvedIPs {
|
||||
if !w.isAllowedDestinationIP(hostname, resolvedIP) {
|
||||
return fmt.Errorf("destination URL validation failed")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *HTTPWrapper) isAllowedDestinationIP(hostname string, ip net.IP) bool {
|
||||
if ip == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if ip.IsUnspecified() || ip.IsMulticast() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
|
||||
return false
|
||||
}
|
||||
|
||||
if ip.IsLoopback() {
|
||||
return w.allowHTTP && isLocalDestinationHost(hostname)
|
||||
}
|
||||
|
||||
if network.IsPrivateIP(ip) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func (w *HTTPWrapper) buildSafeRequestURL(destinationURL *neturl.URL) (*neturl.URL, string, error) {
|
||||
if destinationURL == nil {
|
||||
return nil, "", fmt.Errorf("destination URL validation failed")
|
||||
}
|
||||
|
||||
hostname := strings.TrimSpace(destinationURL.Hostname())
|
||||
if hostname == "" {
|
||||
return nil, "", fmt.Errorf("destination URL validation failed")
|
||||
}
|
||||
|
||||
// Validate destination IPs are allowed (defense-in-depth alongside safeDialer).
|
||||
_, err := w.resolveAllowedDestinationIP(hostname)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
// Preserve the original hostname in the URL so Go's TLS layer derives the
|
||||
// correct ServerName for SNI and certificate verification. The safeDialer
|
||||
// resolves DNS, validates IPs against SSRF rules, and connects to a
|
||||
// validated IP at dial time, so protection is maintained without
|
||||
// IP-pinning in the URL.
|
||||
safeRequestURL := &neturl.URL{
|
||||
Scheme: destinationURL.Scheme,
|
||||
Host: destinationURL.Host,
|
||||
Path: destinationURL.EscapedPath(),
|
||||
RawQuery: destinationURL.RawQuery,
|
||||
}
|
||||
|
||||
if safeRequestURL.Path == "" {
|
||||
safeRequestURL.Path = "/"
|
||||
}
|
||||
|
||||
return safeRequestURL, destinationURL.Host, nil
|
||||
}
|
||||
|
||||
func (w *HTTPWrapper) resolveAllowedDestinationIP(hostname string) (net.IP, error) {
|
||||
if parsedIP := net.ParseIP(hostname); parsedIP != nil {
|
||||
if !w.isAllowedDestinationIP(hostname, parsedIP) {
|
||||
return nil, fmt.Errorf("destination URL validation failed")
|
||||
}
|
||||
return parsedIP, nil
|
||||
}
|
||||
|
||||
resolvedIPs, err := net.LookupIP(hostname)
|
||||
if err != nil || len(resolvedIPs) == 0 {
|
||||
return nil, fmt.Errorf("destination URL validation failed")
|
||||
}
|
||||
|
||||
for _, resolvedIP := range resolvedIPs {
|
||||
if w.isAllowedDestinationIP(hostname, resolvedIP) {
|
||||
return resolvedIP, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("destination URL validation failed")
|
||||
}
|
||||
|
||||
func isLocalDestinationHost(host string) bool {
|
||||
trimmedHost := strings.TrimSpace(host)
|
||||
if strings.EqualFold(trimmedHost, "localhost") {
|
||||
return true
|
||||
}
|
||||
|
||||
parsedIP := net.ParseIP(trimmedHost)
|
||||
return parsedIP != nil && parsedIP.IsLoopback()
|
||||
}
|
||||
|
||||
func shouldRetry(resp *http.Response, err error) bool {
|
||||
if err != nil {
|
||||
var netErr net.Error
|
||||
if isNetErr := strings.Contains(strings.ToLower(err.Error()), "timeout") || strings.Contains(strings.ToLower(err.Error()), "connection"); isNetErr {
|
||||
return true
|
||||
}
|
||||
return errors.As(err, &netErr)
|
||||
}
|
||||
|
||||
if resp == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if resp.StatusCode == http.StatusTooManyRequests {
|
||||
return true
|
||||
}
|
||||
|
||||
return resp.StatusCode >= http.StatusInternalServerError
|
||||
}
|
||||
|
||||
func readCappedResponseBody(body io.Reader) ([]byte, error) {
|
||||
limited := io.LimitReader(body, MaxNotifyResponseBodyBytes+1)
|
||||
content, err := io.ReadAll(limited)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read response body: %w", err)
|
||||
}
|
||||
|
||||
if len(content) > MaxNotifyResponseBodyBytes {
|
||||
return nil, fmt.Errorf("response payload exceeds maximum size")
|
||||
}
|
||||
|
||||
return content, nil
|
||||
}
|
||||
|
||||
func sanitizeOutboundHeaders(headers map[string]string) map[string]string {
|
||||
allowed := map[string]struct{}{
|
||||
"content-type": {},
|
||||
"user-agent": {},
|
||||
"x-request-id": {},
|
||||
"x-gotify-key": {},
|
||||
}
|
||||
|
||||
sanitized := make(map[string]string)
|
||||
for key, value := range headers {
|
||||
normalizedKey := strings.ToLower(strings.TrimSpace(key))
|
||||
if _, ok := allowed[normalizedKey]; !ok {
|
||||
continue
|
||||
}
|
||||
sanitized[http.CanonicalHeaderKey(normalizedKey)] = strings.TrimSpace(value)
|
||||
}
|
||||
|
||||
return sanitized
|
||||
}
|
||||
|
||||
func (w *HTTPWrapper) waitBeforeRetry(attempt int) {
|
||||
delay := w.retryPolicy.BaseDelay << (attempt - 1)
|
||||
if delay > w.retryPolicy.MaxDelay {
|
||||
delay = w.retryPolicy.MaxDelay
|
||||
}
|
||||
|
||||
jitterFn := w.jitterNanos
|
||||
if jitterFn == nil {
|
||||
jitterFn = func(max int64) int64 {
|
||||
if max <= 0 {
|
||||
return 0
|
||||
}
|
||||
n, err := crand.Int(crand.Reader, big.NewInt(max))
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return n.Int64()
|
||||
}
|
||||
}
|
||||
|
||||
jitter := time.Duration(jitterFn(int64(delay) / 2))
|
||||
sleepFn := w.sleep
|
||||
if sleepFn == nil {
|
||||
sleepFn = time.Sleep
|
||||
}
|
||||
sleepFn(delay + jitter)
|
||||
}
|
||||
|
||||
func allowNotifyHTTPOverride() bool {
|
||||
if strings.HasSuffix(os.Args[0], ".test") {
|
||||
return true
|
||||
}
|
||||
|
||||
allowHTTP := strings.EqualFold(strings.TrimSpace(os.Getenv("CHARON_NOTIFY_ALLOW_HTTP")), "true")
|
||||
if !allowHTTP {
|
||||
return false
|
||||
}
|
||||
|
||||
environment := strings.ToLower(strings.TrimSpace(os.Getenv("CHARON_ENV")))
|
||||
return environment == "development" || environment == "test"
|
||||
}
|
||||
|
||||
func notifyMaxRedirects() int {
|
||||
raw := strings.TrimSpace(os.Getenv("CHARON_NOTIFY_MAX_REDIRECTS"))
|
||||
if raw == "" {
|
||||
return 0
|
||||
}
|
||||
|
||||
value, err := strconv.Atoi(raw)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
if value < 0 {
|
||||
return 0
|
||||
}
|
||||
if value > 5 {
|
||||
return 5
|
||||
}
|
||||
return value
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user