Compare commits
69 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bfe535d36a | ||
|
|
aaf52475ee | ||
|
|
cd35f6d8c7 | ||
|
|
85b0bb1f5e | ||
|
|
36d561bbb8 | ||
|
|
fccb1f06ac | ||
|
|
cf46ff0a3b | ||
|
|
a66659476d | ||
|
|
7a8b0343e4 | ||
|
|
cc3077d709 | ||
|
|
d1362a7fba | ||
|
|
4e9e1919a8 | ||
|
|
f19f53ed9a | ||
|
|
f062dc206e | ||
|
|
a97cb334a2 | ||
|
|
cf52a943b5 | ||
|
|
46d0ecc4fb | ||
|
|
348c5e5405 | ||
|
|
25dbe82360 | ||
|
|
fc404da455 | ||
|
|
ed27fb0da9 | ||
|
|
afbd50b43f | ||
|
|
ad2d30b525 | ||
|
|
a570a3327f | ||
|
|
0fd00575a2 | ||
|
|
a3d1ae3742 | ||
|
|
6f408f62ba | ||
|
|
e92e7edd70 | ||
|
|
4e4c4581ea | ||
|
|
3f12ca05a3 | ||
|
|
a681d6aa30 | ||
|
|
3632d0d88c | ||
|
|
a1a9ab2ece | ||
|
|
9c203914dd | ||
|
|
6cfe8ca9f2 | ||
|
|
938b170d98 | ||
|
|
9d6d2cbe53 | ||
|
|
136dd7ef62 | ||
|
|
f0c754cc52 | ||
|
|
28be62dee0 | ||
|
|
49bfbf3f76 | ||
|
|
2f90d936bf | ||
|
|
4a60400af9 | ||
|
|
18d0c235fa | ||
|
|
fe8225753b | ||
|
|
273fb3cf21 | ||
|
|
e3b6693402 | ||
|
|
ac915f14c7 | ||
|
|
5ee52dd4d6 | ||
|
|
b5fd5d5774 | ||
|
|
ae4f5936b3 | ||
|
|
5017fdf4c1 | ||
|
|
f0eda7c93c | ||
|
|
f60a99d0bd | ||
|
|
1440b2722e | ||
|
|
3b92700b5b | ||
|
|
5c0a543669 | ||
|
|
317b695efb | ||
|
|
077e3c1d2b | ||
|
|
b5c5ab0bc3 | ||
|
|
a6188bf2f1 | ||
|
|
16752f4bb1 | ||
|
|
a75dd2dcdd | ||
|
|
63e79664cc | ||
|
|
005b7bdf5b | ||
|
|
0f143af5bc | ||
|
|
76fb800922 | ||
|
|
58f5295652 | ||
|
|
0917a1ae95 |
2
.github/agents/Backend_Dev.agent.md
vendored
2
.github/agents/Backend_Dev.agent.md
vendored
File diff suppressed because one or more lines are too long
2
.github/agents/DevOps.agent.md
vendored
2
.github/agents/DevOps.agent.md
vendored
File diff suppressed because one or more lines are too long
2
.github/agents/Doc_Writer.agent.md
vendored
2
.github/agents/Doc_Writer.agent.md
vendored
File diff suppressed because one or more lines are too long
2
.github/agents/Frontend_Dev.agent.md
vendored
2
.github/agents/Frontend_Dev.agent.md
vendored
File diff suppressed because one or more lines are too long
5
.github/agents/Management.agent.md
vendored
5
.github/agents/Management.agent.md
vendored
File diff suppressed because one or more lines are too long
2
.github/agents/Planning.agent.md
vendored
2
.github/agents/Planning.agent.md
vendored
File diff suppressed because one or more lines are too long
2
.github/agents/Playwright_Dev.agent.md
vendored
2
.github/agents/Playwright_Dev.agent.md
vendored
File diff suppressed because one or more lines are too long
2
.github/agents/QA_Security.agent.md
vendored
2
.github/agents/QA_Security.agent.md
vendored
File diff suppressed because one or more lines are too long
2
.github/agents/Supervisor.agent.md
vendored
2
.github/agents/Supervisor.agent.md
vendored
File diff suppressed because one or more lines are too long
@@ -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.2 | 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 |
|
||||
|
||||
47
.github/renovate.json
vendored
47
.github/renovate.json
vendored
@@ -36,6 +36,19 @@
|
||||
"platformAutomerge": true,
|
||||
|
||||
"customManagers": [
|
||||
{
|
||||
"customType": "regex",
|
||||
"description": "Track caddy-security plugin version in Dockerfile",
|
||||
"managerFilePatterns": [
|
||||
"/^Dockerfile$/"
|
||||
],
|
||||
"matchStrings": [
|
||||
"ARG CADDY_SECURITY_VERSION=(?<currentValue>[^\\s]+)"
|
||||
],
|
||||
"depNameTemplate": "github.com/greenpau/caddy-security",
|
||||
"datasourceTemplate": "go",
|
||||
"versioningTemplate": "semver"
|
||||
},
|
||||
{
|
||||
"customType": "regex",
|
||||
"description": "Track Go dependencies patched in Dockerfile for Caddy CVE fixes",
|
||||
@@ -117,13 +130,45 @@
|
||||
{
|
||||
"customType": "regex",
|
||||
"description": "Track GO_VERSION in Actions workflows",
|
||||
"fileMatch": ["^\\.github/workflows/.*\\.yml$"],
|
||||
"managerFilePatterns": ["/^\\.github/workflows/.*\\.yml$/"],
|
||||
"matchStrings": [
|
||||
"GO_VERSION: ['\"]?(?<currentValue>[\\d\\.]+)['\"]?"
|
||||
],
|
||||
"depNameTemplate": "golang/go",
|
||||
"datasourceTemplate": "golang-version",
|
||||
"versioningTemplate": "semver"
|
||||
},
|
||||
{
|
||||
"customType": "regex",
|
||||
"description": "Track Syft version in workflows and scripts",
|
||||
"managerFilePatterns": [
|
||||
"/^\\.github/workflows/nightly-build\\.yml$/",
|
||||
"/^\\.github/skills/security-scan-docker-image-scripts/run\\.sh$/"
|
||||
],
|
||||
"matchStrings": [
|
||||
"SYFT_VERSION=\\\"v(?<currentValue>[^\\\"\\s]+)\\\"",
|
||||
"set_default_env \\\"SYFT_VERSION\\\" \\\"v(?<currentValue>[^\\\"]+)\\\""
|
||||
],
|
||||
"depNameTemplate": "anchore/syft",
|
||||
"datasourceTemplate": "github-releases",
|
||||
"versioningTemplate": "semver",
|
||||
"extractVersionTemplate": "^v(?<version>.*)$"
|
||||
},
|
||||
{
|
||||
"customType": "regex",
|
||||
"description": "Track Grype version in workflows and scripts",
|
||||
"managerFilePatterns": [
|
||||
"/^\\.github/workflows/supply-chain-pr\\.yml$/",
|
||||
"/^\\.github/skills/security-scan-docker-image-scripts/run\\.sh$/"
|
||||
],
|
||||
"matchStrings": [
|
||||
"anchore/grype/main/install\\.sh \\| sh -s -- -b /usr/local/bin v(?<currentValue>[0-9]+\\.[0-9]+\\.[0-9]+)",
|
||||
"set_default_env \\\"GRYPE_VERSION\\\" \\\"v(?<currentValue>[^\\\"]+)\\\""
|
||||
],
|
||||
"depNameTemplate": "anchore/grype",
|
||||
"datasourceTemplate": "github-releases",
|
||||
"versioningTemplate": "semver",
|
||||
"extractVersionTemplate": "^v(?<version>.*)$"
|
||||
}
|
||||
],
|
||||
|
||||
|
||||
2
.github/workflows/codecov-upload.yml
vendored
2
.github/workflows/codecov-upload.yml
vendored
@@ -154,7 +154,7 @@ jobs:
|
||||
ref: ${{ github.sha }}
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: 'npm'
|
||||
|
||||
13
.github/workflows/codeql.yml
vendored
13
.github/workflows/codeql.yml
vendored
@@ -39,14 +39,19 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
# Use github.ref (full ref path) instead of github.ref_name:
|
||||
# - push/schedule: resolves to refs/heads/<branch>, checking out latest HEAD
|
||||
# - pull_request: resolves to refs/pull/<n>/merge, the correct PR merge ref
|
||||
# github.ref_name fails for PRs because it yields "<n>/merge" which checkout
|
||||
# interprets as a branch name (refs/heads/<n>/merge) that does not exist.
|
||||
ref: ${{ github.ref }}
|
||||
|
||||
- name: Verify CodeQL parity guard
|
||||
if: matrix.language == 'go'
|
||||
run: bash scripts/ci/check-codeql-parity.sh
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4
|
||||
uses: github/codeql-action/init@c793b717bc78562f491db7b0e93a3a178b099162 # v4
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
queries: security-and-quality
|
||||
@@ -86,10 +91,10 @@ jobs:
|
||||
run: mkdir -p sarif-results
|
||||
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4
|
||||
uses: github/codeql-action/autobuild@c793b717bc78562f491db7b0e93a3a178b099162 # v4
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4
|
||||
uses: github/codeql-action/analyze@c793b717bc78562f491db7b0e93a3a178b099162 # v4
|
||||
with:
|
||||
category: "/language:${{ matrix.language }}"
|
||||
output: sarif-results/${{ matrix.language }}
|
||||
|
||||
26
.github/workflows/docker-build.yml
vendored
26
.github/workflows/docker-build.yml
vendored
@@ -115,7 +115,7 @@ jobs:
|
||||
|
||||
- name: Set up QEMU
|
||||
if: steps.skip.outputs.skip_build != 'true'
|
||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
||||
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
|
||||
- name: Set up Docker Buildx
|
||||
if: steps.skip.outputs.skip_build != 'true'
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
@@ -129,7 +129,7 @@ jobs:
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
if: steps.skip.outputs.skip_build != 'true'
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
with:
|
||||
registry: ${{ env.GHCR_REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
@@ -137,7 +137,7 @@ jobs:
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
if: steps.skip.outputs.skip_build != 'true' && env.HAS_DOCKERHUB_TOKEN == 'true'
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
with:
|
||||
registry: docker.io
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
@@ -531,7 +531,7 @@ jobs:
|
||||
|
||||
- name: Run Trivy scan (table output)
|
||||
if: env.TRIGGER_EVENT != 'pull_request' && steps.skip.outputs.skip_build != 'true' && steps.skip.outputs.is_feature_push != 'true'
|
||||
uses: aquasecurity/trivy-action@e368e328979b113139d6f9068e03accaed98a518 # 0.34.1
|
||||
uses: aquasecurity/trivy-action@97e0b3872f55f89b95b2f65b3dbab56962816478 # 0.34.2
|
||||
with:
|
||||
image-ref: ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build-and-push.outputs.digest }}
|
||||
format: 'table'
|
||||
@@ -542,7 +542,7 @@ jobs:
|
||||
- name: Run Trivy vulnerability scanner (SARIF)
|
||||
if: env.TRIGGER_EVENT != 'pull_request' && steps.skip.outputs.skip_build != 'true' && steps.skip.outputs.is_feature_push != 'true'
|
||||
id: trivy
|
||||
uses: aquasecurity/trivy-action@e368e328979b113139d6f9068e03accaed98a518 # 0.34.1
|
||||
uses: aquasecurity/trivy-action@97e0b3872f55f89b95b2f65b3dbab56962816478 # 0.34.2
|
||||
with:
|
||||
image-ref: ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build-and-push.outputs.digest }}
|
||||
format: 'sarif'
|
||||
@@ -562,7 +562,7 @@ jobs:
|
||||
|
||||
- name: Upload Trivy results
|
||||
if: env.TRIGGER_EVENT != 'pull_request' && steps.skip.outputs.skip_build != 'true' && steps.trivy-check.outputs.exists == 'true'
|
||||
uses: github/codeql-action/upload-sarif@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
|
||||
uses: github/codeql-action/upload-sarif@c793b717bc78562f491db7b0e93a3a178b099162 # v4.32.5
|
||||
with:
|
||||
sarif_file: 'trivy-results.sarif'
|
||||
category: '.github/workflows/docker-build.yml:build-and-push'
|
||||
@@ -657,7 +657,7 @@ jobs:
|
||||
echo "image_ref=${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}:${PR_TAG}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
with:
|
||||
registry: ${{ env.GHCR_REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
@@ -689,7 +689,7 @@ jobs:
|
||||
echo "✅ Image freshness validated"
|
||||
|
||||
- name: Run Trivy scan on PR image (table output)
|
||||
uses: aquasecurity/trivy-action@e368e328979b113139d6f9068e03accaed98a518 # 0.34.1
|
||||
uses: aquasecurity/trivy-action@97e0b3872f55f89b95b2f65b3dbab56962816478 # 0.34.2
|
||||
with:
|
||||
image-ref: ${{ steps.pr-image.outputs.image_ref }}
|
||||
format: 'table'
|
||||
@@ -698,7 +698,7 @@ jobs:
|
||||
|
||||
- name: Run Trivy scan on PR image (SARIF - blocking)
|
||||
id: trivy-scan
|
||||
uses: aquasecurity/trivy-action@e368e328979b113139d6f9068e03accaed98a518 # 0.34.1
|
||||
uses: aquasecurity/trivy-action@97e0b3872f55f89b95b2f65b3dbab56962816478 # 0.34.2
|
||||
with:
|
||||
image-ref: ${{ steps.pr-image.outputs.image_ref }}
|
||||
format: 'sarif'
|
||||
@@ -719,14 +719,14 @@ jobs:
|
||||
|
||||
- name: Upload Trivy scan results
|
||||
if: always() && steps.trivy-pr-check.outputs.exists == 'true'
|
||||
uses: github/codeql-action/upload-sarif@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
|
||||
uses: github/codeql-action/upload-sarif@c793b717bc78562f491db7b0e93a3a178b099162 # v4.32.5
|
||||
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
|
||||
uses: github/codeql-action/upload-sarif@c793b717bc78562f491db7b0e93a3a178b099162 # v4.32.5
|
||||
with:
|
||||
sarif_file: 'trivy-pr-results.sarif'
|
||||
category: '.github/workflows/docker-build.yml:build-and-push'
|
||||
@@ -734,7 +734,7 @@ jobs:
|
||||
|
||||
- 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
|
||||
uses: github/codeql-action/upload-sarif@c793b717bc78562f491db7b0e93a3a178b099162 # v4.32.5
|
||||
with:
|
||||
sarif_file: 'trivy-pr-results.sarif'
|
||||
category: '.github/workflows/docker-publish.yml:build-and-push'
|
||||
@@ -742,7 +742,7 @@ jobs:
|
||||
|
||||
- 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
|
||||
uses: github/codeql-action/upload-sarif@c793b717bc78562f491db7b0e93a3a178b099162 # v4.32.5
|
||||
with:
|
||||
sarif_file: 'trivy-pr-results.sarif'
|
||||
category: 'trivy-nightly'
|
||||
|
||||
2
.github/workflows/docs-to-issues.yml
vendored
2
.github/workflows/docs-to-issues.yml
vendored
@@ -44,7 +44,7 @@ jobs:
|
||||
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
|
||||
|
||||
2
.github/workflows/docs.yml
vendored
2
.github/workflows/docs.yml
vendored
@@ -38,7 +38,7 @@ jobs:
|
||||
|
||||
# Step 2: Set up Node.js (for building any JS-based doc tools)
|
||||
- name: 🔧 Set up Node.js
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
|
||||
|
||||
26
.github/workflows/e2e-tests-split.yml
vendored
26
.github/workflows/e2e-tests-split.yml
vendored
@@ -150,7 +150,7 @@ jobs:
|
||||
|
||||
- name: Set up Node.js
|
||||
if: steps.resolve-image.outputs.image_source == 'build'
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: 'npm'
|
||||
@@ -224,7 +224,7 @@ jobs:
|
||||
ref: ${{ github.sha }}
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: 'npm'
|
||||
@@ -232,7 +232,7 @@ jobs:
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
if: needs.build.outputs.image_source == 'registry'
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
with:
|
||||
registry: ${{ env.DOCKERHUB_REGISTRY }}
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
@@ -426,7 +426,7 @@ jobs:
|
||||
ref: ${{ github.sha }}
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: 'npm'
|
||||
@@ -434,7 +434,7 @@ jobs:
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
if: needs.build.outputs.image_source == 'registry'
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
with:
|
||||
registry: ${{ env.DOCKERHUB_REGISTRY }}
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
@@ -636,7 +636,7 @@ jobs:
|
||||
ref: ${{ github.sha }}
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: 'npm'
|
||||
@@ -644,7 +644,7 @@ jobs:
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
if: needs.build.outputs.image_source == 'registry'
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
with:
|
||||
registry: ${{ env.DOCKERHUB_REGISTRY }}
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
@@ -858,7 +858,7 @@ jobs:
|
||||
ref: ${{ github.sha }}
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: 'npm'
|
||||
@@ -898,7 +898,7 @@ jobs:
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
if: needs.build.outputs.image_source == 'registry'
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
with:
|
||||
registry: ${{ env.DOCKERHUB_REGISTRY }}
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
@@ -1095,7 +1095,7 @@ jobs:
|
||||
ref: ${{ github.sha }}
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: 'npm'
|
||||
@@ -1135,7 +1135,7 @@ jobs:
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
if: needs.build.outputs.image_source == 'registry'
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
with:
|
||||
registry: ${{ env.DOCKERHUB_REGISTRY }}
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
@@ -1340,7 +1340,7 @@ jobs:
|
||||
ref: ${{ github.sha }}
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: 'npm'
|
||||
@@ -1380,7 +1380,7 @@ jobs:
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
if: needs.build.outputs.image_source == 'registry'
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
with:
|
||||
registry: ${{ env.DOCKERHUB_REGISTRY }}
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
|
||||
12
.github/workflows/nightly-build.yml
vendored
12
.github/workflows/nightly-build.yml
vendored
@@ -162,13 +162,13 @@ jobs:
|
||||
run: echo "IMAGE_NAME_LC=${IMAGE_NAME,,}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
||||
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
with:
|
||||
registry: ${{ env.GHCR_REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
@@ -176,7 +176,7 @@ jobs:
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
if: env.HAS_DOCKERHUB_TOKEN == 'true'
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
with:
|
||||
registry: docker.io
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
@@ -330,7 +330,7 @@ jobs:
|
||||
run: echo "IMAGE_NAME_LC=${IMAGE_NAME,,}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
with:
|
||||
registry: ${{ env.GHCR_REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
@@ -396,14 +396,14 @@ jobs:
|
||||
severity-cutoff: high
|
||||
|
||||
- name: Scan with Trivy
|
||||
uses: aquasecurity/trivy-action@e368e328979b113139d6f9068e03accaed98a518 # 0.34.1
|
||||
uses: aquasecurity/trivy-action@97e0b3872f55f89b95b2f65b3dbab56962816478 # 0.34.2
|
||||
with:
|
||||
image-ref: ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}@${{ needs.build-and-push-nightly.outputs.digest }}
|
||||
format: 'sarif'
|
||||
output: 'trivy-nightly.sarif'
|
||||
|
||||
- name: Upload Trivy results
|
||||
uses: github/codeql-action/upload-sarif@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
|
||||
uses: github/codeql-action/upload-sarif@c793b717bc78562f491db7b0e93a3a178b099162 # v4.32.5
|
||||
with:
|
||||
sarif_file: 'trivy-nightly.sarif'
|
||||
category: 'trivy-nightly'
|
||||
|
||||
2
.github/workflows/propagate-changes.yml
vendored
2
.github/workflows/propagate-changes.yml
vendored
@@ -28,7 +28,7 @@ jobs:
|
||||
(github.event.workflow_run.head_branch == 'main' || github.event.workflow_run.head_branch == 'development')
|
||||
steps:
|
||||
- name: Set up Node (for github-script)
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
|
||||
|
||||
3
.github/workflows/quality-checks.yml
vendored
3
.github/workflows/quality-checks.yml
vendored
@@ -4,6 +4,7 @@ on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- nightly
|
||||
- main
|
||||
|
||||
concurrency:
|
||||
@@ -248,7 +249,7 @@ jobs:
|
||||
bash "scripts/repo_health_check.sh"
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: 'npm'
|
||||
|
||||
2
.github/workflows/release-goreleaser.yml
vendored
2
.github/workflows/release-goreleaser.yml
vendored
@@ -51,7 +51,7 @@ jobs:
|
||||
cache-dependency-path: backend/go.sum
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
|
||||
|
||||
6
.github/workflows/security-pr.yml
vendored
6
.github/workflows/security-pr.yml
vendored
@@ -362,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@4c61e6329bab9be735ca35291551614bc663dff3
|
||||
uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1
|
||||
with:
|
||||
scan-type: 'fs'
|
||||
scan-ref: ${{ steps.extract.outputs.binary_path }}
|
||||
@@ -385,7 +385,7 @@ jobs:
|
||||
- name: Upload Trivy SARIF to GitHub Security
|
||||
if: always() && steps.trivy-sarif-check.outputs.exists == 'true'
|
||||
# github/codeql-action v4
|
||||
uses: github/codeql-action/upload-sarif@0ec47d036c68ae0cf94c629009b1029407111281
|
||||
uses: github/codeql-action/upload-sarif@a5b959e10d29aec4f277040b4d27d0f6bea2322a
|
||||
with:
|
||||
sarif_file: 'trivy-binary-results.sarif'
|
||||
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) }}
|
||||
@@ -394,7 +394,7 @@ jobs:
|
||||
- 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@4c61e6329bab9be735ca35291551614bc663dff3
|
||||
uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1
|
||||
with:
|
||||
scan-type: 'fs'
|
||||
scan-ref: ${{ steps.extract.outputs.binary_path }}
|
||||
|
||||
17
.github/workflows/security-weekly-rebuild.yml
vendored
17
.github/workflows/security-weekly-rebuild.yml
vendored
@@ -36,13 +36,18 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
# Explicitly fetch the current HEAD of the ref at run time, not the
|
||||
# SHA that was frozen when this scheduled job was queued. Without this,
|
||||
# a queued job can run days later with stale code.
|
||||
ref: ${{ github.ref_name }}
|
||||
|
||||
- name: Normalize image name
|
||||
run: |
|
||||
echo "IMAGE_NAME=$(echo "${{ env.IMAGE_NAME }}" | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
||||
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
@@ -56,7 +61,7 @@ jobs:
|
||||
echo "Base image digest: $DIGEST"
|
||||
|
||||
- name: Log in to Container Registry
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
@@ -88,7 +93,7 @@ jobs:
|
||||
BASE_IMAGE=${{ steps.base-image.outputs.digest }}
|
||||
|
||||
- name: Run Trivy vulnerability scanner (CRITICAL+HIGH)
|
||||
uses: aquasecurity/trivy-action@e368e328979b113139d6f9068e03accaed98a518 # 0.34.1
|
||||
uses: aquasecurity/trivy-action@97e0b3872f55f89b95b2f65b3dbab56962816478 # 0.34.2
|
||||
with:
|
||||
image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build.outputs.digest }}
|
||||
format: 'table'
|
||||
@@ -98,7 +103,7 @@ jobs:
|
||||
|
||||
- name: Run Trivy vulnerability scanner (SARIF)
|
||||
id: trivy-sarif
|
||||
uses: aquasecurity/trivy-action@e368e328979b113139d6f9068e03accaed98a518 # 0.34.1
|
||||
uses: aquasecurity/trivy-action@97e0b3872f55f89b95b2f65b3dbab56962816478 # 0.34.2
|
||||
with:
|
||||
image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build.outputs.digest }}
|
||||
format: 'sarif'
|
||||
@@ -106,12 +111,12 @@ jobs:
|
||||
severity: 'CRITICAL,HIGH,MEDIUM'
|
||||
|
||||
- name: Upload Trivy results to GitHub Security
|
||||
uses: github/codeql-action/upload-sarif@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
|
||||
uses: github/codeql-action/upload-sarif@c793b717bc78562f491db7b0e93a3a178b099162 # v4.32.5
|
||||
with:
|
||||
sarif_file: 'trivy-weekly-results.sarif'
|
||||
|
||||
- name: Run Trivy vulnerability scanner (JSON for artifact)
|
||||
uses: aquasecurity/trivy-action@e368e328979b113139d6f9068e03accaed98a518 # 0.34.1
|
||||
uses: aquasecurity/trivy-action@97e0b3872f55f89b95b2f65b3dbab56962816478 # 0.34.2
|
||||
with:
|
||||
image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build.outputs.digest }}
|
||||
format: 'json'
|
||||
|
||||
2
.github/workflows/supply-chain-pr.yml
vendored
2
.github/workflows/supply-chain-pr.yml
vendored
@@ -362,7 +362,7 @@ jobs:
|
||||
|
||||
- name: Upload SARIF to GitHub Security
|
||||
if: steps.check-artifact.outputs.artifact_found == 'true'
|
||||
uses: github/codeql-action/upload-sarif@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4
|
||||
uses: github/codeql-action/upload-sarif@c793b717bc78562f491db7b0e93a3a178b099162 # v4
|
||||
continue-on-error: true
|
||||
with:
|
||||
sarif_file: grype-results.sarif
|
||||
|
||||
2
.vscode/tasks.json
vendored
2
.vscode/tasks.json
vendored
@@ -727,7 +727,7 @@
|
||||
{
|
||||
"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",
|
||||
"command": "cd /projects/Charon && bash scripts/caddy-compat-matrix.sh --candidate-version 2.11.2 --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": []
|
||||
},
|
||||
|
||||
@@ -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.1 | Embedded HTTP/HTTPS proxy |
|
||||
| **Reverse Proxy** | Caddy Server | 2.11.2 | 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 |
|
||||
|
||||
16
Dockerfile
16
Dockerfile
@@ -14,11 +14,13 @@ 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.1 build.
|
||||
ARG CADDY_VERSION=2.11.1
|
||||
ARG CADDY_CANDIDATE_VERSION=2.11.1
|
||||
## If the requested tag isn't available, fall back to a known-good v2.11.2 build.
|
||||
ARG CADDY_VERSION=2.11.2
|
||||
ARG CADDY_CANDIDATE_VERSION=2.11.2
|
||||
ARG CADDY_USE_CANDIDATE=0
|
||||
ARG CADDY_PATCH_SCENARIO=B
|
||||
# renovate: datasource=go depName=github.com/greenpau/caddy-security
|
||||
ARG CADDY_SECURITY_VERSION=1.1.36
|
||||
## 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
|
||||
@@ -134,7 +136,7 @@ RUN set -eux; \
|
||||
# Note: xx-go install puts binaries in /go/bin/TARGETOS_TARGETARCH/dlv if cross-compiling.
|
||||
# We find it and move it to /go/bin/dlv so it's in a consistent location for the next stage.
|
||||
# renovate: datasource=go depName=github.com/go-delve/delve
|
||||
ARG DLV_VERSION=1.26.0
|
||||
ARG DLV_VERSION=1.26.1
|
||||
# hadolint ignore=DL3059,DL4006
|
||||
RUN CGO_ENABLED=0 xx-go install github.com/go-delve/delve/cmd/dlv@v${DLV_VERSION} && \
|
||||
DLV_PATH=$(find /go/bin -name dlv -type f | head -n 1) && \
|
||||
@@ -202,6 +204,7 @@ ARG CADDY_VERSION
|
||||
ARG CADDY_CANDIDATE_VERSION
|
||||
ARG CADDY_USE_CANDIDATE
|
||||
ARG CADDY_PATCH_SCENARIO
|
||||
ARG CADDY_SECURITY_VERSION
|
||||
# renovate: datasource=go depName=github.com/caddyserver/xcaddy
|
||||
ARG XCADDY_VERSION=0.4.5
|
||||
|
||||
@@ -229,7 +232,8 @@ RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||
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_TARGET_VERSION} \
|
||||
--with github.com/greenpau/caddy-security \
|
||||
--with github.com/caddyserver/caddy/v2@v${CADDY_TARGET_VERSION} \
|
||||
--with github.com/greenpau/caddy-security@v${CADDY_SECURITY_VERSION} \
|
||||
--with github.com/corazawaf/coraza-caddy/v2 \
|
||||
--with github.com/hslatman/caddy-crowdsec-bouncer@v0.10.0 \
|
||||
--with github.com/zhangjiayin/caddy-geoip2 \
|
||||
@@ -413,7 +417,7 @@ SHELL ["/bin/ash", "-o", "pipefail", "-c"]
|
||||
# Note: In production, users should provide their own MaxMind license key
|
||||
# This uses the publicly available GeoLite2 database
|
||||
# In CI, timeout quickly rather than retrying to save build time
|
||||
ARG GEOLITE2_COUNTRY_SHA256=d3031e02196523cbb5f74291122033f2be277b2130abedd4b5bee52ba79832be
|
||||
ARG GEOLITE2_COUNTRY_SHA256=b79afc28a0a52f89c15e8d92b05c173f314dd4f687719f96cf921012d900fcce
|
||||
RUN mkdir -p /app/data/geoip && \
|
||||
if [ -n "$CI" ]; then \
|
||||
echo "⏱️ CI detected - quick download (10s timeout, no retries)"; \
|
||||
|
||||
@@ -40,7 +40,7 @@ func TestResetPasswordCommand_Succeeds(t *testing.T) {
|
||||
}
|
||||
|
||||
email := "user@example.com"
|
||||
user := models.User{UUID: "u-1", Email: email, Name: "User", Role: "admin", Enabled: true}
|
||||
user := models.User{UUID: "u-1", Email: email, Name: "User", Role: models.RoleAdmin, Enabled: true}
|
||||
user.PasswordHash = "$2a$10$example_hashed_password"
|
||||
if err = db.Create(&user).Error; err != nil {
|
||||
t.Fatalf("seed user: %v", err)
|
||||
@@ -257,7 +257,7 @@ func TestMain_ResetPasswordCommand_InProcess(t *testing.T) {
|
||||
}
|
||||
|
||||
email := "user@example.com"
|
||||
user := models.User{UUID: "u-1", Email: email, Name: "User", Role: "admin", Enabled: true}
|
||||
user := models.User{UUID: "u-1", Email: email, Name: "User", Role: models.RoleAdmin, Enabled: true}
|
||||
user.PasswordHash = "$2a$10$example_hashed_password"
|
||||
user.FailedLoginAttempts = 3
|
||||
if err = db.Create(&user).Error; err != nil {
|
||||
|
||||
@@ -72,7 +72,7 @@ func TestSeedMain_ForceAdminUpdatesExistingUserPassword(t *testing.T) {
|
||||
UUID: "existing-user",
|
||||
Email: "admin@localhost",
|
||||
Name: "Old Name",
|
||||
Role: "viewer",
|
||||
Role: models.RolePassthrough,
|
||||
Enabled: false,
|
||||
PasswordHash: "$2a$10$example_hashed_password",
|
||||
}
|
||||
@@ -134,7 +134,7 @@ func TestSeedMain_ForceAdminWithoutPasswordUpdatesMetadata(t *testing.T) {
|
||||
UUID: "existing-user-no-pass",
|
||||
Email: "admin@localhost",
|
||||
Name: "Old Name",
|
||||
Role: "viewer",
|
||||
Role: models.RolePassthrough,
|
||||
Enabled: false,
|
||||
PasswordHash: "$2a$10$example_hashed_password",
|
||||
}
|
||||
|
||||
@@ -84,11 +84,11 @@ require (
|
||||
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/contrib/instrumentation/net/http/otelhttp v0.66.0 // indirect
|
||||
go.opentelemetry.io/otel v1.41.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.40.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.40.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.41.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.41.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
|
||||
|
||||
@@ -176,22 +176,22 @@ go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF
|
||||
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/contrib/instrumentation/net/http/otelhttp v0.66.0 h1:PnV4kVnw0zOmwwFkAzCN5O07fw1YOIQor120zrh0AVo=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.66.0/go.mod h1:ofAwF4uinaf8SXdVzzbL4OsxJ3VfeEg3f/F6CeF49/Y=
|
||||
go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c=
|
||||
go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE=
|
||||
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.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/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ=
|
||||
go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps=
|
||||
go.opentelemetry.io/otel/sdk v1.41.0 h1:YPIEXKmiAwkGl3Gu1huk1aYWwtpRLeskpV+wPisxBp8=
|
||||
go.opentelemetry.io/otel/sdk v1.41.0/go.mod h1:ahFdU0G5y8IxglBf0QBJXgSe7agzjE4GiTJ6HT9ud90=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.41.0 h1:siZQIYBAUd1rlIWQT2uCxWJxcCO7q3TriaMlf08rXw8=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.41.0/go.mod h1:HNBuSvT7ROaGtGI50ArdRLUnvRTRGniSUZbxiWxSO8Y=
|
||||
go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0=
|
||||
go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis=
|
||||
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=
|
||||
|
||||
@@ -381,7 +381,7 @@ func (h *AuthHandler) Verify(c *gin.Context) {
|
||||
|
||||
// Set headers for downstream services
|
||||
c.Header("X-Forwarded-User", user.Email)
|
||||
c.Header("X-Forwarded-Groups", user.Role)
|
||||
c.Header("X-Forwarded-Groups", string(user.Role))
|
||||
c.Header("X-Forwarded-Name", user.Name)
|
||||
|
||||
// Return 200 OK - access granted
|
||||
|
||||
@@ -430,7 +430,7 @@ func TestAuthHandler_Me(t *testing.T) {
|
||||
UUID: uuid.NewString(),
|
||||
Email: "me@example.com",
|
||||
Name: "Me User",
|
||||
Role: "admin",
|
||||
Role: models.RoleAdmin,
|
||||
}
|
||||
db.Create(user)
|
||||
|
||||
@@ -630,7 +630,7 @@ func TestAuthHandler_Verify_ValidToken(t *testing.T) {
|
||||
UUID: uuid.NewString(),
|
||||
Email: "test@example.com",
|
||||
Name: "Test User",
|
||||
Role: "user",
|
||||
Role: models.RoleUser,
|
||||
Enabled: true,
|
||||
}
|
||||
_ = user.SetPassword("password123")
|
||||
@@ -661,7 +661,7 @@ func TestAuthHandler_Verify_BearerToken(t *testing.T) {
|
||||
UUID: uuid.NewString(),
|
||||
Email: "bearer@example.com",
|
||||
Name: "Bearer User",
|
||||
Role: "admin",
|
||||
Role: models.RoleAdmin,
|
||||
Enabled: true,
|
||||
}
|
||||
_ = user.SetPassword("password123")
|
||||
@@ -690,7 +690,7 @@ func TestAuthHandler_Verify_DisabledUser(t *testing.T) {
|
||||
UUID: uuid.NewString(),
|
||||
Email: "disabled@example.com",
|
||||
Name: "Disabled User",
|
||||
Role: "user",
|
||||
Role: models.RoleUser,
|
||||
}
|
||||
_ = user.SetPassword("password123")
|
||||
db.Create(user)
|
||||
@@ -730,7 +730,7 @@ func TestAuthHandler_Verify_ForwardAuthDenied(t *testing.T) {
|
||||
UUID: uuid.NewString(),
|
||||
Email: "denied@example.com",
|
||||
Name: "Denied User",
|
||||
Role: "user",
|
||||
Role: models.RoleUser,
|
||||
Enabled: true,
|
||||
PermissionMode: models.PermissionModeDenyAll,
|
||||
}
|
||||
@@ -795,7 +795,7 @@ func TestAuthHandler_VerifyStatus_Authenticated(t *testing.T) {
|
||||
UUID: uuid.NewString(),
|
||||
Email: "status@example.com",
|
||||
Name: "Status User",
|
||||
Role: "user",
|
||||
Role: models.RoleUser,
|
||||
Enabled: true,
|
||||
}
|
||||
_ = user.SetPassword("password123")
|
||||
@@ -828,7 +828,7 @@ func TestAuthHandler_VerifyStatus_DisabledUser(t *testing.T) {
|
||||
UUID: uuid.NewString(),
|
||||
Email: "disabled2@example.com",
|
||||
Name: "Disabled User 2",
|
||||
Role: "user",
|
||||
Role: models.RoleUser,
|
||||
}
|
||||
_ = user.SetPassword("password123")
|
||||
db.Create(user)
|
||||
@@ -880,7 +880,7 @@ func TestAuthHandler_GetAccessibleHosts_AllowAll(t *testing.T) {
|
||||
UUID: uuid.NewString(),
|
||||
Email: "allowall@example.com",
|
||||
Name: "Allow All User",
|
||||
Role: "user",
|
||||
Role: models.RoleUser,
|
||||
Enabled: true,
|
||||
PermissionMode: models.PermissionModeAllowAll,
|
||||
}
|
||||
@@ -917,7 +917,7 @@ func TestAuthHandler_GetAccessibleHosts_DenyAll(t *testing.T) {
|
||||
UUID: uuid.NewString(),
|
||||
Email: "denyall@example.com",
|
||||
Name: "Deny All User",
|
||||
Role: "user",
|
||||
Role: models.RoleUser,
|
||||
Enabled: true,
|
||||
PermissionMode: models.PermissionModeDenyAll,
|
||||
}
|
||||
@@ -956,7 +956,7 @@ func TestAuthHandler_GetAccessibleHosts_PermittedHosts(t *testing.T) {
|
||||
UUID: uuid.NewString(),
|
||||
Email: "permitted@example.com",
|
||||
Name: "Permitted User",
|
||||
Role: "user",
|
||||
Role: models.RoleUser,
|
||||
Enabled: true,
|
||||
PermissionMode: models.PermissionModeDenyAll,
|
||||
PermittedHosts: []models.ProxyHost{*host1}, // Only host1
|
||||
@@ -1111,7 +1111,7 @@ func TestAuthHandler_Logout_InvalidatesBearerSession(t *testing.T) {
|
||||
UUID: uuid.NewString(),
|
||||
Email: "logout-session@example.com",
|
||||
Name: "Logout Session",
|
||||
Role: "admin",
|
||||
Role: models.RoleAdmin,
|
||||
Enabled: true,
|
||||
}
|
||||
_ = user.SetPassword("password123")
|
||||
@@ -1242,7 +1242,7 @@ func TestAuthHandler_Refresh(t *testing.T) {
|
||||
|
||||
handler, db := setupAuthHandler(t)
|
||||
|
||||
user := &models.User{UUID: uuid.NewString(), Email: "refresh@example.com", Name: "Refresh User", Role: "user", Enabled: true}
|
||||
user := &models.User{UUID: uuid.NewString(), Email: "refresh@example.com", Name: "Refresh User", Role: models.RoleUser, Enabled: true}
|
||||
require.NoError(t, user.SetPassword("password123"))
|
||||
require.NoError(t, db.Create(user).Error)
|
||||
|
||||
@@ -1332,7 +1332,7 @@ func TestAuthHandler_Verify_UsesOriginalHostFallback(t *testing.T) {
|
||||
UUID: uuid.NewString(),
|
||||
Email: "originalhost@example.com",
|
||||
Name: "Original Host User",
|
||||
Role: "user",
|
||||
Role: models.RoleUser,
|
||||
Enabled: true,
|
||||
PermissionMode: models.PermissionModeAllowAll,
|
||||
}
|
||||
|
||||
@@ -384,10 +384,7 @@ func (h *EmergencyHandler) syncSecurityState(ctx context.Context) {
|
||||
// POST /api/v1/emergency/token/generate
|
||||
// Requires admin authentication
|
||||
func (h *EmergencyHandler) GenerateToken(c *gin.Context) {
|
||||
// Check admin role
|
||||
role, exists := c.Get("role")
|
||||
if !exists || role != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
|
||||
if !requireAdmin(c) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -437,10 +434,7 @@ func (h *EmergencyHandler) GenerateToken(c *gin.Context) {
|
||||
// GET /api/v1/emergency/token/status
|
||||
// Requires admin authentication
|
||||
func (h *EmergencyHandler) GetTokenStatus(c *gin.Context) {
|
||||
// Check admin role
|
||||
role, exists := c.Get("role")
|
||||
if !exists || role != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
|
||||
if !requireAdmin(c) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -458,10 +452,7 @@ func (h *EmergencyHandler) GetTokenStatus(c *gin.Context) {
|
||||
// DELETE /api/v1/emergency/token
|
||||
// Requires admin authentication
|
||||
func (h *EmergencyHandler) RevokeToken(c *gin.Context) {
|
||||
// Check admin role
|
||||
role, exists := c.Get("role")
|
||||
if !exists || role != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
|
||||
if !requireAdmin(c) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -485,10 +476,7 @@ func (h *EmergencyHandler) RevokeToken(c *gin.Context) {
|
||||
// PATCH /api/v1/emergency/token/expiration
|
||||
// Requires admin authentication
|
||||
func (h *EmergencyHandler) UpdateTokenExpiration(c *gin.Context) {
|
||||
// Check admin role
|
||||
role, exists := c.Get("role")
|
||||
if !exists || role != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
|
||||
if !requireAdmin(c) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -340,6 +340,11 @@ func TestCommitAndCancel_InvalidSessionUUID(t *testing.T) {
|
||||
r.ServeHTTP(wCommit, reqCommit)
|
||||
assert.Equal(t, http.StatusBadRequest, wCommit.Code)
|
||||
|
||||
wCancelMissing := httptest.NewRecorder()
|
||||
reqCancelMissing, _ := http.NewRequest(http.MethodDelete, "/api/v1/import/cancel", http.NoBody)
|
||||
r.ServeHTTP(wCancelMissing, reqCancelMissing)
|
||||
assert.Equal(t, http.StatusBadRequest, wCancelMissing.Code)
|
||||
|
||||
wCancel := httptest.NewRecorder()
|
||||
reqCancel, _ := http.NewRequest(http.MethodDelete, "/api/v1/import/cancel?session_uuid=.", http.NoBody)
|
||||
r.ServeHTTP(wCancel, reqCancel)
|
||||
|
||||
@@ -310,6 +310,11 @@ func (h *JSONImportHandler) Cancel(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if strings.TrimSpace(req.SessionUUID) == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "session_uuid required"})
|
||||
return
|
||||
}
|
||||
|
||||
// Clean up session if it exists
|
||||
jsonImportSessionsMu.Lock()
|
||||
delete(jsonImportSessions, req.SessionUUID)
|
||||
|
||||
@@ -497,6 +497,62 @@ func TestJSONImportHandler_ConflictDetection(t *testing.T) {
|
||||
assert.Contains(t, conflictDetails, "conflict.com")
|
||||
}
|
||||
|
||||
func TestJSONImportHandler_Cancel_RequiresValidJSONBody(t *testing.T) {
|
||||
db := setupJSONTestDB(t)
|
||||
handler := NewJSONImportHandler(db)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
api := router.Group("/api/v1")
|
||||
handler.RegisterRoutes(api)
|
||||
|
||||
t.Run("missing body", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/import/json/cancel", http.NoBody)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
})
|
||||
|
||||
t.Run("invalid json", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/import/json/cancel", bytes.NewBufferString("{"))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
})
|
||||
|
||||
t.Run("empty object payload", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/import/json/cancel", bytes.NewBufferString("{}"))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
|
||||
var resp map[string]string
|
||||
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "session_uuid required", resp["error"])
|
||||
})
|
||||
|
||||
t.Run("missing session_uuid payload", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/import/json/cancel", bytes.NewBufferString(`{"foo":"bar"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
|
||||
var resp map[string]string
|
||||
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "session_uuid required", resp["error"])
|
||||
})
|
||||
}
|
||||
|
||||
func TestJSONImportHandler_IsCharonFormat(t *testing.T) {
|
||||
db := setupJSONTestDB(t)
|
||||
handler := NewJSONImportHandler(db)
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -293,6 +294,11 @@ func (h *NPMImportHandler) Cancel(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if strings.TrimSpace(req.SessionUUID) == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "session_uuid required"})
|
||||
return
|
||||
}
|
||||
|
||||
// Clean up session if it exists
|
||||
npmImportSessionsMu.Lock()
|
||||
delete(npmImportSessions, req.SessionUUID)
|
||||
|
||||
@@ -453,6 +453,62 @@ func TestNPMImportHandler_Cancel(t *testing.T) {
|
||||
assert.Equal(t, http.StatusNotFound, commitW.Code)
|
||||
}
|
||||
|
||||
func TestNPMImportHandler_Cancel_RequiresValidJSONBody(t *testing.T) {
|
||||
db := setupNPMTestDB(t)
|
||||
handler := NewNPMImportHandler(db)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
api := router.Group("/api/v1")
|
||||
handler.RegisterRoutes(api)
|
||||
|
||||
t.Run("missing body", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/import/npm/cancel", http.NoBody)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
})
|
||||
|
||||
t.Run("invalid json", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/import/npm/cancel", bytes.NewBufferString("{"))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
})
|
||||
|
||||
t.Run("empty object payload", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/import/npm/cancel", bytes.NewBufferString("{}"))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
|
||||
var resp map[string]string
|
||||
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "session_uuid required", resp["error"])
|
||||
})
|
||||
|
||||
t.Run("missing session_uuid payload", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/import/npm/cancel", bytes.NewBufferString(`{"foo":"bar"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
|
||||
var resp map[string]string
|
||||
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "session_uuid required", resp["error"])
|
||||
})
|
||||
}
|
||||
|
||||
func TestNPMImportHandler_ConvertNPMToImportResult(t *testing.T) {
|
||||
db := setupNPMTestDB(t)
|
||||
handler := NewNPMImportHandler(db)
|
||||
|
||||
@@ -36,9 +36,7 @@ func requireAuthenticatedAdmin(c *gin.Context) bool {
|
||||
}
|
||||
|
||||
func isAdmin(c *gin.Context) bool {
|
||||
role, _ := c.Get("role")
|
||||
roleStr, _ := role.(string)
|
||||
return roleStr == "admin"
|
||||
return c.GetString("role") == string(models.RoleAdmin)
|
||||
}
|
||||
|
||||
func respondPermissionError(c *gin.Context, securityService *services.SecurityService, action string, err error, path string) bool {
|
||||
|
||||
@@ -307,7 +307,7 @@ func TestSecurityEventIntakeR6Intact(t *testing.T) {
|
||||
Email: "admin@example.com",
|
||||
Name: "Admin User",
|
||||
PasswordHash: "$2a$10$abcdefghijklmnopqrstuvwxyz", // Dummy bcrypt hash
|
||||
Role: "admin",
|
||||
Role: models.RoleAdmin,
|
||||
Enabled: true,
|
||||
}
|
||||
require.NoError(t, db.Create(adminUser).Error)
|
||||
|
||||
@@ -1075,10 +1075,7 @@ func (h *SecurityHandler) PatchRateLimit(c *gin.Context) {
|
||||
// toggleSecurityModule is a helper function that handles enabling/disabling security modules
|
||||
// It updates the setting, invalidates cache, and triggers Caddy config reload
|
||||
func (h *SecurityHandler) toggleSecurityModule(c *gin.Context, settingKey string, enabled bool) {
|
||||
// Check admin role
|
||||
role, exists := c.Get("role")
|
||||
if !exists || role != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
|
||||
if !requireAdmin(c) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -22,13 +22,15 @@ import (
|
||||
|
||||
type UserHandler struct {
|
||||
DB *gorm.DB
|
||||
AuthService *services.AuthService
|
||||
MailService *services.MailService
|
||||
securitySvc *services.SecurityService
|
||||
}
|
||||
|
||||
func NewUserHandler(db *gorm.DB) *UserHandler {
|
||||
func NewUserHandler(db *gorm.DB, authService *services.AuthService) *UserHandler {
|
||||
return &UserHandler{
|
||||
DB: db,
|
||||
AuthService: authService,
|
||||
MailService: services.NewMailService(db),
|
||||
securitySvc: services.NewSecurityService(db),
|
||||
}
|
||||
@@ -141,7 +143,7 @@ func (h *UserHandler) Setup(c *gin.Context) {
|
||||
UUID: uuid.New().String(),
|
||||
Name: req.Name,
|
||||
Email: strings.ToLower(req.Email),
|
||||
Role: "admin",
|
||||
Role: models.RoleAdmin,
|
||||
Enabled: true,
|
||||
APIKey: uuid.New().String(),
|
||||
}
|
||||
@@ -197,8 +199,21 @@ func (h *UserHandler) Setup(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// rejectPassthrough aborts with 403 if the caller is a passthrough user.
|
||||
// Returns true if the request was rejected (caller should return).
|
||||
func rejectPassthrough(c *gin.Context, action string) bool {
|
||||
if c.GetString("role") == string(models.RolePassthrough) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Passthrough users cannot " + action})
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// RegenerateAPIKey generates a new API key for the authenticated user.
|
||||
func (h *UserHandler) RegenerateAPIKey(c *gin.Context) {
|
||||
if rejectPassthrough(c, "manage API keys") {
|
||||
return
|
||||
}
|
||||
userID, exists := c.Get("userID")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
||||
@@ -222,6 +237,9 @@ func (h *UserHandler) RegenerateAPIKey(c *gin.Context) {
|
||||
|
||||
// GetProfile returns the current user's profile including API key.
|
||||
func (h *UserHandler) GetProfile(c *gin.Context) {
|
||||
if rejectPassthrough(c, "access profile") {
|
||||
return
|
||||
}
|
||||
userID, exists := c.Get("userID")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
||||
@@ -252,6 +270,9 @@ type UpdateProfileRequest struct {
|
||||
|
||||
// UpdateProfile updates the authenticated user's profile.
|
||||
func (h *UserHandler) UpdateProfile(c *gin.Context) {
|
||||
if rejectPassthrough(c, "update profile") {
|
||||
return
|
||||
}
|
||||
userID, exists := c.Get("userID")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
||||
@@ -309,9 +330,7 @@ func (h *UserHandler) UpdateProfile(c *gin.Context) {
|
||||
|
||||
// ListUsers returns all users (admin only).
|
||||
func (h *UserHandler) ListUsers(c *gin.Context) {
|
||||
role, _ := c.Get("role")
|
||||
if role != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
|
||||
if !requireAdmin(c) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -355,9 +374,7 @@ type CreateUserRequest struct {
|
||||
|
||||
// CreateUser creates a new user with a password (admin only).
|
||||
func (h *UserHandler) CreateUser(c *gin.Context) {
|
||||
role, _ := c.Get("role")
|
||||
if role != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
|
||||
if !requireAdmin(c) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -369,7 +386,12 @@ func (h *UserHandler) CreateUser(c *gin.Context) {
|
||||
|
||||
// Default role to "user"
|
||||
if req.Role == "" {
|
||||
req.Role = "user"
|
||||
req.Role = string(models.RoleUser)
|
||||
}
|
||||
|
||||
if !models.UserRole(req.Role).IsValid() {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid role"})
|
||||
return
|
||||
}
|
||||
|
||||
// Default permission mode to "allow_all"
|
||||
@@ -392,7 +414,7 @@ func (h *UserHandler) CreateUser(c *gin.Context) {
|
||||
UUID: uuid.New().String(),
|
||||
Email: strings.ToLower(req.Email),
|
||||
Name: req.Name,
|
||||
Role: req.Role,
|
||||
Role: models.UserRole(req.Role),
|
||||
Enabled: true,
|
||||
APIKey: uuid.New().String(),
|
||||
PermissionMode: models.PermissionMode(req.PermissionMode),
|
||||
@@ -460,9 +482,7 @@ func generateSecureToken(length int) (string, error) {
|
||||
|
||||
// InviteUser creates a new user with an invite token and sends an email (admin only).
|
||||
func (h *UserHandler) InviteUser(c *gin.Context) {
|
||||
role, _ := c.Get("role")
|
||||
if role != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
|
||||
if !requireAdmin(c) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -476,7 +496,12 @@ func (h *UserHandler) InviteUser(c *gin.Context) {
|
||||
|
||||
// Default role to "user"
|
||||
if req.Role == "" {
|
||||
req.Role = "user"
|
||||
req.Role = string(models.RoleUser)
|
||||
}
|
||||
|
||||
if !models.UserRole(req.Role).IsValid() {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid role"})
|
||||
return
|
||||
}
|
||||
|
||||
// Default permission mode to "allow_all"
|
||||
@@ -506,7 +531,7 @@ func (h *UserHandler) InviteUser(c *gin.Context) {
|
||||
user := models.User{
|
||||
UUID: uuid.New().String(),
|
||||
Email: strings.ToLower(req.Email),
|
||||
Role: req.Role,
|
||||
Role: models.UserRole(req.Role),
|
||||
Enabled: false, // Disabled until invite is accepted
|
||||
APIKey: uuid.New().String(),
|
||||
PermissionMode: models.PermissionMode(req.PermissionMode),
|
||||
@@ -595,9 +620,7 @@ type PreviewInviteURLRequest struct {
|
||||
|
||||
// PreviewInviteURL returns what the invite URL would look like with current settings.
|
||||
func (h *UserHandler) PreviewInviteURL(c *gin.Context) {
|
||||
role, _ := c.Get("role")
|
||||
if role != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
|
||||
if !requireAdmin(c) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -641,9 +664,7 @@ func getAppName(db *gorm.DB) string {
|
||||
|
||||
// GetUser returns a single user by ID (admin only).
|
||||
func (h *UserHandler) GetUser(c *gin.Context) {
|
||||
role, _ := c.Get("role")
|
||||
if role != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
|
||||
if !requireAdmin(c) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -692,11 +713,17 @@ type UpdateUserRequest struct {
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
|
||||
// UpdateUser updates an existing user (admin only).
|
||||
// UpdateUser updates an existing user (admin only for management fields, self-service for name/password).
|
||||
func (h *UserHandler) UpdateUser(c *gin.Context) {
|
||||
role, _ := c.Get("role")
|
||||
if role != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
|
||||
currentRole := c.GetString("role")
|
||||
currentUserIDRaw, exists := c.Get("userID")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
|
||||
return
|
||||
}
|
||||
currentUserID, ok := currentUserIDRaw.(uint)
|
||||
if !ok {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Invalid session"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -714,11 +741,31 @@ func (h *UserHandler) UpdateUser(c *gin.Context) {
|
||||
}
|
||||
|
||||
var req UpdateUserRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
if bindErr := c.ShouldBindJSON(&req); bindErr != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": bindErr.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
isSelf := uint(id) == currentUserID
|
||||
isCallerAdmin := currentRole == string(models.RoleAdmin)
|
||||
|
||||
// Non-admin users can only update their own name and password via this endpoint.
|
||||
// Email changes require password verification and must go through PUT /user/profile.
|
||||
if !isCallerAdmin {
|
||||
if !isSelf {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
|
||||
return
|
||||
}
|
||||
if req.Email != "" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Email changes must be made via your profile settings"})
|
||||
return
|
||||
}
|
||||
if req.Role != "" || req.Enabled != nil {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Cannot modify role or enabled status"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
updates := make(map[string]any)
|
||||
|
||||
if req.Name != "" {
|
||||
@@ -727,21 +774,37 @@ func (h *UserHandler) UpdateUser(c *gin.Context) {
|
||||
|
||||
if req.Email != "" {
|
||||
email := strings.ToLower(req.Email)
|
||||
// Check if email is taken by another user
|
||||
var count int64
|
||||
if err := h.DB.Model(&models.User{}).Where("email = ? AND id != ?", email, id).Count(&count).Error; err == nil && count > 0 {
|
||||
if dbErr := h.DB.Model(&models.User{}).Where("email = ? AND id != ?", email, id).Count(&count).Error; dbErr == nil && count > 0 {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "Email already in use"})
|
||||
return
|
||||
}
|
||||
updates["email"] = email
|
||||
}
|
||||
|
||||
needsSessionInvalidation := false
|
||||
|
||||
if req.Role != "" {
|
||||
updates["role"] = req.Role
|
||||
newRole := models.UserRole(req.Role)
|
||||
if !newRole.IsValid() {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid role"})
|
||||
return
|
||||
}
|
||||
|
||||
if newRole != user.Role {
|
||||
// Self-demotion prevention
|
||||
if isSelf {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Cannot change your own role"})
|
||||
return
|
||||
}
|
||||
|
||||
updates["role"] = string(newRole)
|
||||
needsSessionInvalidation = true
|
||||
}
|
||||
}
|
||||
|
||||
if req.Password != nil {
|
||||
if err := user.SetPassword(*req.Password); err != nil {
|
||||
if hashErr := user.SetPassword(*req.Password); hashErr != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to hash password"})
|
||||
return
|
||||
}
|
||||
@@ -750,14 +813,82 @@ func (h *UserHandler) UpdateUser(c *gin.Context) {
|
||||
updates["locked_until"] = nil
|
||||
}
|
||||
|
||||
if req.Enabled != nil {
|
||||
if req.Enabled != nil && *req.Enabled != user.Enabled {
|
||||
// Prevent self-disable
|
||||
if isSelf && !*req.Enabled {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Cannot disable your own account"})
|
||||
return
|
||||
}
|
||||
|
||||
updates["enabled"] = *req.Enabled
|
||||
if !*req.Enabled {
|
||||
needsSessionInvalidation = true
|
||||
}
|
||||
}
|
||||
|
||||
// Wrap the last-admin checks and the actual update in a transaction to prevent
|
||||
// race conditions: two concurrent requests could both read adminCount==2
|
||||
// and both proceed, leaving zero admins.
|
||||
err = h.DB.Transaction(func(tx *gorm.DB) error {
|
||||
// Re-fetch user inside transaction for consistent state
|
||||
if txErr := tx.First(&user, id).Error; txErr != nil {
|
||||
return txErr
|
||||
}
|
||||
|
||||
// Last-admin protection for role demotion
|
||||
if newRoleStr, ok := updates["role"]; ok {
|
||||
newRole := models.UserRole(newRoleStr.(string))
|
||||
if user.Role == models.RoleAdmin && newRole != models.RoleAdmin {
|
||||
var adminCount int64
|
||||
// Policy: count only enabled admins. This is stricter than "WHERE role = ?"
|
||||
// because a disabled admin cannot act; treating them as non-existent
|
||||
// prevents leaving the system with only disabled admins.
|
||||
tx.Model(&models.User{}).Where("role = ? AND enabled = ?", models.RoleAdmin, true).Count(&adminCount)
|
||||
if adminCount <= 1 {
|
||||
return fmt.Errorf("cannot demote the last admin")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Last-admin protection for disabling
|
||||
if enabledVal, ok := updates["enabled"]; ok {
|
||||
if enabled, isBool := enabledVal.(bool); isBool && !enabled {
|
||||
if user.Role == models.RoleAdmin {
|
||||
var adminCount int64
|
||||
// Policy: count only enabled admins (same rationale as above).
|
||||
tx.Model(&models.User{}).Where("role = ? AND enabled = ?", models.RoleAdmin, true).Count(&adminCount)
|
||||
if adminCount <= 1 {
|
||||
return fmt.Errorf("cannot disable the last admin")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(updates) > 0 {
|
||||
if txErr := tx.Model(&user).Updates(updates).Error; txErr != nil {
|
||||
return txErr
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
errMsg := err.Error()
|
||||
if errMsg == "cannot demote the last admin" || errMsg == "cannot disable the last admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Cannot" + errMsg[len("cannot"):]})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update user"})
|
||||
return
|
||||
}
|
||||
|
||||
if len(updates) > 0 {
|
||||
if err := h.DB.Model(&user).Updates(updates).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update user"})
|
||||
return
|
||||
if needsSessionInvalidation && h.AuthService != nil {
|
||||
if invErr := h.AuthService.InvalidateSessions(user.ID); invErr != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to invalidate sessions"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
h.logUserAudit(c, "user_update", &user, map[string]any{
|
||||
@@ -780,13 +911,12 @@ func mapsKeys(values map[string]any) []string {
|
||||
|
||||
// DeleteUser deletes a user (admin only).
|
||||
func (h *UserHandler) DeleteUser(c *gin.Context) {
|
||||
role, _ := c.Get("role")
|
||||
if role != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
|
||||
if !requireAdmin(c) {
|
||||
return
|
||||
}
|
||||
|
||||
currentUserID, _ := c.Get("userID")
|
||||
currentUserIDRaw, _ := c.Get("userID")
|
||||
currentUserID, _ := currentUserIDRaw.(uint)
|
||||
|
||||
idParam := c.Param("id")
|
||||
id, err := strconv.ParseUint(idParam, 10, 32)
|
||||
@@ -796,7 +926,7 @@ func (h *UserHandler) DeleteUser(c *gin.Context) {
|
||||
}
|
||||
|
||||
// Prevent self-deletion
|
||||
if uint(id) == currentUserID.(uint) {
|
||||
if uint(id) == currentUserID {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Cannot delete your own account"})
|
||||
return
|
||||
}
|
||||
@@ -834,9 +964,7 @@ type UpdateUserPermissionsRequest struct {
|
||||
|
||||
// ResendInvite regenerates and resends an invitation to a pending user (admin only).
|
||||
func (h *UserHandler) ResendInvite(c *gin.Context) {
|
||||
role, _ := c.Get("role")
|
||||
if role != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
|
||||
if !requireAdmin(c) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -919,9 +1047,7 @@ func redactInviteURL(inviteURL string) string {
|
||||
|
||||
// UpdateUserPermissions updates a user's permission mode and host exceptions (admin only).
|
||||
func (h *UserHandler) UpdateUserPermissions(c *gin.Context) {
|
||||
role, _ := c.Get("role")
|
||||
if role != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
|
||||
if !requireAdmin(c) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ func setupUserCoverageDB(t *testing.T) *gorm.DB {
|
||||
func TestUserHandler_GetSetupStatus_Error(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupUserCoverageDB(t)
|
||||
h := NewUserHandler(db)
|
||||
h := NewUserHandler(db, nil)
|
||||
|
||||
// Drop table to cause error
|
||||
_ = db.Migrator().DropTable(&models.User{})
|
||||
@@ -40,7 +40,7 @@ func TestUserHandler_GetSetupStatus_Error(t *testing.T) {
|
||||
func TestUserHandler_Setup_CheckStatusError(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupUserCoverageDB(t)
|
||||
h := NewUserHandler(db)
|
||||
h := NewUserHandler(db, nil)
|
||||
|
||||
// Drop table to cause error
|
||||
_ = db.Migrator().DropTable(&models.User{})
|
||||
@@ -57,10 +57,10 @@ func TestUserHandler_Setup_CheckStatusError(t *testing.T) {
|
||||
func TestUserHandler_Setup_AlreadyCompleted(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupUserCoverageDB(t)
|
||||
h := NewUserHandler(db)
|
||||
h := NewUserHandler(db, nil)
|
||||
|
||||
// Create a user to mark setup as complete
|
||||
user := &models.User{UUID: "uuid-a", Name: "Admin", Email: "admin@test.com", Role: "admin"}
|
||||
user := &models.User{UUID: "uuid-a", Name: "Admin", Email: "admin@test.com", Role: models.RoleAdmin}
|
||||
_ = user.SetPassword("password123")
|
||||
db.Create(user)
|
||||
|
||||
@@ -76,7 +76,7 @@ func TestUserHandler_Setup_AlreadyCompleted(t *testing.T) {
|
||||
func TestUserHandler_Setup_InvalidJSON(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupUserCoverageDB(t)
|
||||
h := NewUserHandler(db)
|
||||
h := NewUserHandler(db, nil)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
@@ -91,7 +91,7 @@ func TestUserHandler_Setup_InvalidJSON(t *testing.T) {
|
||||
func TestUserHandler_RegenerateAPIKey_Unauthorized(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupUserCoverageDB(t)
|
||||
h := NewUserHandler(db)
|
||||
h := NewUserHandler(db, nil)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
@@ -105,7 +105,7 @@ func TestUserHandler_RegenerateAPIKey_Unauthorized(t *testing.T) {
|
||||
func TestUserHandler_RegenerateAPIKey_DBError(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupUserCoverageDB(t)
|
||||
h := NewUserHandler(db)
|
||||
h := NewUserHandler(db, nil)
|
||||
|
||||
// Drop table to cause error
|
||||
_ = db.Migrator().DropTable(&models.User{})
|
||||
@@ -123,7 +123,7 @@ func TestUserHandler_RegenerateAPIKey_DBError(t *testing.T) {
|
||||
func TestUserHandler_GetProfile_Unauthorized(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupUserCoverageDB(t)
|
||||
h := NewUserHandler(db)
|
||||
h := NewUserHandler(db, nil)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
@@ -137,7 +137,7 @@ func TestUserHandler_GetProfile_Unauthorized(t *testing.T) {
|
||||
func TestUserHandler_GetProfile_NotFound(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupUserCoverageDB(t)
|
||||
h := NewUserHandler(db)
|
||||
h := NewUserHandler(db, nil)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
@@ -152,7 +152,7 @@ func TestUserHandler_GetProfile_NotFound(t *testing.T) {
|
||||
func TestUserHandler_UpdateProfile_Unauthorized(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupUserCoverageDB(t)
|
||||
h := NewUserHandler(db)
|
||||
h := NewUserHandler(db, nil)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
@@ -166,7 +166,7 @@ func TestUserHandler_UpdateProfile_Unauthorized(t *testing.T) {
|
||||
func TestUserHandler_UpdateProfile_InvalidJSON(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupUserCoverageDB(t)
|
||||
h := NewUserHandler(db)
|
||||
h := NewUserHandler(db, nil)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
@@ -182,7 +182,7 @@ func TestUserHandler_UpdateProfile_InvalidJSON(t *testing.T) {
|
||||
func TestUserHandler_UpdateProfile_UserNotFound(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupUserCoverageDB(t)
|
||||
h := NewUserHandler(db)
|
||||
h := NewUserHandler(db, nil)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"name": "Updated",
|
||||
@@ -203,14 +203,14 @@ func TestUserHandler_UpdateProfile_UserNotFound(t *testing.T) {
|
||||
func TestUserHandler_UpdateProfile_EmailConflict(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupUserCoverageDB(t)
|
||||
h := NewUserHandler(db)
|
||||
h := NewUserHandler(db, nil)
|
||||
|
||||
// Create two users
|
||||
user1 := &models.User{UUID: "uuid-1", Name: "User1", Email: "user1@test.com", Role: "admin", APIKey: "key1"}
|
||||
user1 := &models.User{UUID: "uuid-1", Name: "User1", Email: "user1@test.com", Role: models.RoleAdmin, APIKey: "key1"}
|
||||
_ = user1.SetPassword("password123")
|
||||
db.Create(user1)
|
||||
|
||||
user2 := &models.User{UUID: "uuid-2", Name: "User2", Email: "user2@test.com", Role: "admin", APIKey: "key2"}
|
||||
user2 := &models.User{UUID: "uuid-2", Name: "User2", Email: "user2@test.com", Role: models.RoleAdmin, APIKey: "key2"}
|
||||
_ = user2.SetPassword("password123")
|
||||
db.Create(user2)
|
||||
|
||||
@@ -236,9 +236,9 @@ func TestUserHandler_UpdateProfile_EmailConflict(t *testing.T) {
|
||||
func TestUserHandler_UpdateProfile_EmailChangeNoPassword(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupUserCoverageDB(t)
|
||||
h := NewUserHandler(db)
|
||||
h := NewUserHandler(db, nil)
|
||||
|
||||
user := &models.User{UUID: "uuid-u", Name: "User", Email: "user@test.com", Role: "admin"}
|
||||
user := &models.User{UUID: "uuid-u", Name: "User", Email: "user@test.com", Role: models.RoleAdmin}
|
||||
_ = user.SetPassword("password123")
|
||||
db.Create(user)
|
||||
|
||||
@@ -263,9 +263,9 @@ func TestUserHandler_UpdateProfile_EmailChangeNoPassword(t *testing.T) {
|
||||
func TestUserHandler_UpdateProfile_WrongPassword(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupUserCoverageDB(t)
|
||||
h := NewUserHandler(db)
|
||||
h := NewUserHandler(db, nil)
|
||||
|
||||
user := &models.User{UUID: "uuid-u", Name: "User", Email: "user@test.com", Role: "admin"}
|
||||
user := &models.User{UUID: "uuid-u", Name: "User", Email: "user@test.com", Role: models.RoleAdmin}
|
||||
_ = user.SetPassword("password123")
|
||||
db.Create(user)
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
@@ -11,6 +12,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wikid82/charon/backend/internal/config"
|
||||
"github.com/Wikid82/charon/backend/internal/models"
|
||||
"github.com/Wikid82/charon/backend/internal/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -23,7 +25,7 @@ import (
|
||||
func setupUserHandler(t *testing.T) (*UserHandler, *gorm.DB) {
|
||||
db := OpenTestDB(t)
|
||||
_ = db.AutoMigrate(&models.User{}, &models.Setting{}, &models.SecurityAudit{})
|
||||
return NewUserHandler(db), db
|
||||
return NewUserHandler(db, nil), db
|
||||
}
|
||||
|
||||
func TestMapsKeys(t *testing.T) {
|
||||
@@ -312,7 +314,7 @@ func TestUserHandler_ListUsers_SecretEchoContract(t *testing.T) {
|
||||
UUID: uuid.NewString(),
|
||||
Email: "user@example.com",
|
||||
Name: "User",
|
||||
Role: "user",
|
||||
Role: models.RoleUser,
|
||||
APIKey: "raw-api-key",
|
||||
InviteToken: "raw-invite-token",
|
||||
PasswordHash: "raw-password-hash",
|
||||
@@ -661,7 +663,7 @@ func TestUserHandler_UpdateProfile_Errors(t *testing.T) {
|
||||
func setupUserHandlerWithProxyHosts(t *testing.T) (*UserHandler, *gorm.DB) {
|
||||
db := OpenTestDB(t)
|
||||
_ = db.AutoMigrate(&models.User{}, &models.Setting{}, &models.ProxyHost{}, &models.SecurityAudit{})
|
||||
return NewUserHandler(db), db
|
||||
return NewUserHandler(db, nil), db
|
||||
}
|
||||
|
||||
func TestUserHandler_ListUsers_NonAdmin(t *testing.T) {
|
||||
@@ -912,18 +914,24 @@ func TestUserHandler_GetUser_Success(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestUserHandler_UpdateUser_NonAdmin(t *testing.T) {
|
||||
handler, _ := setupUserHandlerWithProxyHosts(t)
|
||||
handler, db := setupUserHandlerWithProxyHosts(t)
|
||||
|
||||
// Create a target user so it exists in the DB
|
||||
target := &models.User{UUID: uuid.NewString(), Email: "target@example.com", Name: "Target", APIKey: uuid.NewString(), Role: models.RoleUser}
|
||||
db.Create(target)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.Use(func(c *gin.Context) {
|
||||
c.Set("role", "user")
|
||||
c.Set("userID", uint(999))
|
||||
c.Next()
|
||||
})
|
||||
r.PUT("/users/:id", handler.UpdateUser)
|
||||
|
||||
body := map[string]any{"name": "Updated"}
|
||||
jsonBody, _ := json.Marshal(body)
|
||||
req := httptest.NewRequest("PUT", "/users/1", bytes.NewBuffer(jsonBody))
|
||||
req := httptest.NewRequest("PUT", fmt.Sprintf("/users/%d", target.ID), bytes.NewBuffer(jsonBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
@@ -937,6 +945,7 @@ func TestUserHandler_UpdateUser_InvalidID(t *testing.T) {
|
||||
r := gin.New()
|
||||
r.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Set("userID", uint(11))
|
||||
c.Next()
|
||||
})
|
||||
r.PUT("/users/:id", handler.UpdateUser)
|
||||
@@ -962,6 +971,7 @@ func TestUserHandler_UpdateUser_InvalidJSON(t *testing.T) {
|
||||
r := gin.New()
|
||||
r.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Set("userID", uint(11))
|
||||
c.Next()
|
||||
})
|
||||
r.PUT("/users/:id", handler.UpdateUser)
|
||||
@@ -980,6 +990,7 @@ func TestUserHandler_UpdateUser_NotFound(t *testing.T) {
|
||||
r := gin.New()
|
||||
r.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Set("userID", uint(11))
|
||||
c.Next()
|
||||
})
|
||||
r.PUT("/users/:id", handler.UpdateUser)
|
||||
@@ -997,7 +1008,7 @@ func TestUserHandler_UpdateUser_NotFound(t *testing.T) {
|
||||
func TestUserHandler_UpdateUser_Success(t *testing.T) {
|
||||
handler, db := setupUserHandlerWithProxyHosts(t)
|
||||
|
||||
user := &models.User{UUID: uuid.NewString(), Email: "update@example.com", Name: "Original", Role: "user"}
|
||||
user := &models.User{UUID: uuid.NewString(), Email: "update@example.com", Name: "Original", Role: models.RoleUser}
|
||||
db.Create(user)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
@@ -1030,7 +1041,7 @@ func TestUserHandler_UpdateUser_Success(t *testing.T) {
|
||||
func TestUserHandler_UpdateUser_PasswordReset(t *testing.T) {
|
||||
handler, db := setupUserHandlerWithProxyHosts(t)
|
||||
|
||||
user := &models.User{UUID: uuid.NewString(), Email: "reset@example.com", Name: "Reset User", Role: "user"}
|
||||
user := &models.User{UUID: uuid.NewString(), Email: "reset@example.com", Name: "Reset User", Role: models.RoleUser}
|
||||
require.NoError(t, user.SetPassword("oldpassword123"))
|
||||
lockUntil := time.Now().Add(10 * time.Minute)
|
||||
user.FailedLoginAttempts = 4
|
||||
@@ -1041,6 +1052,7 @@ func TestUserHandler_UpdateUser_PasswordReset(t *testing.T) {
|
||||
r := gin.New()
|
||||
r.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Set("userID", uint(11))
|
||||
c.Next()
|
||||
})
|
||||
r.PUT("/users/:id", handler.UpdateUser)
|
||||
@@ -1214,7 +1226,7 @@ func TestUserHandler_UpdateUserPermissions_InvalidJSON(t *testing.T) {
|
||||
APIKey: uuid.NewString(),
|
||||
Email: "perms-invalid@example.com",
|
||||
Name: "Perms Invalid Test",
|
||||
Role: "user",
|
||||
Role: models.RoleUser,
|
||||
Enabled: true,
|
||||
}
|
||||
db.Create(user)
|
||||
@@ -1562,7 +1574,7 @@ func TestUserHandler_InviteUser_Success(t *testing.T) {
|
||||
UUID: uuid.NewString(),
|
||||
APIKey: uuid.NewString(),
|
||||
Email: "admin@example.com",
|
||||
Role: "admin",
|
||||
Role: models.RoleAdmin,
|
||||
}
|
||||
db.Create(admin)
|
||||
|
||||
@@ -1615,7 +1627,7 @@ func TestUserHandler_InviteUser_WithPermittedHosts(t *testing.T) {
|
||||
UUID: uuid.NewString(),
|
||||
APIKey: uuid.NewString(),
|
||||
Email: "admin-perm@example.com",
|
||||
Role: "admin",
|
||||
Role: models.RoleAdmin,
|
||||
}
|
||||
db.Create(admin)
|
||||
|
||||
@@ -1664,7 +1676,7 @@ func TestUserHandler_InviteUser_WithSMTPConfigured(t *testing.T) {
|
||||
UUID: uuid.NewString(),
|
||||
APIKey: uuid.NewString(),
|
||||
Email: "admin-smtp@example.com",
|
||||
Role: "admin",
|
||||
Role: models.RoleAdmin,
|
||||
}
|
||||
db.Create(admin)
|
||||
|
||||
@@ -1727,7 +1739,7 @@ func TestUserHandler_InviteUser_WithSMTPAndConfiguredPublicURL_IncludesInviteURL
|
||||
UUID: uuid.NewString(),
|
||||
APIKey: uuid.NewString(),
|
||||
Email: "admin-publicurl@example.com",
|
||||
Role: "admin",
|
||||
Role: models.RoleAdmin,
|
||||
}
|
||||
db.Create(admin)
|
||||
|
||||
@@ -1780,7 +1792,7 @@ func TestUserHandler_InviteUser_WithSMTPAndMalformedPublicURL_DoesNotExposeInvit
|
||||
UUID: uuid.NewString(),
|
||||
APIKey: uuid.NewString(),
|
||||
Email: "admin-malformed-publicurl@example.com",
|
||||
Role: "admin",
|
||||
Role: models.RoleAdmin,
|
||||
}
|
||||
db.Create(admin)
|
||||
|
||||
@@ -1834,7 +1846,7 @@ func TestUserHandler_InviteUser_WithSMTPConfigured_DefaultAppName(t *testing.T)
|
||||
UUID: uuid.NewString(),
|
||||
APIKey: uuid.NewString(),
|
||||
Email: "admin-smtp-default@example.com",
|
||||
Role: "admin",
|
||||
Role: models.RoleAdmin,
|
||||
}
|
||||
db.Create(admin)
|
||||
|
||||
@@ -1976,7 +1988,7 @@ func TestUserHandler_PreviewInviteURL_NonAdmin(t *testing.T) {
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusForbidden, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "Admin access required")
|
||||
assert.Contains(t, w.Body.String(), "admin privileges required")
|
||||
}
|
||||
|
||||
func TestUserHandler_PreviewInviteURL_InvalidJSON(t *testing.T) {
|
||||
@@ -2137,6 +2149,7 @@ func TestUserHandler_UpdateUser_EmailConflict(t *testing.T) {
|
||||
r := gin.New()
|
||||
r.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Set("userID", uint(11))
|
||||
c.Next()
|
||||
})
|
||||
r.PUT("/users/:id", handler.UpdateUser)
|
||||
@@ -2195,7 +2208,7 @@ func TestUserHandler_InviteUser_EmailNormalization(t *testing.T) {
|
||||
UUID: uuid.NewString(),
|
||||
APIKey: uuid.NewString(),
|
||||
Email: "admin@example.com",
|
||||
Role: "admin",
|
||||
Role: models.RoleAdmin,
|
||||
}
|
||||
db.Create(admin)
|
||||
|
||||
@@ -2264,7 +2277,7 @@ func TestUserHandler_InviteUser_DefaultPermissionMode(t *testing.T) {
|
||||
UUID: uuid.NewString(),
|
||||
APIKey: uuid.NewString(),
|
||||
Email: "admin@example.com",
|
||||
Role: "admin",
|
||||
Role: models.RoleAdmin,
|
||||
}
|
||||
db.Create(admin)
|
||||
|
||||
@@ -2322,7 +2335,7 @@ func TestUserHandler_CreateUser_DefaultRole(t *testing.T) {
|
||||
// Verify role defaults to "user"
|
||||
var user models.User
|
||||
db.Where("email = ?", "defaultrole@example.com").First(&user)
|
||||
assert.Equal(t, "user", user.Role)
|
||||
assert.Equal(t, models.RoleUser, user.Role)
|
||||
}
|
||||
|
||||
func TestUserHandler_InviteUser_DefaultRole(t *testing.T) {
|
||||
@@ -2333,7 +2346,7 @@ func TestUserHandler_InviteUser_DefaultRole(t *testing.T) {
|
||||
UUID: uuid.NewString(),
|
||||
APIKey: uuid.NewString(),
|
||||
Email: "admin@example.com",
|
||||
Role: "admin",
|
||||
Role: models.RoleAdmin,
|
||||
}
|
||||
db.Create(admin)
|
||||
|
||||
@@ -2361,7 +2374,7 @@ func TestUserHandler_InviteUser_DefaultRole(t *testing.T) {
|
||||
// Verify role defaults to "user"
|
||||
var user models.User
|
||||
db.Where("email = ?", "defaultroleinvite@example.com").First(&user)
|
||||
assert.Equal(t, "user", user.Role)
|
||||
assert.Equal(t, models.RoleUser, user.Role)
|
||||
}
|
||||
|
||||
// TestUserHandler_PreviewInviteURL_Unconfigured_DoesNotUseRequestHost verifies that
|
||||
@@ -2484,7 +2497,7 @@ func TestResendInvite_NonAdmin(t *testing.T) {
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusForbidden, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "Admin access required")
|
||||
assert.Contains(t, w.Body.String(), "admin privileges required")
|
||||
}
|
||||
|
||||
func TestResendInvite_InvalidID(t *testing.T) {
|
||||
@@ -2705,3 +2718,394 @@ func TestRedactInviteURL(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- Passthrough rejection tests ---
|
||||
|
||||
func setupPassthroughRouter(handler *UserHandler) *gin.Engine {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.Use(func(c *gin.Context) {
|
||||
c.Set("role", string(models.RolePassthrough))
|
||||
c.Next()
|
||||
})
|
||||
r.POST("/api-key", handler.RegenerateAPIKey)
|
||||
r.GET("/profile", handler.GetProfile)
|
||||
r.PUT("/profile", handler.UpdateProfile)
|
||||
return r
|
||||
}
|
||||
|
||||
func TestUserHandler_RegenerateAPIKey_PassthroughRejected(t *testing.T) {
|
||||
handler, _ := setupUserHandler(t)
|
||||
r := setupPassthroughRouter(handler)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api-key", http.NoBody)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusForbidden, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "Passthrough users cannot manage API keys")
|
||||
}
|
||||
|
||||
func TestUserHandler_GetProfile_PassthroughRejected(t *testing.T) {
|
||||
handler, _ := setupUserHandler(t)
|
||||
r := setupPassthroughRouter(handler)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/profile", http.NoBody)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusForbidden, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "Passthrough users cannot access profile")
|
||||
}
|
||||
|
||||
func TestUserHandler_UpdateProfile_PassthroughRejected(t *testing.T) {
|
||||
handler, _ := setupUserHandler(t)
|
||||
r := setupPassthroughRouter(handler)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{"name": "Test"})
|
||||
req := httptest.NewRequest(http.MethodPut, "/profile", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusForbidden, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "Passthrough users cannot update profile")
|
||||
}
|
||||
|
||||
// --- CreateUser / InviteUser invalid role ---
|
||||
|
||||
func TestUserHandler_CreateUser_InvalidRole(t *testing.T) {
|
||||
handler, _ := setupUserHandler(t)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
r.POST("/users", handler.CreateUser)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"name": "Test User",
|
||||
"email": "new@example.com",
|
||||
"role": "superadmin",
|
||||
"password": "password123",
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/users", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "Invalid role")
|
||||
}
|
||||
|
||||
func TestUserHandler_InviteUser_InvalidRole(t *testing.T) {
|
||||
handler, _ := setupUserHandler(t)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Set("userID", uint(1))
|
||||
c.Next()
|
||||
})
|
||||
r.POST("/invite", handler.InviteUser)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"email": "invite@example.com",
|
||||
"role": "superadmin",
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/invite", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "Invalid role")
|
||||
}
|
||||
|
||||
// --- UpdateUser authentication/session edge cases ---
|
||||
|
||||
func TestUserHandler_UpdateUser_MissingUserID(t *testing.T) {
|
||||
handler, db := setupUserHandler(t)
|
||||
|
||||
user := models.User{UUID: uuid.NewString(), APIKey: uuid.NewString(), Email: "target@example.com", Role: models.RoleUser, Enabled: true}
|
||||
require.NoError(t, db.Create(&user).Error)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
// No userID set in context
|
||||
r.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
r.PUT("/users/:id", handler.UpdateUser)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{"name": "New Name"})
|
||||
req := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/users/%d", user.ID), bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "Authentication required")
|
||||
}
|
||||
|
||||
func TestUserHandler_UpdateUser_InvalidSessionType(t *testing.T) {
|
||||
handler, db := setupUserHandler(t)
|
||||
|
||||
user := models.User{UUID: uuid.NewString(), APIKey: uuid.NewString(), Email: "target2@example.com", Role: models.RoleUser, Enabled: true}
|
||||
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.Set("userID", "not-a-uint") // wrong type
|
||||
c.Next()
|
||||
})
|
||||
r.PUT("/users/:id", handler.UpdateUser)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{"name": "New Name"})
|
||||
req := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/users/%d", user.ID), bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusInternalServerError, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "Invalid session")
|
||||
}
|
||||
|
||||
// --- UpdateUser role/enabled restriction for non-admin self ---
|
||||
|
||||
func TestUserHandler_UpdateUser_NonAdminSelfRoleChange(t *testing.T) {
|
||||
handler, db := setupUserHandler(t)
|
||||
|
||||
user := models.User{UUID: uuid.NewString(), APIKey: uuid.NewString(), Email: "self@example.com", Role: models.RoleUser, Enabled: true}
|
||||
require.NoError(t, db.Create(&user).Error)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.Use(func(c *gin.Context) {
|
||||
c.Set("role", "user") // non-admin
|
||||
c.Set("userID", user.ID) // isSelf = true
|
||||
c.Next()
|
||||
})
|
||||
r.PUT("/users/:id", handler.UpdateUser)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{"role": "admin"})
|
||||
req := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/users/%d", user.ID), bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusForbidden, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "Cannot modify role or enabled status")
|
||||
}
|
||||
|
||||
// --- UpdateUser invalid role string ---
|
||||
|
||||
func TestUserHandler_UpdateUser_InvalidRole(t *testing.T) {
|
||||
handler, db := setupUserHandler(t)
|
||||
|
||||
target := models.User{UUID: uuid.NewString(), APIKey: uuid.NewString(), Email: "target3@example.com", Role: models.RoleUser, Enabled: true}
|
||||
require.NoError(t, db.Create(&target).Error)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Set("userID", uint(9999)) // not the target
|
||||
c.Next()
|
||||
})
|
||||
r.PUT("/users/:id", handler.UpdateUser)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{"role": "superadmin"})
|
||||
req := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/users/%d", target.ID), bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "Invalid role")
|
||||
}
|
||||
|
||||
// --- UpdateUser self-demotion and self-disable ---
|
||||
|
||||
func TestUserHandler_UpdateUser_SelfDemotion(t *testing.T) {
|
||||
handler, db := setupUserHandler(t)
|
||||
|
||||
admin := models.User{UUID: uuid.NewString(), APIKey: uuid.NewString(), Email: "admin@self.example.com", Role: models.RoleAdmin, Enabled: true}
|
||||
require.NoError(t, db.Create(&admin).Error)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Set("userID", admin.ID) // isSelf = true
|
||||
c.Next()
|
||||
})
|
||||
r.PUT("/users/:id", handler.UpdateUser)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{"role": "user"})
|
||||
req := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/users/%d", admin.ID), bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusForbidden, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "Cannot change your own role")
|
||||
}
|
||||
|
||||
func TestUserHandler_UpdateUser_SelfDisable(t *testing.T) {
|
||||
handler, db := setupUserHandler(t)
|
||||
|
||||
admin := models.User{UUID: uuid.NewString(), APIKey: uuid.NewString(), Email: "admin@disable.example.com", Role: models.RoleAdmin, Enabled: true}
|
||||
require.NoError(t, db.Create(&admin).Error)
|
||||
|
||||
disabled := false
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Set("userID", admin.ID) // isSelf = true
|
||||
c.Next()
|
||||
})
|
||||
r.PUT("/users/:id", handler.UpdateUser)
|
||||
|
||||
body, _ := json.Marshal(map[string]interface{}{"enabled": disabled})
|
||||
req := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/users/%d", admin.ID), bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusForbidden, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "Cannot disable your own account")
|
||||
}
|
||||
|
||||
// --- UpdateUser last-admin protection ---
|
||||
|
||||
func TestUserHandler_UpdateUser_LastAdminDemotion(t *testing.T) {
|
||||
handler, db := setupUserHandler(t)
|
||||
|
||||
// Only one admin in the DB (the target)
|
||||
target := models.User{UUID: uuid.NewString(), APIKey: uuid.NewString(), Email: "last-admin@example.com", Role: models.RoleAdmin, Enabled: true}
|
||||
require.NoError(t, db.Create(&target).Error)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Set("userID", uint(9999)) // different from target; not in DB but role injected via context
|
||||
c.Next()
|
||||
})
|
||||
r.PUT("/users/:id", handler.UpdateUser)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{"role": "user"})
|
||||
req := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/users/%d", target.ID), bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusForbidden, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "Cannot demote the last admin")
|
||||
}
|
||||
|
||||
func TestUserHandler_UpdateUser_LastAdminDisable(t *testing.T) {
|
||||
handler, db := setupUserHandler(t)
|
||||
|
||||
target := models.User{UUID: uuid.NewString(), APIKey: uuid.NewString(), Email: "last-admin-disable@example.com", Role: models.RoleAdmin, Enabled: true}
|
||||
require.NoError(t, db.Create(&target).Error)
|
||||
|
||||
disabled := false
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Set("userID", uint(9999))
|
||||
c.Next()
|
||||
})
|
||||
r.PUT("/users/:id", handler.UpdateUser)
|
||||
|
||||
body, _ := json.Marshal(map[string]interface{}{"enabled": disabled})
|
||||
req := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/users/%d", target.ID), bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusForbidden, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "Cannot disable the last admin")
|
||||
}
|
||||
|
||||
// --- UpdateUser session invalidation ---
|
||||
|
||||
func TestUserHandler_UpdateUser_WithSessionInvalidation(t *testing.T) {
|
||||
db := OpenTestDB(t)
|
||||
_ = db.AutoMigrate(&models.User{}, &models.Setting{}, &models.SecurityAudit{})
|
||||
|
||||
authSvc := services.NewAuthService(db, config.Config{JWTSecret: "test-secret"})
|
||||
|
||||
caller := models.User{UUID: uuid.NewString(), APIKey: uuid.NewString(), Email: "caller-si@example.com", Role: models.RoleAdmin, Enabled: true}
|
||||
target := models.User{UUID: uuid.NewString(), APIKey: uuid.NewString(), Email: "target-si@example.com", Role: models.RoleUser, Enabled: true}
|
||||
require.NoError(t, db.Create(&caller).Error)
|
||||
require.NoError(t, db.Create(&target).Error)
|
||||
|
||||
handler := NewUserHandler(db, authSvc)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Set("userID", caller.ID)
|
||||
c.Next()
|
||||
})
|
||||
r.PUT("/users/:id", handler.UpdateUser)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{"role": "passthrough"})
|
||||
req := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/users/%d", target.ID), bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "User updated successfully")
|
||||
|
||||
var updated models.User
|
||||
require.NoError(t, db.First(&updated, target.ID).Error)
|
||||
assert.Greater(t, updated.SessionVersion, uint(0))
|
||||
}
|
||||
|
||||
func TestUserHandler_UpdateUser_SessionInvalidationError(t *testing.T) {
|
||||
mainDB := OpenTestDB(t)
|
||||
_ = mainDB.AutoMigrate(&models.User{}, &models.Setting{}, &models.SecurityAudit{})
|
||||
|
||||
// Use a separate empty DB so InvalidateSessions cannot find the user
|
||||
authDB := OpenTestDB(t)
|
||||
authSvc := services.NewAuthService(authDB, config.Config{JWTSecret: "test-secret"})
|
||||
|
||||
target := models.User{UUID: uuid.NewString(), APIKey: uuid.NewString(), Email: "target-sie@example.com", Role: models.RoleUser, Enabled: true}
|
||||
require.NoError(t, mainDB.Create(&target).Error)
|
||||
|
||||
handler := NewUserHandler(mainDB, authSvc)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Set("userID", uint(9999))
|
||||
c.Next()
|
||||
})
|
||||
r.PUT("/users/:id", handler.UpdateUser)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{"role": "passthrough"})
|
||||
req := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/users/%d", target.ID), bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusInternalServerError, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "Failed to invalidate sessions")
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ func TestUserLoginAfterEmailChange(t *testing.T) {
|
||||
cfg := config.Config{}
|
||||
authService := services.NewAuthService(db, cfg)
|
||||
authHandler := NewAuthHandler(authService)
|
||||
userHandler := NewUserHandler(db)
|
||||
userHandler := NewUserHandler(db, nil)
|
||||
|
||||
// Setup Router
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/Wikid82/charon/backend/internal/models"
|
||||
"github.com/Wikid82/charon/backend/internal/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -37,7 +38,7 @@ func AuthMiddleware(authService *services.AuthService) gin.HandlerFunc {
|
||||
}
|
||||
|
||||
c.Set("userID", user.ID)
|
||||
c.Set("role", user.Role)
|
||||
c.Set("role", string(user.Role))
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -95,15 +96,15 @@ func extractAuthCookieToken(c *gin.Context) string {
|
||||
return token
|
||||
}
|
||||
|
||||
func RequireRole(role string) gin.HandlerFunc {
|
||||
func RequireRole(role models.UserRole) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
userRole, exists := c.Get("role")
|
||||
if !exists {
|
||||
userRole := c.GetString("role")
|
||||
if userRole == "" {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
||||
return
|
||||
}
|
||||
|
||||
if userRole.(string) != role && userRole.(string) != "admin" {
|
||||
if userRole != string(role) && userRole != string(models.RoleAdmin) {
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "Forbidden"})
|
||||
return
|
||||
}
|
||||
@@ -111,3 +112,14 @@ func RequireRole(role string) gin.HandlerFunc {
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func RequireManagementAccess() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
role := c.GetString("role")
|
||||
if role == string(models.RolePassthrough) {
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "Pass-through users cannot access management features"})
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -427,3 +427,61 @@ func TestExtractAuthCookieToken_IgnoresNonAuthCookies(t *testing.T) {
|
||||
token := extractAuthCookieToken(ctx)
|
||||
assert.Equal(t, "", token)
|
||||
}
|
||||
|
||||
func TestRequireManagementAccess_PassthroughBlocked(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.Use(func(c *gin.Context) {
|
||||
c.Set("role", string(models.RolePassthrough))
|
||||
c.Next()
|
||||
})
|
||||
r.Use(RequireManagementAccess())
|
||||
r.GET("/test", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/test", http.NoBody)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusForbidden, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "Pass-through users cannot access management features")
|
||||
}
|
||||
|
||||
func TestRequireManagementAccess_UserAllowed(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.Use(func(c *gin.Context) {
|
||||
c.Set("role", string(models.RoleUser))
|
||||
c.Next()
|
||||
})
|
||||
r.Use(RequireManagementAccess())
|
||||
r.GET("/test", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/test", http.NoBody)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
}
|
||||
|
||||
func TestRequireManagementAccess_AdminAllowed(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.Use(func(c *gin.Context) {
|
||||
c.Set("role", string(models.RoleAdmin))
|
||||
c.Next()
|
||||
})
|
||||
r.Use(RequireManagementAccess())
|
||||
r.GET("/test", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/test", http.NoBody)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ func OptionalAuth(authService *services.AuthService) gin.HandlerFunc {
|
||||
}
|
||||
|
||||
c.Set("userID", user.ID)
|
||||
c.Set("role", user.Role)
|
||||
c.Set("role", string(user.Role))
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,7 +138,7 @@ func TestOptionalAuth_ValidTokenSetsContext(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
authService, db := setupAuthServiceWithDB(t)
|
||||
user := &models.User{Email: "optional-auth@example.com", Name: "Optional Auth", Role: "admin", Enabled: true}
|
||||
user := &models.User{Email: "optional-auth@example.com", Name: "Optional Auth", Role: models.RoleAdmin, Enabled: true}
|
||||
require.NoError(t, user.SetPassword("password123"))
|
||||
require.NoError(t, db.Create(user).Error)
|
||||
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
package routes_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type endpointInventoryEntry struct {
|
||||
Name string
|
||||
Method string
|
||||
Path string
|
||||
Source string
|
||||
}
|
||||
|
||||
func backendImportRouteMatrix() []endpointInventoryEntry {
|
||||
return []endpointInventoryEntry{
|
||||
{Name: "Import status", Method: "GET", Path: "/api/v1/import/status", Source: "backend/internal/api/handlers/import_handler.go"},
|
||||
{Name: "Import preview", Method: "GET", Path: "/api/v1/import/preview", Source: "backend/internal/api/handlers/import_handler.go"},
|
||||
{Name: "Import upload", Method: "POST", Path: "/api/v1/import/upload", Source: "backend/internal/api/handlers/import_handler.go"},
|
||||
{Name: "Import upload multi", Method: "POST", Path: "/api/v1/import/upload-multi", Source: "backend/internal/api/handlers/import_handler.go"},
|
||||
{Name: "Import detect imports", Method: "POST", Path: "/api/v1/import/detect-imports", Source: "backend/internal/api/handlers/import_handler.go"},
|
||||
{Name: "Import commit", Method: "POST", Path: "/api/v1/import/commit", Source: "backend/internal/api/handlers/import_handler.go"},
|
||||
{Name: "Import cancel", Method: "DELETE", Path: "/api/v1/import/cancel", Source: "backend/internal/api/handlers/import_handler.go"},
|
||||
{Name: "NPM import upload", Method: "POST", Path: "/api/v1/import/npm/upload", Source: "backend/internal/api/handlers/npm_import_handler.go"},
|
||||
{Name: "NPM import commit", Method: "POST", Path: "/api/v1/import/npm/commit", Source: "backend/internal/api/handlers/npm_import_handler.go"},
|
||||
{Name: "NPM import cancel", Method: "POST", Path: "/api/v1/import/npm/cancel", Source: "backend/internal/api/handlers/npm_import_handler.go"},
|
||||
{Name: "JSON import upload", Method: "POST", Path: "/api/v1/import/json/upload", Source: "backend/internal/api/handlers/json_import_handler.go"},
|
||||
{Name: "JSON import commit", Method: "POST", Path: "/api/v1/import/json/commit", Source: "backend/internal/api/handlers/json_import_handler.go"},
|
||||
{Name: "JSON import cancel", Method: "POST", Path: "/api/v1/import/json/cancel", Source: "backend/internal/api/handlers/json_import_handler.go"},
|
||||
}
|
||||
}
|
||||
|
||||
func frontendImportRouteMatrix() []endpointInventoryEntry {
|
||||
return []endpointInventoryEntry{
|
||||
{Name: "Import status", Method: "GET", Path: "/api/v1/import/status", Source: "frontend/src/api/import.ts"},
|
||||
{Name: "Import preview", Method: "GET", Path: "/api/v1/import/preview", Source: "frontend/src/api/import.ts"},
|
||||
{Name: "Import upload", Method: "POST", Path: "/api/v1/import/upload", Source: "frontend/src/api/import.ts"},
|
||||
{Name: "Import upload multi", Method: "POST", Path: "/api/v1/import/upload-multi", Source: "frontend/src/api/import.ts"},
|
||||
{Name: "Import commit", Method: "POST", Path: "/api/v1/import/commit", Source: "frontend/src/api/import.ts"},
|
||||
{Name: "Import cancel", Method: "DELETE", Path: "/api/v1/import/cancel", Source: "frontend/src/api/import.ts"},
|
||||
{Name: "NPM import upload", Method: "POST", Path: "/api/v1/import/npm/upload", Source: "frontend/src/api/npmImport.ts"},
|
||||
{Name: "NPM import commit", Method: "POST", Path: "/api/v1/import/npm/commit", Source: "frontend/src/api/npmImport.ts"},
|
||||
{Name: "NPM import cancel", Method: "POST", Path: "/api/v1/import/npm/cancel", Source: "frontend/src/api/npmImport.ts"},
|
||||
{Name: "JSON import upload", Method: "POST", Path: "/api/v1/import/json/upload", Source: "frontend/src/api/jsonImport.ts"},
|
||||
{Name: "JSON import commit", Method: "POST", Path: "/api/v1/import/json/commit", Source: "frontend/src/api/jsonImport.ts"},
|
||||
{Name: "JSON import cancel", Method: "POST", Path: "/api/v1/import/json/cancel", Source: "frontend/src/api/jsonImport.ts"},
|
||||
}
|
||||
}
|
||||
|
||||
func saveRouteMatrixForImportWorkflows() []endpointInventoryEntry {
|
||||
return []endpointInventoryEntry{
|
||||
{Name: "Backup list", Method: "GET", Path: "/api/v1/backups", Source: "frontend/src/api/backups.ts"},
|
||||
{Name: "Backup create", Method: "POST", Path: "/api/v1/backups", Source: "frontend/src/api/backups.ts"},
|
||||
{Name: "Settings list", Method: "GET", Path: "/api/v1/settings", Source: "frontend/src/api/settings.ts"},
|
||||
{Name: "Settings save", Method: "POST", Path: "/api/v1/settings", Source: "frontend/src/api/settings.ts"},
|
||||
{Name: "Settings save patch", Method: "PATCH", Path: "/api/v1/settings", Source: "frontend/src/api/settings.ts"},
|
||||
{Name: "Settings validate URL", Method: "POST", Path: "/api/v1/settings/validate-url", Source: "frontend/src/api/settings.ts"},
|
||||
{Name: "Settings test URL", Method: "POST", Path: "/api/v1/settings/test-url", Source: "frontend/src/api/settings.ts"},
|
||||
{Name: "SMTP get", Method: "GET", Path: "/api/v1/settings/smtp", Source: "frontend/src/api/smtp.ts"},
|
||||
{Name: "SMTP save", Method: "POST", Path: "/api/v1/settings/smtp", Source: "frontend/src/api/smtp.ts"},
|
||||
{Name: "Proxy host list", Method: "GET", Path: "/api/v1/proxy-hosts", Source: "frontend/src/api/proxyHosts.ts"},
|
||||
{Name: "Proxy host create", Method: "POST", Path: "/api/v1/proxy-hosts", Source: "frontend/src/api/proxyHosts.ts"},
|
||||
{Name: "Proxy host get", Method: "GET", Path: "/api/v1/proxy-hosts/:uuid", Source: "frontend/src/api/proxyHosts.ts"},
|
||||
{Name: "Proxy host update", Method: "PUT", Path: "/api/v1/proxy-hosts/:uuid", Source: "frontend/src/api/proxyHosts.ts"},
|
||||
{Name: "Proxy host delete", Method: "DELETE", Path: "/api/v1/proxy-hosts/:uuid", Source: "frontend/src/api/proxyHosts.ts"},
|
||||
}
|
||||
}
|
||||
|
||||
func backendImportSaveInventoryCanonical() []endpointInventoryEntry {
|
||||
entries := append([]endpointInventoryEntry{}, backendImportRouteMatrix()...)
|
||||
entries = append(entries, saveRouteMatrixForImportWorkflows()...)
|
||||
return entries
|
||||
}
|
||||
|
||||
func frontendObservedImportSaveInventory() []endpointInventoryEntry {
|
||||
entries := append([]endpointInventoryEntry{}, frontendImportRouteMatrix()...)
|
||||
entries = append(entries, saveRouteMatrixForImportWorkflows()...)
|
||||
return entries
|
||||
}
|
||||
|
||||
func routeKey(method, path string) string {
|
||||
return method + " " + path
|
||||
}
|
||||
|
||||
func buildRouteLookup(routes []gin.RouteInfo) (map[string]gin.RouteInfo, map[string]map[string]struct{}) {
|
||||
byMethodAndPath := make(map[string]gin.RouteInfo, len(routes))
|
||||
methodsByPath := make(map[string]map[string]struct{})
|
||||
for _, route := range routes {
|
||||
key := routeKey(route.Method, route.Path)
|
||||
byMethodAndPath[key] = route
|
||||
if _, exists := methodsByPath[route.Path]; !exists {
|
||||
methodsByPath[route.Path] = map[string]struct{}{}
|
||||
}
|
||||
methodsByPath[route.Path][route.Method] = struct{}{}
|
||||
}
|
||||
return byMethodAndPath, methodsByPath
|
||||
}
|
||||
|
||||
func methodList(methodSet map[string]struct{}) []string {
|
||||
methods := make([]string, 0, len(methodSet))
|
||||
for method := range methodSet {
|
||||
methods = append(methods, method)
|
||||
}
|
||||
sort.Strings(methods)
|
||||
return methods
|
||||
}
|
||||
|
||||
func assertStrictMethodPathMatrix(t *testing.T, routes []gin.RouteInfo, expected []endpointInventoryEntry, matrixName string) {
|
||||
t.Helper()
|
||||
|
||||
byMethodAndPath, methodsByPath := buildRouteLookup(routes)
|
||||
|
||||
seen := map[string]string{}
|
||||
expectedMethodsByPath := map[string]map[string]struct{}{}
|
||||
var failures []string
|
||||
|
||||
for _, endpoint := range expected {
|
||||
key := routeKey(endpoint.Method, endpoint.Path)
|
||||
if previous, duplicated := seen[key]; duplicated {
|
||||
failures = append(failures, fmt.Sprintf("duplicate expected entry %q (%s and %s)", key, previous, endpoint.Name))
|
||||
continue
|
||||
}
|
||||
seen[key] = endpoint.Name
|
||||
|
||||
if _, exists := expectedMethodsByPath[endpoint.Path]; !exists {
|
||||
expectedMethodsByPath[endpoint.Path] = map[string]struct{}{}
|
||||
}
|
||||
expectedMethodsByPath[endpoint.Path][endpoint.Method] = struct{}{}
|
||||
|
||||
if _, exists := byMethodAndPath[key]; exists {
|
||||
continue
|
||||
}
|
||||
|
||||
if methodSet, pathExists := methodsByPath[endpoint.Path]; pathExists {
|
||||
failures = append(
|
||||
failures,
|
||||
fmt.Sprintf("method drift for %s (%s): expected %s, registered methods=[%s]", endpoint.Name, endpoint.Path, endpoint.Method, strings.Join(methodList(methodSet), ", ")),
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
failures = append(
|
||||
failures,
|
||||
fmt.Sprintf("missing route for %s: expected %s (source=%s)", endpoint.Name, key, endpoint.Source),
|
||||
)
|
||||
}
|
||||
|
||||
for path, expectedMethodSet := range expectedMethodsByPath {
|
||||
actualMethodSet, exists := methodsByPath[path]
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
|
||||
extraMethods := make([]string, 0)
|
||||
for method := range actualMethodSet {
|
||||
if _, expectedMethod := expectedMethodSet[method]; !expectedMethod {
|
||||
extraMethods = append(extraMethods, method)
|
||||
}
|
||||
}
|
||||
if len(extraMethods) > 0 {
|
||||
sort.Strings(extraMethods)
|
||||
failures = append(
|
||||
failures,
|
||||
fmt.Sprintf(
|
||||
"unexpected methods for %s: extra=[%s], expected=[%s], registered=[%s]",
|
||||
path,
|
||||
strings.Join(extraMethods, ", "),
|
||||
strings.Join(methodList(expectedMethodSet), ", "),
|
||||
strings.Join(methodList(actualMethodSet), ", "),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if len(failures) > 0 {
|
||||
t.Fatalf("%s route matrix assertion failed:\n- %s", matrixName, strings.Join(failures, "\n- "))
|
||||
}
|
||||
}
|
||||
|
||||
func collectRouteMatrixDrift(routes []gin.RouteInfo, expected []endpointInventoryEntry) []string {
|
||||
byMethodAndPath, methodsByPath := buildRouteLookup(routes)
|
||||
failures := make([]string, 0)
|
||||
|
||||
for _, endpoint := range expected {
|
||||
key := routeKey(endpoint.Method, endpoint.Path)
|
||||
if _, exists := byMethodAndPath[key]; exists {
|
||||
continue
|
||||
}
|
||||
|
||||
if methodSet, pathExists := methodsByPath[endpoint.Path]; pathExists {
|
||||
failures = append(
|
||||
failures,
|
||||
fmt.Sprintf("method drift for %s (%s): expected %s, registered methods=[%s]", endpoint.Name, endpoint.Path, endpoint.Method, strings.Join(methodList(methodSet), ", ")),
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
failures = append(
|
||||
failures,
|
||||
fmt.Sprintf("missing route for %s: expected %s (source=%s)", endpoint.Name, key, endpoint.Source),
|
||||
)
|
||||
}
|
||||
|
||||
return failures
|
||||
}
|
||||
67
backend/internal/api/routes/endpoint_inventory_test.go
Normal file
67
backend/internal/api/routes/endpoint_inventory_test.go
Normal file
@@ -0,0 +1,67 @@
|
||||
package routes_test
|
||||
|
||||
import (
|
||||
"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"
|
||||
)
|
||||
|
||||
func TestEndpointInventory_FrontendCanonicalSaveImportContractsExistInBackend(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared&_test_endpoint_inventory"), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
|
||||
router := gin.New()
|
||||
require.NoError(t, routes.Register(router, db, config.Config{JWTSecret: "test-secret"}))
|
||||
routes.RegisterImportHandler(router, db, config.Config{JWTSecret: "test-secret"}, "echo", "/tmp", "/import/Caddyfile")
|
||||
|
||||
assertStrictMethodPathMatrix(t, router.Routes(), backendImportSaveInventoryCanonical(), "backend canonical save/import inventory")
|
||||
}
|
||||
|
||||
func TestEndpointInventory_FrontendParityMatchesCurrentContract(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared&_test_endpoint_inventory_frontend_parity"), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
|
||||
router := gin.New()
|
||||
require.NoError(t, routes.Register(router, db, config.Config{JWTSecret: "test-secret"}))
|
||||
routes.RegisterImportHandler(router, db, config.Config{JWTSecret: "test-secret"}, "echo", "/tmp", "/import/Caddyfile")
|
||||
|
||||
assertStrictMethodPathMatrix(t, router.Routes(), frontendObservedImportSaveInventory(), "frontend observed save/import inventory")
|
||||
}
|
||||
|
||||
func TestEndpointInventory_FrontendParityDetectsActualMismatch(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared&_test_endpoint_inventory_frontend_parity_mismatch"), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
|
||||
router := gin.New()
|
||||
require.NoError(t, routes.Register(router, db, config.Config{JWTSecret: "test-secret"}))
|
||||
routes.RegisterImportHandler(router, db, config.Config{JWTSecret: "test-secret"}, "echo", "/tmp", "/import/Caddyfile")
|
||||
|
||||
contractWithMismatch := append([]endpointInventoryEntry{}, frontendObservedImportSaveInventory()...)
|
||||
for i := range contractWithMismatch {
|
||||
if contractWithMismatch[i].Path == "/api/v1/import/cancel" {
|
||||
contractWithMismatch[i].Method = "POST"
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
drift := collectRouteMatrixDrift(router.Routes(), contractWithMismatch)
|
||||
|
||||
assert.Contains(
|
||||
t,
|
||||
drift,
|
||||
"method drift for Import cancel (/api/v1/import/cancel): expected POST, registered methods=[DELETE]",
|
||||
)
|
||||
}
|
||||
@@ -52,6 +52,14 @@ func runInitialUptimeBootstrap(enabled bool, uptimeService uptimeBootstrapServic
|
||||
uptimeService.CheckAll()
|
||||
}
|
||||
|
||||
// migrateViewerToPassthrough renames any legacy "viewer" roles to "passthrough".
|
||||
func migrateViewerToPassthrough(db *gorm.DB) {
|
||||
result := db.Model(&models.User{}).Where("role = ?", "viewer").Update("role", string(models.RolePassthrough))
|
||||
if result.RowsAffected > 0 {
|
||||
logger.Log().WithField("count", result.RowsAffected).Info("Migrated viewer roles to passthrough")
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -118,7 +126,7 @@ func RegisterWithDeps(router *gin.Engine, db *gorm.DB, cfg config.Config, caddyM
|
||||
return fmt.Errorf("auto migrate: %w", err)
|
||||
}
|
||||
|
||||
// Clean up invalid Let's Encrypt certificate associations
|
||||
migrateViewerToPassthrough(db)
|
||||
// Let's Encrypt certs are auto-managed by Caddy and should not be assigned via certificate_id
|
||||
logger.Log().Info("Cleaning up invalid Let's Encrypt certificate associations...")
|
||||
var hostsWithInvalidCerts []models.ProxyHost
|
||||
@@ -239,7 +247,7 @@ func RegisterWithDeps(router *gin.Engine, db *gorm.DB, cfg config.Config, caddyM
|
||||
api.POST("/security/events", securityNotificationHandler.HandleSecurityEvent)
|
||||
|
||||
// User handler (public endpoints)
|
||||
userHandler := handlers.NewUserHandler(db)
|
||||
userHandler := handlers.NewUserHandler(db, authService)
|
||||
api.GET("/setup", userHandler.GetSetupStatus)
|
||||
api.POST("/setup", userHandler.Setup)
|
||||
api.GET("/invite/validate", userHandler.ValidateInvite)
|
||||
@@ -251,109 +259,110 @@ func RegisterWithDeps(router *gin.Engine, db *gorm.DB, cfg config.Config, caddyM
|
||||
protected := api.Group("/")
|
||||
protected.Use(authMiddleware)
|
||||
{
|
||||
// Self-service routes — accessible to all authenticated users including passthrough
|
||||
protected.POST("/auth/logout", authHandler.Logout)
|
||||
protected.POST("/auth/refresh", authHandler.Refresh)
|
||||
protected.GET("/auth/me", authHandler.Me)
|
||||
protected.POST("/auth/change-password", authHandler.ChangePassword)
|
||||
|
||||
// Backups
|
||||
protected.GET("/backups", backupHandler.List)
|
||||
protected.POST("/backups", backupHandler.Create)
|
||||
protected.DELETE("/backups/:filename", backupHandler.Delete)
|
||||
protected.GET("/backups/:filename/download", backupHandler.Download)
|
||||
protected.POST("/backups/:filename/restore", backupHandler.Restore)
|
||||
|
||||
// Logs
|
||||
// WebSocket endpoints
|
||||
logsWSHandler := handlers.NewLogsWSHandler(wsTracker)
|
||||
protected.GET("/logs/live", logsWSHandler.HandleWebSocket)
|
||||
protected.GET("/logs", logsHandler.List)
|
||||
protected.GET("/logs/:filename", logsHandler.Read)
|
||||
protected.GET("/logs/:filename/download", logsHandler.Download)
|
||||
|
||||
// WebSocket status monitoring
|
||||
protected.GET("/websocket/connections", wsStatusHandler.GetConnections)
|
||||
protected.GET("/websocket/stats", wsStatusHandler.GetStats)
|
||||
|
||||
// Security Notification Settings - Use handler created earlier for event intake
|
||||
protected.GET("/security/notifications/settings", securityNotificationHandler.DeprecatedGetSettings)
|
||||
protected.PUT("/security/notifications/settings", securityNotificationHandler.DeprecatedUpdateSettings)
|
||||
protected.GET("/notifications/settings/security", securityNotificationHandler.GetSettings)
|
||||
protected.PUT("/notifications/settings/security", securityNotificationHandler.UpdateSettings)
|
||||
|
||||
// System permissions diagnostics and repair
|
||||
systemPermissionsHandler := handlers.NewSystemPermissionsHandler(cfg, securityService, nil)
|
||||
protected.GET("/system/permissions", systemPermissionsHandler.GetPermissions)
|
||||
protected.POST("/system/permissions/repair", systemPermissionsHandler.RepairPermissions)
|
||||
|
||||
// Audit Logs
|
||||
auditLogHandler := handlers.NewAuditLogHandler(securityService)
|
||||
protected.GET("/audit-logs", auditLogHandler.List)
|
||||
protected.GET("/audit-logs/:uuid", auditLogHandler.Get)
|
||||
|
||||
// Settings - with CaddyManager and Cerberus for security settings reload
|
||||
settingsHandler := handlers.NewSettingsHandlerWithDeps(db, caddyManager, cerb, securityService, dataRoot)
|
||||
|
||||
protected.GET("/settings", settingsHandler.GetSettings)
|
||||
protected.POST("/settings", settingsHandler.UpdateSetting)
|
||||
protected.PATCH("/settings", settingsHandler.UpdateSetting) // E2E tests use PATCH
|
||||
protected.PATCH("/config", settingsHandler.PatchConfig) // Bulk configuration update
|
||||
|
||||
// SMTP Configuration
|
||||
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)
|
||||
|
||||
// URL Validation
|
||||
protected.POST("/settings/validate-url", settingsHandler.ValidatePublicURL)
|
||||
protected.POST("/settings/test-url", settingsHandler.TestPublicURL)
|
||||
|
||||
// Auth related protected routes
|
||||
protected.GET("/auth/accessible-hosts", authHandler.GetAccessibleHosts)
|
||||
protected.GET("/auth/check-host/:hostId", authHandler.CheckHostAccess)
|
||||
|
||||
// Feature flags (DB-backed with env fallback)
|
||||
featureFlagsHandler := handlers.NewFeatureFlagsHandler(db)
|
||||
protected.GET("/feature-flags", featureFlagsHandler.GetFlags)
|
||||
protected.PUT("/feature-flags", featureFlagsHandler.UpdateFlags)
|
||||
|
||||
// User Profile & API Key
|
||||
protected.GET("/user/profile", userHandler.GetProfile)
|
||||
protected.POST("/user/profile", userHandler.UpdateProfile)
|
||||
protected.POST("/user/api-key", userHandler.RegenerateAPIKey)
|
||||
|
||||
// Management routes — blocked for passthrough users
|
||||
management := protected.Group("/")
|
||||
management.Use(middleware.RequireManagementAccess())
|
||||
|
||||
// Backups
|
||||
management.GET("/backups", backupHandler.List)
|
||||
management.POST("/backups", backupHandler.Create)
|
||||
management.DELETE("/backups/:filename", backupHandler.Delete)
|
||||
management.GET("/backups/:filename/download", backupHandler.Download)
|
||||
management.POST("/backups/:filename/restore", backupHandler.Restore)
|
||||
|
||||
// Logs
|
||||
// WebSocket endpoints
|
||||
logsWSHandler := handlers.NewLogsWSHandler(wsTracker)
|
||||
management.GET("/logs/live", logsWSHandler.HandleWebSocket)
|
||||
management.GET("/logs", logsHandler.List)
|
||||
management.GET("/logs/:filename", logsHandler.Read)
|
||||
management.GET("/logs/:filename/download", logsHandler.Download)
|
||||
|
||||
// WebSocket status monitoring
|
||||
management.GET("/websocket/connections", wsStatusHandler.GetConnections)
|
||||
management.GET("/websocket/stats", wsStatusHandler.GetStats)
|
||||
|
||||
// Security Notification Settings - Use handler created earlier for event intake
|
||||
management.GET("/security/notifications/settings", securityNotificationHandler.DeprecatedGetSettings)
|
||||
management.PUT("/security/notifications/settings", securityNotificationHandler.DeprecatedUpdateSettings)
|
||||
management.GET("/notifications/settings/security", securityNotificationHandler.GetSettings)
|
||||
management.PUT("/notifications/settings/security", securityNotificationHandler.UpdateSettings)
|
||||
|
||||
// System permissions diagnostics and repair
|
||||
systemPermissionsHandler := handlers.NewSystemPermissionsHandler(cfg, securityService, nil)
|
||||
management.GET("/system/permissions", systemPermissionsHandler.GetPermissions)
|
||||
management.POST("/system/permissions/repair", systemPermissionsHandler.RepairPermissions)
|
||||
|
||||
// Audit Logs
|
||||
auditLogHandler := handlers.NewAuditLogHandler(securityService)
|
||||
management.GET("/audit-logs", auditLogHandler.List)
|
||||
management.GET("/audit-logs/:uuid", auditLogHandler.Get)
|
||||
|
||||
// Settings - with CaddyManager and Cerberus for security settings reload
|
||||
settingsHandler := handlers.NewSettingsHandlerWithDeps(db, caddyManager, cerb, securityService, dataRoot)
|
||||
|
||||
management.GET("/settings", settingsHandler.GetSettings)
|
||||
management.POST("/settings", settingsHandler.UpdateSetting)
|
||||
management.PATCH("/settings", settingsHandler.UpdateSetting) // E2E tests use PATCH
|
||||
management.PATCH("/config", settingsHandler.PatchConfig) // Bulk configuration update
|
||||
|
||||
// SMTP Configuration
|
||||
management.GET("/settings/smtp", middleware.RequireRole(models.RoleAdmin), settingsHandler.GetSMTPConfig)
|
||||
management.POST("/settings/smtp", settingsHandler.UpdateSMTPConfig)
|
||||
management.POST("/settings/smtp/test", settingsHandler.TestSMTPConfig)
|
||||
management.POST("/settings/smtp/test-email", settingsHandler.SendTestEmail)
|
||||
|
||||
// URL Validation
|
||||
management.POST("/settings/validate-url", settingsHandler.ValidatePublicURL)
|
||||
management.POST("/settings/test-url", settingsHandler.TestPublicURL)
|
||||
|
||||
// Feature flags (DB-backed with env fallback)
|
||||
featureFlagsHandler := handlers.NewFeatureFlagsHandler(db)
|
||||
management.GET("/feature-flags", featureFlagsHandler.GetFlags)
|
||||
management.PUT("/feature-flags", featureFlagsHandler.UpdateFlags)
|
||||
|
||||
// User Management (admin only routes are in RegisterRoutes)
|
||||
protected.GET("/users", userHandler.ListUsers)
|
||||
protected.POST("/users", userHandler.CreateUser)
|
||||
protected.POST("/users/invite", userHandler.InviteUser)
|
||||
protected.POST("/users/preview-invite-url", userHandler.PreviewInviteURL)
|
||||
protected.GET("/users/:id", userHandler.GetUser)
|
||||
protected.PUT("/users/:id", userHandler.UpdateUser)
|
||||
protected.DELETE("/users/:id", userHandler.DeleteUser)
|
||||
protected.PUT("/users/:id/permissions", userHandler.UpdateUserPermissions)
|
||||
protected.POST("/users/:id/resend-invite", userHandler.ResendInvite)
|
||||
management.GET("/users", userHandler.ListUsers)
|
||||
management.POST("/users", userHandler.CreateUser)
|
||||
management.POST("/users/invite", userHandler.InviteUser)
|
||||
management.POST("/users/preview-invite-url", userHandler.PreviewInviteURL)
|
||||
management.GET("/users/:id", userHandler.GetUser)
|
||||
management.PUT("/users/:id", userHandler.UpdateUser)
|
||||
management.DELETE("/users/:id", userHandler.DeleteUser)
|
||||
management.PUT("/users/:id/permissions", userHandler.UpdateUserPermissions)
|
||||
management.POST("/users/:id/resend-invite", userHandler.ResendInvite)
|
||||
|
||||
// Updates
|
||||
updateService := services.NewUpdateService()
|
||||
updateHandler := handlers.NewUpdateHandler(updateService)
|
||||
protected.GET("/system/updates", updateHandler.Check)
|
||||
management.GET("/system/updates", updateHandler.Check)
|
||||
|
||||
// System info
|
||||
systemHandler := handlers.NewSystemHandler()
|
||||
protected.GET("/system/my-ip", systemHandler.GetMyIP)
|
||||
management.GET("/system/my-ip", systemHandler.GetMyIP)
|
||||
|
||||
// Notifications
|
||||
notificationHandler := handlers.NewNotificationHandler(notificationService)
|
||||
protected.GET("/notifications", notificationHandler.List)
|
||||
protected.POST("/notifications/:id/read", notificationHandler.MarkAsRead)
|
||||
protected.POST("/notifications/read-all", notificationHandler.MarkAllAsRead)
|
||||
management.GET("/notifications", notificationHandler.List)
|
||||
management.POST("/notifications/:id/read", notificationHandler.MarkAsRead)
|
||||
management.POST("/notifications/read-all", notificationHandler.MarkAllAsRead)
|
||||
|
||||
// Domains
|
||||
domainHandler := handlers.NewDomainHandler(db, notificationService)
|
||||
protected.GET("/domains", domainHandler.List)
|
||||
protected.POST("/domains", domainHandler.Create)
|
||||
protected.DELETE("/domains/:id", domainHandler.Delete)
|
||||
management.GET("/domains", domainHandler.List)
|
||||
management.POST("/domains", domainHandler.Create)
|
||||
management.DELETE("/domains/:id", domainHandler.Delete)
|
||||
|
||||
// DNS Providers - only available if encryption key is configured
|
||||
if cfg.EncryptionKey != "" {
|
||||
@@ -363,33 +372,33 @@ func RegisterWithDeps(router *gin.Engine, db *gorm.DB, cfg config.Config, caddyM
|
||||
} else {
|
||||
dnsProviderService := services.NewDNSProviderService(db, encryptionService)
|
||||
dnsProviderHandler := handlers.NewDNSProviderHandler(dnsProviderService)
|
||||
protected.GET("/dns-providers", dnsProviderHandler.List)
|
||||
protected.POST("/dns-providers", dnsProviderHandler.Create)
|
||||
protected.GET("/dns-providers/types", dnsProviderHandler.GetTypes)
|
||||
protected.GET("/dns-providers/:id", dnsProviderHandler.Get)
|
||||
protected.PUT("/dns-providers/:id", dnsProviderHandler.Update)
|
||||
protected.DELETE("/dns-providers/:id", dnsProviderHandler.Delete)
|
||||
protected.POST("/dns-providers/:id/test", dnsProviderHandler.Test)
|
||||
protected.POST("/dns-providers/test", dnsProviderHandler.TestCredentials)
|
||||
management.GET("/dns-providers", dnsProviderHandler.List)
|
||||
management.POST("/dns-providers", dnsProviderHandler.Create)
|
||||
management.GET("/dns-providers/types", dnsProviderHandler.GetTypes)
|
||||
management.GET("/dns-providers/:id", dnsProviderHandler.Get)
|
||||
management.PUT("/dns-providers/:id", dnsProviderHandler.Update)
|
||||
management.DELETE("/dns-providers/:id", dnsProviderHandler.Delete)
|
||||
management.POST("/dns-providers/:id/test", dnsProviderHandler.Test)
|
||||
management.POST("/dns-providers/test", dnsProviderHandler.TestCredentials)
|
||||
// Audit logs for DNS providers
|
||||
protected.GET("/dns-providers/:id/audit-logs", auditLogHandler.ListByProvider)
|
||||
management.GET("/dns-providers/:id/audit-logs", auditLogHandler.ListByProvider)
|
||||
|
||||
// DNS Provider Auto-Detection (Phase 4)
|
||||
dnsDetectionService := services.NewDNSDetectionService(db)
|
||||
dnsDetectionHandler := handlers.NewDNSDetectionHandler(dnsDetectionService)
|
||||
protected.POST("/dns-providers/detect", dnsDetectionHandler.Detect)
|
||||
protected.GET("/dns-providers/detection-patterns", dnsDetectionHandler.GetPatterns)
|
||||
management.POST("/dns-providers/detect", dnsDetectionHandler.Detect)
|
||||
management.GET("/dns-providers/detection-patterns", dnsDetectionHandler.GetPatterns)
|
||||
|
||||
// Multi-Credential Management (Phase 3)
|
||||
credentialService := services.NewCredentialService(db, encryptionService)
|
||||
credentialHandler := handlers.NewCredentialHandler(credentialService)
|
||||
protected.GET("/dns-providers/:id/credentials", credentialHandler.List)
|
||||
protected.POST("/dns-providers/:id/credentials", credentialHandler.Create)
|
||||
protected.GET("/dns-providers/:id/credentials/:cred_id", credentialHandler.Get)
|
||||
protected.PUT("/dns-providers/:id/credentials/:cred_id", credentialHandler.Update)
|
||||
protected.DELETE("/dns-providers/:id/credentials/:cred_id", credentialHandler.Delete)
|
||||
protected.POST("/dns-providers/:id/credentials/:cred_id/test", credentialHandler.Test)
|
||||
protected.POST("/dns-providers/:id/enable-multi-credentials", credentialHandler.EnableMultiCredentials)
|
||||
management.GET("/dns-providers/:id/credentials", credentialHandler.List)
|
||||
management.POST("/dns-providers/:id/credentials", credentialHandler.Create)
|
||||
management.GET("/dns-providers/:id/credentials/:cred_id", credentialHandler.Get)
|
||||
management.PUT("/dns-providers/:id/credentials/:cred_id", credentialHandler.Update)
|
||||
management.DELETE("/dns-providers/:id/credentials/:cred_id", credentialHandler.Delete)
|
||||
management.POST("/dns-providers/:id/credentials/:cred_id/test", credentialHandler.Test)
|
||||
management.POST("/dns-providers/:id/enable-multi-credentials", credentialHandler.EnableMultiCredentials)
|
||||
|
||||
// Encryption Management - Admin only endpoints
|
||||
rotationService, rotErr := crypto.NewRotationService(db)
|
||||
@@ -397,7 +406,7 @@ func RegisterWithDeps(router *gin.Engine, db *gorm.DB, cfg config.Config, caddyM
|
||||
logger.Log().WithError(rotErr).Warn("Failed to initialize rotation service - key rotation features will be unavailable")
|
||||
} else {
|
||||
encryptionHandler := handlers.NewEncryptionHandler(rotationService, securityService)
|
||||
adminEncryption := protected.Group("/admin/encryption")
|
||||
adminEncryption := management.Group("/admin/encryption")
|
||||
adminEncryption.GET("/status", encryptionHandler.GetStatus)
|
||||
adminEncryption.POST("/rotate", encryptionHandler.Rotate)
|
||||
adminEncryption.GET("/history", encryptionHandler.GetHistory)
|
||||
@@ -411,7 +420,7 @@ func RegisterWithDeps(router *gin.Engine, db *gorm.DB, cfg config.Config, caddyM
|
||||
}
|
||||
pluginLoader := services.NewPluginLoaderService(db, pluginDir, nil)
|
||||
pluginHandler := handlers.NewPluginHandler(db, pluginLoader)
|
||||
adminPlugins := protected.Group("/admin/plugins")
|
||||
adminPlugins := management.Group("/admin/plugins")
|
||||
adminPlugins.GET("", pluginHandler.ListPlugins)
|
||||
adminPlugins.GET("/:id", pluginHandler.GetPlugin)
|
||||
adminPlugins.POST("/:id/enable", pluginHandler.EnablePlugin)
|
||||
@@ -421,7 +430,7 @@ func RegisterWithDeps(router *gin.Engine, db *gorm.DB, cfg config.Config, caddyM
|
||||
// Manual DNS Challenges (Phase 1) - For users without automated DNS API access
|
||||
manualChallengeService := services.NewManualChallengeService(db)
|
||||
manualChallengeHandler := handlers.NewManualChallengeHandler(manualChallengeService, dnsProviderService)
|
||||
manualChallengeHandler.RegisterRoutes(protected)
|
||||
manualChallengeHandler.RegisterRoutes(management)
|
||||
}
|
||||
} else {
|
||||
logger.Log().Warn("CHARON_ENCRYPTION_KEY not set - DNS provider and plugin features will be unavailable")
|
||||
@@ -431,37 +440,37 @@ func RegisterWithDeps(router *gin.Engine, db *gorm.DB, cfg config.Config, caddyM
|
||||
// The service will return proper error messages when Docker is not accessible
|
||||
dockerService := services.NewDockerService()
|
||||
dockerHandler := handlers.NewDockerHandler(dockerService, remoteServerService)
|
||||
dockerHandler.RegisterRoutes(protected)
|
||||
dockerHandler.RegisterRoutes(management)
|
||||
|
||||
// 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)
|
||||
protected.PUT("/uptime/monitors/:id", uptimeHandler.Update)
|
||||
protected.DELETE("/uptime/monitors/:id", uptimeHandler.Delete)
|
||||
protected.POST("/uptime/monitors/:id/check", uptimeHandler.CheckMonitor)
|
||||
protected.POST("/uptime/sync", uptimeHandler.Sync)
|
||||
management.GET("/uptime/monitors", uptimeHandler.List)
|
||||
management.POST("/uptime/monitors", uptimeHandler.Create)
|
||||
management.GET("/uptime/monitors/:id/history", uptimeHandler.GetHistory)
|
||||
management.PUT("/uptime/monitors/:id", uptimeHandler.Update)
|
||||
management.DELETE("/uptime/monitors/:id", uptimeHandler.Delete)
|
||||
management.POST("/uptime/monitors/:id/check", uptimeHandler.CheckMonitor)
|
||||
management.POST("/uptime/sync", uptimeHandler.Sync)
|
||||
|
||||
// Notification Providers
|
||||
notificationProviderHandler := handlers.NewNotificationProviderHandlerWithDeps(notificationService, securityService, dataRoot)
|
||||
protected.GET("/notifications/providers", notificationProviderHandler.List)
|
||||
protected.POST("/notifications/providers", notificationProviderHandler.Create)
|
||||
protected.PUT("/notifications/providers/:id", notificationProviderHandler.Update)
|
||||
protected.DELETE("/notifications/providers/:id", notificationProviderHandler.Delete)
|
||||
protected.POST("/notifications/providers/test", notificationProviderHandler.Test)
|
||||
protected.POST("/notifications/providers/preview", notificationProviderHandler.Preview)
|
||||
protected.GET("/notifications/templates", notificationProviderHandler.Templates)
|
||||
management.GET("/notifications/providers", notificationProviderHandler.List)
|
||||
management.POST("/notifications/providers", notificationProviderHandler.Create)
|
||||
management.PUT("/notifications/providers/:id", notificationProviderHandler.Update)
|
||||
management.DELETE("/notifications/providers/:id", notificationProviderHandler.Delete)
|
||||
management.POST("/notifications/providers/test", notificationProviderHandler.Test)
|
||||
management.POST("/notifications/providers/preview", notificationProviderHandler.Preview)
|
||||
management.GET("/notifications/templates", notificationProviderHandler.Templates)
|
||||
|
||||
// External notification templates (saved templates for providers)
|
||||
notificationTemplateHandler := handlers.NewNotificationTemplateHandlerWithDeps(notificationService, securityService, dataRoot)
|
||||
protected.GET("/notifications/external-templates", notificationTemplateHandler.List)
|
||||
protected.POST("/notifications/external-templates", notificationTemplateHandler.Create)
|
||||
protected.PUT("/notifications/external-templates/:id", notificationTemplateHandler.Update)
|
||||
protected.DELETE("/notifications/external-templates/:id", notificationTemplateHandler.Delete)
|
||||
protected.POST("/notifications/external-templates/preview", notificationTemplateHandler.Preview)
|
||||
management.GET("/notifications/external-templates", notificationTemplateHandler.List)
|
||||
management.POST("/notifications/external-templates", notificationTemplateHandler.Create)
|
||||
management.PUT("/notifications/external-templates/:id", notificationTemplateHandler.Update)
|
||||
management.DELETE("/notifications/external-templates/:id", notificationTemplateHandler.Delete)
|
||||
management.POST("/notifications/external-templates/preview", notificationTemplateHandler.Preview)
|
||||
|
||||
// Ensure uptime feature flag exists to avoid record-not-found logs
|
||||
defaultUptime := models.Setting{Key: "feature.uptime.enabled", Value: "true", Type: "bool", Category: "feature"}
|
||||
@@ -510,7 +519,7 @@ func RegisterWithDeps(router *gin.Engine, db *gorm.DB, cfg config.Config, caddyM
|
||||
}
|
||||
}()
|
||||
|
||||
protected.POST("/system/uptime/check", func(c *gin.Context) {
|
||||
management.POST("/system/uptime/check", func(c *gin.Context) {
|
||||
go uptimeService.CheckAll()
|
||||
c.JSON(200, gin.H{"message": "Uptime check started"})
|
||||
})
|
||||
@@ -542,19 +551,19 @@ func RegisterWithDeps(router *gin.Engine, db *gorm.DB, cfg config.Config, caddyM
|
||||
securityHandler.SetGeoIPService(geoipSvc)
|
||||
}
|
||||
|
||||
protected.GET("/security/status", securityHandler.GetStatus)
|
||||
management.GET("/security/status", securityHandler.GetStatus)
|
||||
// Security Config management
|
||||
protected.GET("/security/config", securityHandler.GetConfig)
|
||||
protected.GET("/security/decisions", securityHandler.ListDecisions)
|
||||
protected.GET("/security/rulesets", securityHandler.ListRuleSets)
|
||||
protected.GET("/security/rate-limit/presets", securityHandler.GetRateLimitPresets)
|
||||
management.GET("/security/config", securityHandler.GetConfig)
|
||||
management.GET("/security/decisions", securityHandler.ListDecisions)
|
||||
management.GET("/security/rulesets", securityHandler.ListRuleSets)
|
||||
management.GET("/security/rate-limit/presets", securityHandler.GetRateLimitPresets)
|
||||
// GeoIP endpoints
|
||||
protected.GET("/security/geoip/status", securityHandler.GetGeoIPStatus)
|
||||
management.GET("/security/geoip/status", securityHandler.GetGeoIPStatus)
|
||||
// WAF exclusion endpoints
|
||||
protected.GET("/security/waf/exclusions", securityHandler.GetWAFExclusions)
|
||||
management.GET("/security/waf/exclusions", securityHandler.GetWAFExclusions)
|
||||
|
||||
securityAdmin := protected.Group("/security")
|
||||
securityAdmin.Use(middleware.RequireRole("admin"))
|
||||
securityAdmin := management.Group("/security")
|
||||
securityAdmin.Use(middleware.RequireRole(models.RoleAdmin))
|
||||
securityAdmin.POST("/config", securityHandler.UpdateConfig)
|
||||
securityAdmin.POST("/enable", securityHandler.Enable)
|
||||
securityAdmin.POST("/disable", securityHandler.Disable)
|
||||
@@ -595,7 +604,7 @@ func RegisterWithDeps(router *gin.Engine, db *gorm.DB, cfg config.Config, caddyM
|
||||
|
||||
crowdsecExec := handlers.NewDefaultCrowdsecExecutor()
|
||||
crowdsecHandler := handlers.NewCrowdsecHandler(db, crowdsecExec, crowdsecBinPath, crowdsecDataDir)
|
||||
crowdsecHandler.RegisterRoutes(protected)
|
||||
crowdsecHandler.RegisterRoutes(management)
|
||||
|
||||
// NOTE: CrowdSec reconciliation now happens in main.go BEFORE HTTP server starts
|
||||
// This ensures proper initialization order and prevents race conditions
|
||||
@@ -626,24 +635,24 @@ func RegisterWithDeps(router *gin.Engine, db *gorm.DB, cfg config.Config, caddyM
|
||||
logger.Log().WithError(err).Error("Failed to start security log watcher")
|
||||
}
|
||||
cerberusLogsHandler := handlers.NewCerberusLogsHandler(logWatcher, wsTracker)
|
||||
protected.GET("/cerberus/logs/ws", cerberusLogsHandler.LiveLogs)
|
||||
management.GET("/cerberus/logs/ws", cerberusLogsHandler.LiveLogs)
|
||||
|
||||
// Access Lists
|
||||
accessListHandler := handlers.NewAccessListHandler(db)
|
||||
if geoipSvc != nil {
|
||||
accessListHandler.SetGeoIPService(geoipSvc)
|
||||
}
|
||||
protected.GET("/access-lists/templates", accessListHandler.GetTemplates)
|
||||
protected.GET("/access-lists", accessListHandler.List)
|
||||
protected.POST("/access-lists", accessListHandler.Create)
|
||||
protected.GET("/access-lists/:id", accessListHandler.Get)
|
||||
protected.PUT("/access-lists/:id", accessListHandler.Update)
|
||||
protected.DELETE("/access-lists/:id", accessListHandler.Delete)
|
||||
protected.POST("/access-lists/:id/test", accessListHandler.TestIP)
|
||||
management.GET("/access-lists/templates", accessListHandler.GetTemplates)
|
||||
management.GET("/access-lists", accessListHandler.List)
|
||||
management.POST("/access-lists", accessListHandler.Create)
|
||||
management.GET("/access-lists/:id", accessListHandler.Get)
|
||||
management.PUT("/access-lists/:id", accessListHandler.Update)
|
||||
management.DELETE("/access-lists/:id", accessListHandler.Delete)
|
||||
management.POST("/access-lists/:id/test", accessListHandler.TestIP)
|
||||
|
||||
// Security Headers
|
||||
securityHeadersHandler := handlers.NewSecurityHeadersHandler(db, caddyManager)
|
||||
securityHeadersHandler.RegisterRoutes(protected)
|
||||
securityHeadersHandler.RegisterRoutes(management)
|
||||
|
||||
// Certificate routes
|
||||
// Use cfg.CaddyConfigDir + "/data" for cert service so we scan the actual Caddy storage
|
||||
@@ -652,19 +661,20 @@ func RegisterWithDeps(router *gin.Engine, db *gorm.DB, cfg config.Config, caddyM
|
||||
logger.Log().WithField("caddy_data_dir", caddyDataDir).Info("Using Caddy data directory for certificates scan")
|
||||
certService := services.NewCertificateService(caddyDataDir, db)
|
||||
certHandler := handlers.NewCertificateHandler(certService, backupService, notificationService)
|
||||
protected.GET("/certificates", certHandler.List)
|
||||
protected.POST("/certificates", certHandler.Upload)
|
||||
protected.DELETE("/certificates/:id", certHandler.Delete)
|
||||
management.GET("/certificates", certHandler.List)
|
||||
management.POST("/certificates", certHandler.Upload)
|
||||
management.DELETE("/certificates/:id", certHandler.Delete)
|
||||
|
||||
// Proxy Hosts & Remote Servers
|
||||
proxyHostHandler := handlers.NewProxyHostHandler(db, caddyManager, notificationService, uptimeService)
|
||||
proxyHostHandler.RegisterRoutes(management)
|
||||
|
||||
remoteServerHandler := handlers.NewRemoteServerHandler(remoteServerService, notificationService)
|
||||
remoteServerHandler.RegisterRoutes(management)
|
||||
}
|
||||
|
||||
// Caddy Manager already created above
|
||||
|
||||
proxyHostHandler := handlers.NewProxyHostHandler(db, caddyManager, notificationService, uptimeService)
|
||||
proxyHostHandler.RegisterRoutes(protected)
|
||||
|
||||
remoteServerHandler := handlers.NewRemoteServerHandler(remoteServerService, notificationService)
|
||||
remoteServerHandler.RegisterRoutes(protected)
|
||||
|
||||
// Initial Caddy Config Sync
|
||||
go func() {
|
||||
// Wait for Caddy to be ready (max 30 seconds)
|
||||
@@ -708,7 +718,7 @@ func RegisterImportHandler(router *gin.Engine, db *gorm.DB, cfg config.Config, c
|
||||
api := router.Group("/api/v1")
|
||||
authService := services.NewAuthService(db, cfg)
|
||||
authenticatedAdmin := api.Group("/")
|
||||
authenticatedAdmin.Use(middleware.AuthMiddleware(authService), middleware.RequireRole("admin"))
|
||||
authenticatedAdmin.Use(middleware.AuthMiddleware(authService), middleware.RequireRole(models.RoleAdmin))
|
||||
importHandler.RegisterRoutes(authenticatedAdmin)
|
||||
|
||||
// NPM Import Handler - supports Nginx Proxy Manager export format
|
||||
|
||||
23
backend/internal/api/routes/routes_import_contract_test.go
Normal file
23
backend/internal/api/routes/routes_import_contract_test.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package routes_test
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/Wikid82/charon/backend/internal/api/routes"
|
||||
"github.com/Wikid82/charon/backend/internal/config"
|
||||
)
|
||||
|
||||
func TestRegisterImportHandler_StrictRouteMatrix(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupTestImportDB(t)
|
||||
tempDir := t.TempDir()
|
||||
importCaddyfilePath := filepath.Join(tempDir, "import", "Caddyfile")
|
||||
|
||||
router := gin.New()
|
||||
routes.RegisterImportHandler(router, db, config.Config{JWTSecret: "test-secret"}, "echo", tempDir, importCaddyfilePath)
|
||||
|
||||
assertStrictMethodPathMatrix(t, router.Routes(), backendImportRouteMatrix(), "import")
|
||||
}
|
||||
@@ -73,7 +73,7 @@ func TestRegisterImportHandler_AuthzGuards(t *testing.T) {
|
||||
router.ServeHTTP(unauthW, unauthReq)
|
||||
assert.Equal(t, http.StatusUnauthorized, unauthW.Code)
|
||||
|
||||
nonAdmin := &models.User{Email: "user@example.com", Role: "user", Enabled: true}
|
||||
nonAdmin := &models.User{Email: "user@example.com", Role: models.RoleUser, Enabled: true}
|
||||
require.NoError(t, db.Create(nonAdmin).Error)
|
||||
authSvc := services.NewAuthService(db, cfg)
|
||||
token, err := authSvc.GenerateToken(nonAdmin)
|
||||
|
||||
25
backend/internal/api/routes/routes_save_contract_test.go
Normal file
25
backend/internal/api/routes/routes_save_contract_test.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package routes_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"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"
|
||||
)
|
||||
|
||||
func TestRegister_StrictSaveRouteMatrixUsedByImportWorkflows(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared&_test_save_contract_matrix"), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
|
||||
router := gin.New()
|
||||
require.NoError(t, routes.Register(router, db, config.Config{JWTSecret: "test-secret"}))
|
||||
|
||||
assertStrictMethodPathMatrix(t, router.Routes(), saveRouteMatrixForImportWorkflows(), "save")
|
||||
}
|
||||
@@ -10,7 +10,9 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/Wikid82/charon/backend/internal/config"
|
||||
"github.com/Wikid82/charon/backend/internal/models"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/sqlite"
|
||||
@@ -1298,3 +1300,25 @@ func TestRegister_CreatesAccessLogFileForLogWatcher(t *testing.T) {
|
||||
_, statErr := os.Stat(logFilePath)
|
||||
assert.NoError(t, statErr)
|
||||
}
|
||||
|
||||
func TestMigrateViewerToPassthrough(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.AutoMigrate(&models.User{}))
|
||||
|
||||
// Seed a user with the legacy "viewer" role
|
||||
viewer := models.User{
|
||||
UUID: uuid.NewString(),
|
||||
APIKey: uuid.NewString(),
|
||||
Email: "viewer@example.com",
|
||||
Role: models.UserRole("viewer"),
|
||||
Enabled: true,
|
||||
}
|
||||
require.NoError(t, db.Create(&viewer).Error)
|
||||
|
||||
migrateViewerToPassthrough(db)
|
||||
|
||||
var updated models.User
|
||||
require.NoError(t, db.First(&updated, viewer.ID).Error)
|
||||
assert.Equal(t, models.RolePassthrough, updated.Role)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package tests
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
@@ -45,7 +46,7 @@ func createTestAdminUser(t *testing.T, db *gorm.DB) uint {
|
||||
UUID: "admin-uuid-1234",
|
||||
Email: "admin@test.com",
|
||||
Name: "Test Admin",
|
||||
Role: "admin",
|
||||
Role: models.RoleAdmin,
|
||||
Enabled: true,
|
||||
APIKey: "test-api-key",
|
||||
}
|
||||
@@ -66,7 +67,7 @@ func setupRouterWithAuth(db *gorm.DB, userID uint, role string) *gin.Engine {
|
||||
c.Next()
|
||||
})
|
||||
|
||||
userHandler := handlers.NewUserHandler(db)
|
||||
userHandler := handlers.NewUserHandler(db, nil)
|
||||
settingsHandler := handlers.NewSettingsHandler(db)
|
||||
|
||||
api := r.Group("/api")
|
||||
@@ -124,7 +125,7 @@ func TestInviteToken_ExpiredCannotBeUsed(t *testing.T) {
|
||||
user := models.User{
|
||||
UUID: "invite-uuid-1234",
|
||||
Email: "expired@test.com",
|
||||
Role: "user",
|
||||
Role: models.RoleUser,
|
||||
Enabled: false,
|
||||
InviteToken: "expired-token-12345678901234567890123456789012",
|
||||
InviteExpires: &expiredTime,
|
||||
@@ -153,7 +154,7 @@ func TestInviteToken_CannotBeReused(t *testing.T) {
|
||||
UUID: "accepted-uuid-1234",
|
||||
Email: "accepted@test.com",
|
||||
Name: "Accepted User",
|
||||
Role: "user",
|
||||
Role: models.RoleUser,
|
||||
Enabled: true,
|
||||
InviteToken: "accepted-token-1234567890123456789012345678901",
|
||||
InvitedAt: &invitedAt,
|
||||
@@ -217,7 +218,7 @@ func TestAcceptInvite_PasswordValidation(t *testing.T) {
|
||||
user := models.User{
|
||||
UUID: "pending-uuid-1234",
|
||||
Email: "pending@test.com",
|
||||
Role: "user",
|
||||
Role: models.RoleUser,
|
||||
Enabled: false,
|
||||
InviteToken: "valid-token-12345678901234567890123456789012345",
|
||||
InviteExpires: &expires,
|
||||
@@ -269,15 +270,29 @@ func TestUserEndpoints_RequireAdmin(t *testing.T) {
|
||||
UUID: "user-uuid-1234",
|
||||
Email: "user@test.com",
|
||||
Name: "Regular User",
|
||||
Role: "user",
|
||||
Role: models.RoleUser,
|
||||
Enabled: true,
|
||||
APIKey: "user-api-key-unique",
|
||||
}
|
||||
require.NoError(t, user.SetPassword("userpassword123"))
|
||||
require.NoError(t, db.Create(&user).Error)
|
||||
|
||||
// Create a second user to test admin-only operations against a non-self target
|
||||
otherUser := models.User{
|
||||
UUID: "other-uuid-5678",
|
||||
Email: "other@test.com",
|
||||
Name: "Other User",
|
||||
Role: models.RoleUser,
|
||||
Enabled: true,
|
||||
APIKey: "other-api-key-unique",
|
||||
}
|
||||
require.NoError(t, otherUser.SetPassword("otherpassword123"))
|
||||
require.NoError(t, db.Create(&otherUser).Error)
|
||||
|
||||
// Router with regular user role
|
||||
r := setupRouterWithAuth(db, user.ID, "user")
|
||||
|
||||
otherID := fmt.Sprintf("%d", otherUser.ID)
|
||||
endpoints := []struct {
|
||||
method string
|
||||
path string
|
||||
@@ -286,10 +301,10 @@ func TestUserEndpoints_RequireAdmin(t *testing.T) {
|
||||
{"GET", "/api/users", ""},
|
||||
{"POST", "/api/users", `{"email":"new@test.com","name":"New","password":"password123"}`},
|
||||
{"POST", "/api/users/invite", `{"email":"invite@test.com"}`},
|
||||
{"GET", "/api/users/1", ""},
|
||||
{"PUT", "/api/users/1", `{"name":"Updated"}`},
|
||||
{"DELETE", "/api/users/1", ""},
|
||||
{"PUT", "/api/users/1/permissions", `{"permission_mode":"deny_all"}`},
|
||||
{"GET", "/api/users/" + otherID, ""},
|
||||
{"PUT", "/api/users/" + otherID, `{"name":"Updated"}`},
|
||||
{"DELETE", "/api/users/" + otherID, ""},
|
||||
{"PUT", "/api/users/" + otherID + "/permissions", `{"permission_mode":"deny_all"}`},
|
||||
}
|
||||
|
||||
for _, ep := range endpoints {
|
||||
@@ -316,7 +331,7 @@ func TestSMTPEndpoints_RequireAdmin(t *testing.T) {
|
||||
UUID: "user-uuid-5678",
|
||||
Email: "user2@test.com",
|
||||
Name: "Regular User 2",
|
||||
Role: "user",
|
||||
Role: models.RoleUser,
|
||||
Enabled: true,
|
||||
}
|
||||
require.NoError(t, user.SetPassword("userpassword123"))
|
||||
@@ -462,7 +477,7 @@ func TestInviteUser_DuplicateEmailBlocked(t *testing.T) {
|
||||
UUID: "existing-uuid-1234",
|
||||
Email: "existing@test.com",
|
||||
Name: "Existing User",
|
||||
Role: "user",
|
||||
Role: models.RoleUser,
|
||||
Enabled: true,
|
||||
}
|
||||
require.NoError(t, db.Create(&existing).Error)
|
||||
@@ -488,7 +503,7 @@ func TestInviteUser_EmailCaseInsensitive(t *testing.T) {
|
||||
UUID: "existing-uuid-5678",
|
||||
Email: "test@example.com",
|
||||
Name: "Existing User",
|
||||
Role: "user",
|
||||
Role: models.RoleUser,
|
||||
Enabled: true,
|
||||
}
|
||||
require.NoError(t, db.Create(&existing).Error)
|
||||
@@ -532,7 +547,7 @@ func TestUpdatePermissions_ValidModes(t *testing.T) {
|
||||
UUID: "perms-user-1234",
|
||||
Email: "permsuser@test.com",
|
||||
Name: "Perms User",
|
||||
Role: "user",
|
||||
Role: models.RoleUser,
|
||||
Enabled: true,
|
||||
}
|
||||
require.NoError(t, db.Create(&user).Error)
|
||||
@@ -574,7 +589,7 @@ func TestPublicEndpoints_NoAuthRequired(t *testing.T) {
|
||||
// Router WITHOUT auth middleware
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
userHandler := handlers.NewUserHandler(db)
|
||||
userHandler := handlers.NewUserHandler(db, nil)
|
||||
api := r.Group("/api")
|
||||
userHandler.RegisterRoutes(api)
|
||||
|
||||
@@ -584,7 +599,7 @@ func TestPublicEndpoints_NoAuthRequired(t *testing.T) {
|
||||
user := models.User{
|
||||
UUID: "public-test-uuid",
|
||||
Email: "public@test.com",
|
||||
Role: "user",
|
||||
Role: models.RoleUser,
|
||||
Enabled: false,
|
||||
InviteToken: "public-test-token-123456789012345678901234567",
|
||||
InviteExpires: &expires,
|
||||
|
||||
@@ -272,7 +272,7 @@ func (c *Cerberus) isAuthenticatedAdmin(ctx *gin.Context) bool {
|
||||
return false
|
||||
}
|
||||
roleStr, ok := role.(string)
|
||||
if !ok || roleStr != "admin" {
|
||||
if !ok || roleStr != string(models.RoleAdmin) {
|
||||
return false
|
||||
}
|
||||
userID, exists := ctx.Get("userID")
|
||||
|
||||
@@ -7,6 +7,27 @@ import (
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// UserRole represents an authenticated user's privilege tier.
|
||||
type UserRole string
|
||||
|
||||
const (
|
||||
// RoleAdmin has full access to all Charon features and management.
|
||||
RoleAdmin UserRole = "admin"
|
||||
// RoleUser can access the Charon management UI with restricted permissions.
|
||||
RoleUser UserRole = "user"
|
||||
// RolePassthrough can only authenticate for forward-auth proxy access.
|
||||
RolePassthrough UserRole = "passthrough"
|
||||
)
|
||||
|
||||
// IsValid returns true when the role is one of the recognised privilege tiers.
|
||||
func (r UserRole) IsValid() bool {
|
||||
switch r {
|
||||
case RoleAdmin, RoleUser, RolePassthrough:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// PermissionMode determines how user access to proxy hosts is evaluated.
|
||||
type PermissionMode string
|
||||
|
||||
@@ -26,7 +47,7 @@ type User struct {
|
||||
APIKey string `json:"-" gorm:"uniqueIndex"` // For external API access, never exposed in JSON
|
||||
PasswordHash string `json:"-"` // Never serialize password hash
|
||||
Name string `json:"name"`
|
||||
Role string `json:"role" gorm:"default:'user'"` // "admin", "user", "viewer"
|
||||
Role UserRole `json:"role" gorm:"default:'user'"`
|
||||
Enabled bool `json:"enabled" gorm:"default:true"`
|
||||
FailedLoginAttempts int `json:"-" gorm:"default:0"`
|
||||
LockedUntil *time.Time `json:"-"`
|
||||
@@ -77,7 +98,7 @@ func (u *User) HasPendingInvite() bool {
|
||||
// - deny_all mode: User can ONLY access hosts in PermittedHosts (whitelist)
|
||||
func (u *User) CanAccessHost(hostID uint) bool {
|
||||
// Admins always have access
|
||||
if u.Role == "admin" {
|
||||
if u.Role == RoleAdmin {
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -87,7 +87,7 @@ func TestUser_HasPendingInvite(t *testing.T) {
|
||||
func TestUser_CanAccessHost_AllowAll(t *testing.T) {
|
||||
// User with allow_all mode (blacklist) - can access everything except listed hosts
|
||||
user := User{
|
||||
Role: "user",
|
||||
Role: RoleUser,
|
||||
PermissionMode: PermissionModeAllowAll,
|
||||
PermittedHosts: []ProxyHost{
|
||||
{ID: 1}, // Blocked host
|
||||
@@ -107,7 +107,7 @@ func TestUser_CanAccessHost_AllowAll(t *testing.T) {
|
||||
func TestUser_CanAccessHost_DenyAll(t *testing.T) {
|
||||
// User with deny_all mode (whitelist) - can only access listed hosts
|
||||
user := User{
|
||||
Role: "user",
|
||||
Role: RoleUser,
|
||||
PermissionMode: PermissionModeDenyAll,
|
||||
PermittedHosts: []ProxyHost{
|
||||
{ID: 5}, // Allowed host
|
||||
@@ -127,7 +127,7 @@ func TestUser_CanAccessHost_DenyAll(t *testing.T) {
|
||||
func TestUser_CanAccessHost_AdminBypass(t *testing.T) {
|
||||
// Admin users should always have access regardless of permission mode
|
||||
adminUser := User{
|
||||
Role: "admin",
|
||||
Role: RoleAdmin,
|
||||
PermissionMode: PermissionModeDenyAll,
|
||||
PermittedHosts: []ProxyHost{}, // No hosts in whitelist
|
||||
}
|
||||
@@ -140,7 +140,7 @@ func TestUser_CanAccessHost_AdminBypass(t *testing.T) {
|
||||
func TestUser_CanAccessHost_DefaultBehavior(t *testing.T) {
|
||||
// User with empty/default permission mode should behave like allow_all
|
||||
user := User{
|
||||
Role: "user",
|
||||
Role: RoleUser,
|
||||
PermissionMode: "", // Empty = default
|
||||
PermittedHosts: []ProxyHost{
|
||||
{ID: 1}, // Should be blocked
|
||||
@@ -175,7 +175,7 @@ func TestUser_CanAccessHost_EmptyPermittedHosts(t *testing.T) {
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
user := User{
|
||||
Role: "user",
|
||||
Role: RoleUser,
|
||||
PermissionMode: tt.permissionMode,
|
||||
PermittedHosts: []ProxyHost{},
|
||||
}
|
||||
@@ -190,6 +190,31 @@ func TestPermissionMode_Constants(t *testing.T) {
|
||||
assert.Equal(t, PermissionMode("deny_all"), PermissionModeDenyAll)
|
||||
}
|
||||
|
||||
func TestUserRole_Constants(t *testing.T) {
|
||||
assert.Equal(t, UserRole("admin"), RoleAdmin)
|
||||
assert.Equal(t, UserRole("user"), RoleUser)
|
||||
assert.Equal(t, UserRole("passthrough"), RolePassthrough)
|
||||
}
|
||||
|
||||
func TestUserRole_IsValid(t *testing.T) {
|
||||
tests := []struct {
|
||||
role UserRole
|
||||
expected bool
|
||||
}{
|
||||
{RoleAdmin, true},
|
||||
{RoleUser, true},
|
||||
{RolePassthrough, true},
|
||||
{UserRole("viewer"), false},
|
||||
{UserRole("superadmin"), false},
|
||||
{UserRole(""), false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(string(tt.role), func(t *testing.T) {
|
||||
assert.Equal(t, tt.expected, tt.role.IsValid())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to create time pointers
|
||||
func timePtr(t time.Time) *time.Time {
|
||||
return &t
|
||||
|
||||
@@ -33,9 +33,9 @@ func (s *AuthService) Register(email, password, name string) (*models.User, erro
|
||||
var count int64
|
||||
s.db.Model(&models.User{}).Count(&count)
|
||||
|
||||
role := "user"
|
||||
role := models.RoleUser
|
||||
if count == 0 {
|
||||
role = "admin" // First user is admin
|
||||
role = models.RoleAdmin
|
||||
}
|
||||
|
||||
user := &models.User{
|
||||
@@ -98,7 +98,7 @@ func (s *AuthService) GenerateToken(user *models.User) (string, error) {
|
||||
expirationTime := time.Now().Add(24 * time.Hour)
|
||||
claims := &Claims{
|
||||
UserID: user.ID,
|
||||
Role: user.Role,
|
||||
Role: string(user.Role),
|
||||
SessionVersion: user.SessionVersion,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(expirationTime),
|
||||
|
||||
@@ -30,14 +30,14 @@ func TestAuthService_Register(t *testing.T) {
|
||||
// Test 1: First user should be admin
|
||||
admin, err := service.Register("admin@example.com", "password123", "Admin User")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "admin", admin.Role)
|
||||
assert.Equal(t, models.RoleAdmin, admin.Role)
|
||||
assert.NotEmpty(t, admin.PasswordHash)
|
||||
assert.NotEqual(t, "password123", admin.PasswordHash)
|
||||
|
||||
// Test 2: Second user should be regular user
|
||||
user, err := service.Register("user@example.com", "password123", "Regular User")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "user", user.Role)
|
||||
assert.Equal(t, models.RoleUser, user.Role)
|
||||
}
|
||||
|
||||
func TestAuthService_Login(t *testing.T) {
|
||||
@@ -300,7 +300,7 @@ func TestAuthService_AuthenticateToken_InvalidUserIDInClaims(t *testing.T) {
|
||||
|
||||
claims := Claims{
|
||||
UserID: user.ID + 9999,
|
||||
Role: "user",
|
||||
Role: string(models.RoleUser),
|
||||
SessionVersion: user.SessionVersion,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)),
|
||||
|
||||
@@ -45,7 +45,7 @@ func TestBackupService_RehydrateLiveDatabase(t *testing.T) {
|
||||
UUID: uuid.NewString(),
|
||||
Email: "restore-user@example.com",
|
||||
Name: "Restore User",
|
||||
Role: "user",
|
||||
Role: models.RoleUser,
|
||||
Enabled: true,
|
||||
APIKey: uuid.NewString(),
|
||||
}
|
||||
@@ -87,7 +87,7 @@ func TestBackupService_RehydrateLiveDatabase_FromBackupWithWAL(t *testing.T) {
|
||||
UUID: uuid.NewString(),
|
||||
Email: "restore-from-wal@example.com",
|
||||
Name: "Restore From WAL",
|
||||
Role: "user",
|
||||
Role: models.RoleUser,
|
||||
Enabled: true,
|
||||
APIKey: uuid.NewString(),
|
||||
}
|
||||
|
||||
362
docs/plans/archive/uptime_regression_spec.md
Normal file
362
docs/plans/archive/uptime_regression_spec.md
Normal file
@@ -0,0 +1,362 @@
|
||||
# Uptime Monitoring Regression Investigation (Scheduled vs Manual)
|
||||
|
||||
Date: 2026-03-01
|
||||
Owner: Planning Agent
|
||||
Status: Investigation Complete, Fix Plan Proposed
|
||||
Severity: High (false DOWN states on automated monitoring)
|
||||
|
||||
## 1. Executive Summary
|
||||
|
||||
Two services (Wizarr and Charon) can flip to `DOWN` during scheduled cycles while manual checks immediately return `UP` because scheduled checks use a host-level TCP gate that can short-circuit monitor-level HTTP checks.
|
||||
|
||||
The scheduled path is:
|
||||
- `ticker -> CheckAll -> checkAllHosts -> (host status down) -> markHostMonitorsDown`
|
||||
|
||||
The manual path is:
|
||||
- `POST /api/v1/uptime/monitors/:id/check -> CheckMonitor -> checkMonitor`
|
||||
|
||||
Only the scheduled path runs host precheck gating. If host precheck fails (TCP to upstream host/port), `CheckAll` skips HTTP checks and forcibly writes monitor status to `down` with heartbeat message `Host unreachable`.
|
||||
|
||||
This is a backend state mutation problem (not only UI rendering).
|
||||
|
||||
## 1.1 Monitoring Policy (Authoritative Behavior)
|
||||
|
||||
Charon uptime monitoring SHALL follow URL-truth semantics for HTTP/HTTPS monitors,
|
||||
matching third-party external monitor behavior (Uptime Kuma style) without requiring
|
||||
any additional service.
|
||||
|
||||
Policy:
|
||||
- HTTP/HTTPS monitors are URL-truth based. The monitor result is authoritative based
|
||||
on the configured URL check outcome (status code/timeout/TLS/connectivity from URL
|
||||
perspective).
|
||||
- Internal TCP reachability precheck (`ForwardHost:ForwardPort`) is
|
||||
non-authoritative for HTTP/HTTPS monitor status.
|
||||
- TCP monitors remain endpoint-socket checks and may rely on direct socket
|
||||
reachability semantics.
|
||||
- Host precheck may still be used for optimization, grouping telemetry, and operator
|
||||
diagnostics, but SHALL NOT force HTTP/HTTPS monitors to DOWN.
|
||||
|
||||
## 2. Research Findings
|
||||
|
||||
### 2.1 Execution Path Comparison (Required)
|
||||
|
||||
### Scheduled path behavior
|
||||
- Entry: `backend/internal/api/routes/routes.go` (background ticker, calls `uptimeService.CheckAll()`)
|
||||
- `CheckAll()` calls `checkAllHosts()` first.
|
||||
- File: `backend/internal/services/uptime_service.go:354`
|
||||
- `checkAllHosts()` updates each `UptimeHost.Status` via TCP checks in `checkHost()`.
|
||||
- File: `backend/internal/services/uptime_service.go:395`
|
||||
- `checkHost()` dials `UptimeHost.Host` + monitor port (prefer `ProxyHost.ForwardPort`, fallback to URL port).
|
||||
- File: `backend/internal/services/uptime_service.go:437`
|
||||
- Back in `CheckAll()`, monitors are grouped by `UptimeHostID`.
|
||||
- File: `backend/internal/services/uptime_service.go:367`
|
||||
- If `UptimeHost.Status == "down"`, `markHostMonitorsDown()` is called and individual monitor checks are skipped.
|
||||
- File: `backend/internal/services/uptime_service.go:381`
|
||||
- File: `backend/internal/services/uptime_service.go:593`
|
||||
|
||||
### Manual path behavior
|
||||
- Entry: `POST /api/v1/uptime/monitors/:id/check`.
|
||||
- Handler: `backend/internal/api/handlers/uptime_handler.go:107`
|
||||
- Calls `service.CheckMonitor(*monitor)` asynchronously.
|
||||
- File: `backend/internal/services/uptime_service.go:707`
|
||||
- `checkMonitor()` performs direct HTTP/TCP monitor check and updates monitor + heartbeat.
|
||||
- File: `backend/internal/services/uptime_service.go:711`
|
||||
|
||||
### Key divergence
|
||||
- Scheduled: host-gated (precheck can override monitor)
|
||||
- Manual: direct monitor check (no host gate)
|
||||
|
||||
## 3. Root Cause With Evidence
|
||||
|
||||
## 3.1 Primary Root Cause: Host Precheck Overrides HTTP Success in Scheduled Cycles
|
||||
|
||||
When `UptimeHost` is marked `down`, scheduled checks do not run `checkMonitor()` for that host's monitors. Instead they call `markHostMonitorsDown()` which:
|
||||
- sets each monitor `Status = "down"`
|
||||
- writes `UptimeHeartbeat{Status: "down", Message: "Host unreachable"}`
|
||||
- maxes failure count (`FailureCount = MaxRetries`)
|
||||
|
||||
Evidence:
|
||||
- Short-circuit: `backend/internal/services/uptime_service.go:381`
|
||||
- Forced down write: `backend/internal/services/uptime_service.go:610`
|
||||
- Forced heartbeat message: `backend/internal/services/uptime_service.go:624`
|
||||
|
||||
This exactly matches symptom pattern:
|
||||
1. Manual refresh sets monitor `UP` via direct HTTP check.
|
||||
2. Next scheduler cycle can force it back to `DOWN` from host precheck path.
|
||||
|
||||
## 3.2 Hypothesis Check: TCP precheck can fail while public URL HTTP check succeeds
|
||||
|
||||
Confirmed as plausible by design:
|
||||
- `checkHost()` tests upstream reachability (`ForwardHost:ForwardPort`) from Charon runtime.
|
||||
- `checkMonitor()` tests monitor URL (public domain URL, often via Caddy/public routing).
|
||||
|
||||
A service can be publicly reachable by monitor URL while upstream TCP precheck fails due to network namespace/routing/DNS/hairpin differences.
|
||||
|
||||
This is especially likely for:
|
||||
- self-referential routes (Charon monitoring Charon via public hostname)
|
||||
- host/container networking asymmetry
|
||||
- services reachable through proxy path but not directly on upstream socket from current runtime context
|
||||
|
||||
## 3.3 Recent Change Correlation (Required)
|
||||
|
||||
### `SyncAndCheckForHost` (regression amplifier)
|
||||
- Introduced in commit `2cd19d89` and called from proxy host create path.
|
||||
- Files:
|
||||
- `backend/internal/services/uptime_service.go:1195`
|
||||
- `backend/internal/api/handlers/proxy_host_handler.go:418`
|
||||
- Behavior: creates/syncs monitor and immediately runs `checkMonitor()`.
|
||||
|
||||
Impact: makes monitors quickly show `UP` after create/manual, then scheduler can flip to `DOWN` if host precheck fails. This increased visibility of scheduled/manual inconsistency.
|
||||
|
||||
### `CleanupStaleFailureCounts`
|
||||
- Introduced in `2cd19d89`, refined in `7a12ab79`.
|
||||
- File: `backend/internal/services/uptime_service.go:1277`
|
||||
- It runs at startup and resets stale monitor states only; not per-cycle override logic.
|
||||
- Not root cause of recurring per-cycle flip.
|
||||
|
||||
### Frontend effective status changes
|
||||
- Latest commit `0241de69` refactors `effectiveStatus` handling.
|
||||
- File: `frontend/src/pages/Uptime.tsx`.
|
||||
- Backend evidence proves this is not visual-only: scheduler writes `down` heartbeats/messages directly in DB.
|
||||
|
||||
## 3.4 Grouping Logic Analysis (`UptimeHost`/`UpstreamHost`)
|
||||
|
||||
Monitors are grouped by `UptimeHostID` in `CheckAll()`. `UptimeHost` is derived from `ProxyHost.ForwardHost` in sync flows.
|
||||
|
||||
Relevant code:
|
||||
- group map by `UptimeHostID`: `backend/internal/services/uptime_service.go:367`
|
||||
- host linkage in sync: `backend/internal/services/uptime_service.go:189`, `backend/internal/services/uptime_service.go:226`
|
||||
- sync single-host update path: `backend/internal/services/uptime_service.go:1023`
|
||||
|
||||
Risk: one host precheck failure can mark all grouped monitors down without URL-level validation.
|
||||
|
||||
## 4. Technical Specification (Fix Plan)
|
||||
|
||||
## 4.1 Minimal Proper Fix (First)
|
||||
|
||||
Goal: eliminate false DOWN while preserving existing behavior as much as possible.
|
||||
|
||||
Change `CheckAll()` host-down branch to avoid hard override for HTTP/HTTPS monitors.
|
||||
|
||||
Mandatory hotfix rule:
|
||||
- WHEN a host precheck is `down`, THE SYSTEM SHALL partition host monitors by type inside `CheckAll()`.
|
||||
- `markHostMonitorsDown` MUST be invoked only for `tcp` monitors.
|
||||
- `http`/`https` monitors MUST still run through `checkMonitor()` and MUST NOT be force-written `down` by the host precheck path.
|
||||
- Host precheck outcomes MAY be recorded for optimization/telemetry/grouping, but MUST NOT be treated as final status for `http`/`https` monitors.
|
||||
|
||||
Proposed rule:
|
||||
1. If host is down:
|
||||
- For `http`/`https` monitors: still run `checkMonitor()` (do not force down).
|
||||
- For `tcp` monitors: keep current host-down fast-path (`markHostMonitorsDown`) or direct tcp check.
|
||||
2. If host is not down:
|
||||
- Keep existing behavior (run `checkMonitor()` for all monitors).
|
||||
|
||||
Rationale:
|
||||
- Aligns scheduled behavior with manual for URL-based monitors.
|
||||
- Preserves reverse proxy product semantics where public URL availability is the source of truth.
|
||||
- Minimal code delta in `CheckAll()` decision branch.
|
||||
- Preserves optimization for true TCP-only monitors.
|
||||
|
||||
### Exact file/function targets
|
||||
- `backend/internal/services/uptime_service.go`
|
||||
- `CheckAll()`
|
||||
- add small helper (optional): `partitionMonitorsByType(...)`
|
||||
|
||||
## 4.2 Long-Term Robust Fix (Deferred)
|
||||
|
||||
Introduce host precheck as advisory signal, not authoritative override.
|
||||
|
||||
Design:
|
||||
1. Add `HostReachability` result to run context (not persisted as forced monitor status).
|
||||
2. Always execute per-monitor checks, but use host precheck to:
|
||||
- tune retries/backoff
|
||||
- annotate failure reason
|
||||
- optimize notification batching
|
||||
3. Optionally add feature flag:
|
||||
- `feature.uptime.strict_host_precheck` (default `false`)
|
||||
- allows legacy strict gating in environments that want it.
|
||||
|
||||
Benefits:
|
||||
- Removes false DOWN caused by precheck mismatch.
|
||||
- Keeps performance and batching controls.
|
||||
- More explicit semantics for operators.
|
||||
|
||||
## 5. API/Schema Impact
|
||||
|
||||
No API contract change required for minimal fix.
|
||||
No database migration required for minimal fix.
|
||||
|
||||
Long-term fix may add one feature flag setting only.
|
||||
|
||||
## 6. EARS Requirements
|
||||
|
||||
### Ubiquitous
|
||||
- THE SYSTEM SHALL evaluate HTTP/HTTPS monitor availability using URL-level checks as the authoritative signal.
|
||||
|
||||
### Event-driven
|
||||
- WHEN the scheduled uptime cycle runs, THE SYSTEM SHALL execute HTTP/HTTPS monitor checks regardless of internal host precheck state.
|
||||
- WHEN the scheduled uptime cycle runs and host precheck is down, THE SYSTEM SHALL apply host-level forced-down logic only to TCP monitors.
|
||||
|
||||
### State-driven
|
||||
- WHILE a monitor type is `http` or `https`, THE SYSTEM SHALL NOT force monitor status to `down` solely from internal host precheck failure.
|
||||
- WHILE a monitor type is `tcp`, THE SYSTEM SHALL evaluate status using endpoint socket reachability semantics.
|
||||
|
||||
### Unwanted behavior
|
||||
- IF internal host precheck is unreachable AND URL-level HTTP/HTTPS check returns success, THEN THE SYSTEM SHALL set monitor status to `up`.
|
||||
- IF internal host precheck is reachable AND URL-level HTTP/HTTPS check fails, THEN THE SYSTEM SHALL set monitor status to `down`.
|
||||
|
||||
### Optional
|
||||
- WHERE host precheck telemetry is enabled, THE SYSTEM SHALL record host-level reachability for diagnostics and grouping without overriding HTTP/HTTPS monitor final state.
|
||||
|
||||
## 7. Implementation Plan
|
||||
|
||||
### Phase 1: Reproduction Lock-In (Tests First)
|
||||
- Add backend service test proving current regression:
|
||||
- host precheck fails
|
||||
- monitor URL check would succeed
|
||||
- scheduled `CheckAll()` currently writes down (existing behavior)
|
||||
- File: `backend/internal/services/uptime_service_test.go` (new test block)
|
||||
|
||||
### Phase 2: Minimal Backend Fix
|
||||
- Update `CheckAll()` branch logic to run HTTP/HTTPS monitors even when host is down.
|
||||
- Make monitor partitioning explicit and mandatory in `CheckAll()` host-down branch.
|
||||
- Add an implementation guard before partitioning: normalize monitor type using
|
||||
`strings.TrimSpace` + `strings.ToLower` to prevent `HTTP`/`HTTPS` case
|
||||
regressions and whitespace-related misclassification.
|
||||
- Ensure `markHostMonitorsDown` is called only for TCP monitor partitions.
|
||||
- File: `backend/internal/services/uptime_service.go`
|
||||
|
||||
### Phase 3: Backend Validation
|
||||
- Add/adjust tests:
|
||||
- scheduled path no longer forces down when HTTP succeeds
|
||||
- manual and scheduled reach same final state for HTTP monitors
|
||||
- internal host unreachable + public URL HTTP 200 => monitor is `UP`
|
||||
- internal host reachable + public URL failure => monitor is `DOWN`
|
||||
- TCP monitor behavior unchanged under host-down conditions
|
||||
- Files:
|
||||
- `backend/internal/services/uptime_service_test.go`
|
||||
- `backend/internal/services/uptime_service_race_test.go` (if needed for concurrency side-effects)
|
||||
|
||||
### Phase 4: Integration/E2E Coverage
|
||||
- Add targeted API-level integration test for scheduler vs manual parity.
|
||||
- Add Playwright scenario for:
|
||||
- monitor set UP by manual check
|
||||
- remains UP after scheduled cycle when URL is reachable
|
||||
- Add parity scenario for:
|
||||
- internal TCP precheck unreachable + URL returns 200 => `UP`
|
||||
- internal TCP precheck reachable + URL failure => `DOWN`
|
||||
- Files:
|
||||
- `backend/internal/api/routes/routes_test.go` (or uptime handler integration suite)
|
||||
- `tests/monitoring/uptime-monitoring.spec.ts` (or equivalent uptime spec file)
|
||||
|
||||
Scope note:
|
||||
- This hotfix plan is intentionally limited to backend behavior correction and
|
||||
regression tests (unit/integration/E2E).
|
||||
- Dedicated documentation-phase work is deferred and out of scope for this
|
||||
hotfix PR.
|
||||
|
||||
## 8. Test Plan (Unit / Integration / E2E)
|
||||
|
||||
Duplicate notification definition (hotfix acceptance/testing):
|
||||
- A duplicate notification means the same `(monitor_id, status,
|
||||
scheduler_tick_id)` is emitted more than once within a single scheduler run.
|
||||
|
||||
## Unit Tests
|
||||
1. `CheckAll_HostDown_DoesNotForceDown_HTTPMonitor_WhenHTTPCheckSucceeds`
|
||||
2. `CheckAll_HostDown_StillHandles_TCPMonitor_Conservatively`
|
||||
3. `CheckAll_ManualAndScheduledParity_HTTPMonitor`
|
||||
4. `CheckAll_InternalHostUnreachable_PublicURL200_HTTPMonitorEndsUp` (blocking)
|
||||
5. `CheckAll_InternalHostReachable_PublicURLFail_HTTPMonitorEndsDown` (blocking)
|
||||
|
||||
## Integration Tests
|
||||
1. Scheduler endpoint (`/api/v1/system/uptime/check`) parity with monitor check endpoint.
|
||||
2. Verify DB heartbeat message is real HTTP result (not `Host unreachable`) for HTTP monitors where URL is reachable.
|
||||
3. Verify when host precheck is down, HTTP monitor heartbeat/notification output is derived from `checkMonitor()` (not synthetic host-path `Host unreachable`).
|
||||
4. Verify no duplicate notifications are emitted from host+monitor paths for the same scheduler run, where duplicate is defined as repeated `(monitor_id, status, scheduler_tick_id)`.
|
||||
5. Verify internal host precheck unreachable + public URL 200 still resolves monitor `UP`.
|
||||
6. Verify internal host precheck reachable + public URL failure resolves monitor `DOWN`.
|
||||
|
||||
## E2E Tests
|
||||
1. Create/sync monitor scenario where manual refresh returns `UP`.
|
||||
2. Wait one scheduler interval.
|
||||
3. Assert monitor remains `UP` and latest heartbeat is not forced `Host unreachable` for reachable URL.
|
||||
4. Assert scenario: internal host precheck unreachable + public URL 200 => monitor remains `UP`.
|
||||
5. Assert scenario: internal host precheck reachable + public URL failure => monitor is `DOWN`.
|
||||
|
||||
## Regression Guardrails
|
||||
- Add a test explicitly asserting that host precheck must not unconditionally override HTTP monitor checks.
|
||||
- Add explicit assertions that HTTP monitors under host-down precheck emit
|
||||
check-derived heartbeat messages and do not produce duplicate notifications
|
||||
under the `(monitor_id, status, scheduler_tick_id)` rule within a single
|
||||
scheduler run.
|
||||
|
||||
## 9. Risks and Rollback
|
||||
|
||||
## Risks
|
||||
1. More HTTP checks under true host outage may increase check volume.
|
||||
2. Notification patterns may shift from single host-level event to monitor-level batched events.
|
||||
3. Edge cases for mixed-type monitor groups (HTTP + TCP) need deterministic behavior.
|
||||
|
||||
## Mitigations
|
||||
1. Preserve batching (`queueDownNotification`) and existing retry thresholds.
|
||||
2. Keep TCP strict path unchanged in minimal fix.
|
||||
3. Add explicit log fields and targeted tests for mixed groups.
|
||||
|
||||
## Rollback Plan
|
||||
1. Revert the `CheckAll()` branch change only (single-file rollback).
|
||||
2. Keep added tests; mark expected behavior as legacy if temporary rollback needed.
|
||||
3. If necessary, introduce temporary feature toggle to switch between strict and tolerant host gating.
|
||||
|
||||
## 10. PR Slicing Strategy
|
||||
|
||||
Decision: Single focused PR (hotfix + tests)
|
||||
|
||||
Trigger reasons:
|
||||
- High-severity runtime behavior fix requiring minimal blast radius
|
||||
- Fast review/rollback with behavior-only delta plus regression coverage
|
||||
- Avoid scope creep into optional hardening/feature-flag work
|
||||
|
||||
### PR-1 (Hotfix + Tests)
|
||||
Scope:
|
||||
- `CheckAll()` host-down branch adjustment for HTTP/HTTPS
|
||||
- Unit/integration/E2E regression tests for URL-truth semantics
|
||||
|
||||
Files:
|
||||
- `backend/internal/services/uptime_service.go`
|
||||
- `backend/internal/services/uptime_service_test.go`
|
||||
- `backend/internal/api/routes/routes_test.go` (or equivalent)
|
||||
- `tests/monitoring/uptime-monitoring.spec.ts` (or equivalent)
|
||||
|
||||
Validation gates:
|
||||
- backend unit tests pass
|
||||
- targeted uptime integration tests pass
|
||||
- targeted uptime E2E tests pass
|
||||
- no behavior regression in existing `CheckAll` tests
|
||||
|
||||
Rollback:
|
||||
- single revert of PR-1 commit
|
||||
|
||||
## 11. Acceptance Criteria (DoD)
|
||||
|
||||
1. Scheduled and manual checks produce consistent status for HTTP/HTTPS monitors.
|
||||
2. A reachable monitor URL is not forced to `DOWN` solely by host precheck failure.
|
||||
3. New regression tests fail before fix and pass after fix.
|
||||
4. No break in TCP monitor behavior expectations.
|
||||
5. No new critical/high security findings in touched paths.
|
||||
6. Blocking parity case passes: internal host precheck unreachable + public URL 200 => scheduled result is `UP`.
|
||||
7. Blocking parity case passes: internal host precheck reachable + public URL failure => scheduled result is `DOWN`.
|
||||
8. Under host-down precheck, HTTP monitors produce check-derived heartbeat messages (not synthetic `Host unreachable` from host path).
|
||||
9. No duplicate notifications are produced by host+monitor paths within a
|
||||
single scheduler run, where duplicate is defined as repeated
|
||||
`(monitor_id, status, scheduler_tick_id)`.
|
||||
|
||||
## 12. Implementation Risks
|
||||
|
||||
1. Increased scheduler workload during host-precheck failures because HTTP/HTTPS checks continue to run.
|
||||
2. Notification cadence may change due to check-derived monitor outcomes replacing host-forced synthetic downs.
|
||||
3. Mixed monitor groups (TCP + HTTP/HTTPS) require strict ordering/partitioning to avoid regression.
|
||||
|
||||
Mitigations:
|
||||
- Keep change localized to `CheckAll()` host-down branch decisioning.
|
||||
- Add explicit regression tests for both parity directions and mixed monitor types.
|
||||
- Keep rollback path as single-commit revert.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
# PR-1 Caddy Compatibility Matrix Report
|
||||
|
||||
- Generated at: 2026-02-23T13:52:26Z
|
||||
- Candidate Caddy version: 2.11.1
|
||||
- Candidate Caddy version: 2.11.2
|
||||
- Plugin set: caddy-security,coraza-caddy,caddy-crowdsec-bouncer,caddy-geoip2,caddy-ratelimit
|
||||
- Smoke set: boot_caddy,plugin_modules,config_validate,admin_api_health
|
||||
- Matrix dimensions: patch scenario × platform/arch × checked plugin modules
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
- Date: 2026-02-23
|
||||
- Scope: PR-2 only (security patch posture + xcaddy patch retirement decision)
|
||||
- Upstream target: Caddy 2.11.x line (`2.11.1` candidate in this repository)
|
||||
- Upstream target: Caddy 2.11.x line (`2.11.2` candidate in this repository)
|
||||
- Inputs:
|
||||
- PR-1 compatibility matrix: `docs/reports/caddy-compatibility-matrix.md`
|
||||
- Plan authority: `docs/plans/current_spec.md`
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
## QA Report - PR #779
|
||||
## QA Report — Import/Save Route Regression Test Suite
|
||||
|
||||
- Date: 2026-03-01
|
||||
- Scope: Post-remediation merge-readiness gates after Caddy Import E2E fix
|
||||
- Date: 2026-03-02
|
||||
- Branch: `feature/beta-release` (HEAD `2f90d936`)
|
||||
- Scope: Regression test coverage for import and save function routes
|
||||
- Full report: [docs/reports/qa_report_import_save_regression.md](qa_report_import_save_regression.md)
|
||||
|
||||
## E2E Status
|
||||
|
||||
|
||||
188
docs/reports/qa_report_import_save_regression.md
Normal file
188
docs/reports/qa_report_import_save_regression.md
Normal file
@@ -0,0 +1,188 @@
|
||||
## QA Report — Import/Save Route Regression Test Suite
|
||||
|
||||
- **Date**: 2026-03-02
|
||||
- **Branch**: `feature/beta-release`
|
||||
- **HEAD**: `2f90d936` — `fix(tests): simplify back/cancel button handling in cross-browser import tests`
|
||||
- **Scope**: Regression test implementation for import and save function routes
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| DoD Gate | Result | Notes |
|
||||
|---|---|---|
|
||||
| Patch Coverage Preflight | ✅ PASS | 100% — 12/12 changed lines covered |
|
||||
| Backend Unit Tests + Coverage | ✅ PASS | 87.9% statements (threshold: 87%) |
|
||||
| Frontend Unit Tests + Coverage | ✅ PASS | 89.63% lines (threshold: 87%) |
|
||||
| TypeScript Type Check | ✅ PASS | 0 type errors |
|
||||
| Pre-commit Hooks | ✅ PASS | 17/17 hooks passed |
|
||||
| GORM Security Scan | ⏭️ SKIP | No model files changed |
|
||||
| Trivy FS Scan | ✅ PASS | 0 HIGH/CRITICAL in npm packages |
|
||||
| Docker Image Scan | ✅ PASS | 0 HIGH/CRITICAL (13 LOW/MED total) |
|
||||
| CodeQL Analysis | ✅ PASS | 1 pre-existing warning (not a regression) |
|
||||
|
||||
**Overall Verdict: PASS** — All gated checks passed. Two pre-existing items documented below.
|
||||
|
||||
---
|
||||
|
||||
## New Test Files
|
||||
|
||||
Eight test files were added as part of this feature:
|
||||
|
||||
| File | Type | Tests |
|
||||
|---|---|---|
|
||||
| `backend/internal/api/routes/routes_import_contract_test.go` | Backend unit | Route contract coverage |
|
||||
| `backend/internal/api/routes/routes_save_contract_test.go` | Backend unit | Route contract coverage |
|
||||
| `backend/internal/api/routes/endpoint_inventory_test.go` | Backend unit | Endpoint inventory/matrix |
|
||||
| `frontend/src/api/__tests__/npmImport.test.ts` | Frontend unit | 6 tests |
|
||||
| `frontend/src/api/__tests__/jsonImport.test.ts` | Frontend unit | 6 tests |
|
||||
| `frontend/src/hooks/__tests__/useNPMImport.test.tsx` | Frontend unit | 5 tests |
|
||||
| `frontend/src/hooks/__tests__/useJSONImport.test.tsx` | Frontend unit | 5 tests |
|
||||
| `tests/integration/import-save-route-regression.spec.ts` | Integration | Route regression spec |
|
||||
|
||||
All 22 new frontend tests passed. Backend route package runs clean.
|
||||
|
||||
---
|
||||
|
||||
## Step 1 — Patch Coverage Preflight
|
||||
|
||||
- **Command**: `bash scripts/local-patch-report.sh`
|
||||
- **Artifacts**: `test-results/local-patch-report.md`, `test-results/local-patch-report.json`
|
||||
- **Result**: PASS
|
||||
- **Metrics**:
|
||||
- Overall patch coverage: 100% (12/12 changed lines)
|
||||
- Backend changed lines: 8/8 covered (100%)
|
||||
- Frontend changed lines: 4/4 covered (100%)
|
||||
|
||||
---
|
||||
|
||||
## Step 2 — Backend Unit Tests + Coverage
|
||||
|
||||
- **Command**: `bash scripts/go-test-coverage.sh`
|
||||
- **Result**: PASS
|
||||
- **Metrics**:
|
||||
- Total statements: 87.9%
|
||||
- `internal/api/routes` package: 87.8%
|
||||
- Gate threshold: 87%
|
||||
- **Package results**: 25/26 packages `ok`
|
||||
- **Known exception**: `internal/api/handlers` — 1 test fails in full suite only
|
||||
|
||||
### Pre-existing Backend Failure
|
||||
|
||||
| Item | Detail |
|
||||
|---|---|
|
||||
| Test | `TestSecurityHandler_UpsertRuleSet_XSSInContent` |
|
||||
| Package | `internal/api/handlers` |
|
||||
| File | `security_handler_audit_test.go` |
|
||||
| Behaviour | Fails in full suite (`FAIL: expected 200, got {"error":"failed to list rule sets"}`); passes in isolation |
|
||||
| Cause | Parallel test state pollution — shared in-memory SQLite DB contaminated by another test in the same package |
|
||||
| Introduced by this PR | No — file shows no git changes in this session |
|
||||
| Regression | No |
|
||||
|
||||
---
|
||||
|
||||
## Step 3 — Frontend Unit Tests + Coverage
|
||||
|
||||
- **Command**: `bash scripts/frontend-test-coverage.sh`
|
||||
- **Result**: PASS
|
||||
- **Metrics**:
|
||||
- Lines: 89.63% (threshold: 87%)
|
||||
- Statements: 88.96%
|
||||
- Functions: 86.06%
|
||||
- Branches: 81.41%
|
||||
- **Test counts**: 589 passed, 23 skipped, 0 failed, 24 test suites
|
||||
|
||||
### New Frontend Test Results
|
||||
|
||||
All four new test files passed explicitly:
|
||||
|
||||
```
|
||||
✅ npmImport.test.ts 6 tests passed
|
||||
✅ jsonImport.test.ts 6 tests passed
|
||||
✅ useNPMImport.test.tsx 5 tests passed
|
||||
✅ useJSONImport.test.tsx 5 tests passed
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 4 — TypeScript Type Check
|
||||
|
||||
- **Command**: `npm run type-check`
|
||||
- **Result**: PASS — 0 errors, clean exit
|
||||
|
||||
---
|
||||
|
||||
## Step 5 — Pre-commit Hooks
|
||||
|
||||
- **Command**: `pre-commit run --all-files`
|
||||
- **Result**: PASS — 17/17 hooks passed
|
||||
|
||||
Hooks verified include: `fix-end-of-files`, `trim-trailing-whitespace`, `check-yaml`, `shellcheck`, `actionlint`, `dockerfile-validation`, `go-vet`, `golangci-lint (Fast Linters - BLOCKING)`, `frontend-typecheck`, `frontend-lint`.
|
||||
|
||||
---
|
||||
|
||||
## Step 6 — GORM Security Scan
|
||||
|
||||
- **Result**: SKIPPED
|
||||
- **Reason**: No files under `backend/internal/models/**` or GORM service/repository paths were modified in this session.
|
||||
|
||||
---
|
||||
|
||||
## Step 7 — Security Scans
|
||||
|
||||
### Trivy Filesystem Scan
|
||||
|
||||
- **Command**: `trivy fs . --severity HIGH,CRITICAL --exit-code 1 --skip-dirs .git,node_modules,...`
|
||||
- **Result**: PASS — 0 HIGH/CRITICAL vulnerabilities
|
||||
- **Scope**: `package-lock.json` (npm)
|
||||
- **Report**: `trivy-report.json`
|
||||
|
||||
### Docker Image Scan
|
||||
|
||||
- **Command**: `.github/skills/scripts/skill-runner.sh security-scan-docker-image`
|
||||
- **Result**: PASS — 0 HIGH/CRITICAL vulnerabilities
|
||||
- **Total findings**: 13 (all LOW or MEDIUM severity)
|
||||
- **Verdict**: Gate passed — no action required
|
||||
|
||||
### CodeQL Analysis
|
||||
|
||||
- **SARIF files**:
|
||||
- `codeql-results-go.sarif` — generated 2026-03-02
|
||||
- `codeql-results-javascript.sarif` — generated 2026-03-02
|
||||
- **Go results**: 1 finding — `go/cookie-secure-not-set` (warning level)
|
||||
- **JavaScript results**: 0 findings
|
||||
- **Result**: PASS (no error-level findings)
|
||||
|
||||
#### Pre-existing CodeQL Finding
|
||||
|
||||
| Item | Detail |
|
||||
|---|---|
|
||||
| Rule | `go/cookie-secure-not-set` |
|
||||
| File | `internal/api/handlers/auth_handler.go:151–159` |
|
||||
| Severity | Warning (non-blocking) |
|
||||
| Description | Cookie does not set `Secure` attribute to `true` |
|
||||
| Context | Intentional design: `secure` flag defaults to `true`; set to `false` **only** for local loopback requests without TLS. This allows the management UI to function over HTTP on `localhost` during development. The code comment explicitly documents this decision: _"Secure: true for HTTPS; false only for local non-HTTPS loopback flows"_ |
|
||||
| Introduced by this PR | No — `auth_handler.go` was last modified in commits predating HEAD (`e348b5b2`, `00349689`) |
|
||||
| Regression | No |
|
||||
| Action | None — accepted as intentional design trade-off for local-dev UX |
|
||||
|
||||
---
|
||||
|
||||
## Pre-existing Issues Register
|
||||
|
||||
| ID | Location | Nature | Regression? | Action |
|
||||
|---|---|---|---|---|
|
||||
| PE-001 | `handlers.TestSecurityHandler_UpsertRuleSet_XSSInContent` | Test isolation failure — parallel SQLite state pollution | No | Track separately; fix with test DB isolation |
|
||||
| PE-002 | `auth_handler.go:151` — `go/cookie-secure-not-set` | CodeQL warning; intentional local-dev design | No | Accepted; document as acknowledged finding |
|
||||
|
||||
---
|
||||
|
||||
## Related Commits
|
||||
|
||||
| Hash | Message |
|
||||
|---|---|
|
||||
| `63e79664` | `test(routes): add strict route matrix tests for import and save workflows` |
|
||||
| `077e3c1d` | `chore: add integration tests for import/save route regression coverage` |
|
||||
| `f60a99d0` | `fix(tests): update route validation functions to ensure canonical success responses in import/save regression tests` |
|
||||
| `b5fd5d57` | `fix(tests): update import handler test to use temporary directory for Caddyfile path` |
|
||||
| `2f90d936` | `fix(tests): simplify back/cancel button handling in cross-browser import tests` |
|
||||
116
frontend/package-lock.json
generated
116
frontend/package-lock.json
generated
@@ -19,9 +19,9 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"i18next": "^25.8.13",
|
||||
"i18next": "^25.8.14",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"lucide-react": "^0.575.0",
|
||||
"lucide-react": "^0.577.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-hook-form": "^7.71.2",
|
||||
@@ -29,7 +29,7 @@
|
||||
"react-i18next": "^16.5.4",
|
||||
"react-router-dom": "^7.13.1",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tldts": "^7.0.23"
|
||||
"tldts": "^7.0.24"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/css": "^0.14.1",
|
||||
@@ -56,7 +56,7 @@
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"jsdom": "28.1.0",
|
||||
"knip": "^5.85.0",
|
||||
"postcss": "^8.5.6",
|
||||
"postcss": "^8.5.8",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.56.1",
|
||||
@@ -579,9 +579,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-syntax-patches-for-csstree": {
|
||||
"version": "1.0.28",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.28.tgz",
|
||||
"integrity": "sha512-1NRf1CUBjnr3K7hu8BLxjQrKCxEe8FP/xmPTenAxCRZWVLbmGotkFvG9mfNpjA6k7Bw1bw4BilZq9cu19RA5pg==",
|
||||
"version": "1.0.29",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.29.tgz",
|
||||
"integrity": "sha512-jx9GjkkP5YHuTmko2eWAvpPnb0mB4mGRr2U7XwVNwevm8nlpobZEVk+GNmiYMk2VuA75v+plfXWyroWKmICZXg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -1391,9 +1391,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@exodus/bytes": {
|
||||
"version": "1.14.1",
|
||||
"resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.14.1.tgz",
|
||||
"integrity": "sha512-OhkBFWI6GcRMUroChZiopRiSp2iAMvEBK47NhJooDqz1RERO4QuZIZnjP63TXX8GAiLABkYmX+fuQsdJ1dd2QQ==",
|
||||
"version": "1.15.0",
|
||||
"resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz",
|
||||
"integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -1409,31 +1409,31 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/core": {
|
||||
"version": "1.7.4",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.4.tgz",
|
||||
"integrity": "sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==",
|
||||
"version": "1.7.5",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz",
|
||||
"integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/utils": "^0.2.10"
|
||||
"@floating-ui/utils": "^0.2.11"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/dom": {
|
||||
"version": "1.7.5",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.5.tgz",
|
||||
"integrity": "sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg==",
|
||||
"version": "1.7.6",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz",
|
||||
"integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/core": "^1.7.4",
|
||||
"@floating-ui/utils": "^0.2.10"
|
||||
"@floating-ui/core": "^1.7.5",
|
||||
"@floating-ui/utils": "^0.2.11"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/react-dom": {
|
||||
"version": "2.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.7.tgz",
|
||||
"integrity": "sha512-0tLRojf/1Go2JgEVm+3Frg9A3IW8bJgKgdO0BN5RkF//ufuz2joZM63Npau2ff3J6lUVYgDSNzNkR+aH3IVfjg==",
|
||||
"version": "2.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz",
|
||||
"integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/dom": "^1.7.5"
|
||||
"@floating-ui/dom": "^1.7.6"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
@@ -1441,9 +1441,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/utils": {
|
||||
"version": "0.2.10",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz",
|
||||
"integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==",
|
||||
"version": "0.2.11",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz",
|
||||
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@humanfs/core": {
|
||||
@@ -4350,9 +4350,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001775",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001775.tgz",
|
||||
"integrity": "sha512-s3Qv7Lht9zbVKE9XoTyRG6wVDCKdtOFIjBGg3+Yhn6JaytuNKPIjBMTMIY1AnOH3seL5mvF+x33oGAyK3hVt3A==",
|
||||
"version": "1.0.30001776",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001776.tgz",
|
||||
"integrity": "sha512-sg01JDPzZ9jGshqKSckOQthXnYwOEP50jeVFhaSFbZcOy05TiuuaffDOfcwtCisJ9kNQuLBFibYywv2Bgm9osw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -4543,13 +4543,13 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cssstyle": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-6.1.0.tgz",
|
||||
"integrity": "sha512-Ml4fP2UT2K3CUBQnVlbdV/8aFDdlY69E+YnwJM+3VUWl08S3J8c8aRuJqCkD9Py8DHZ7zNNvsfKl8psocHZEFg==",
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-6.2.0.tgz",
|
||||
"integrity": "sha512-Fm5NvhYathRnXNVndkUsCCuR63DCLVVwGOOwQw782coXFi5HhkXdu289l59HlXZBawsyNccXfWRYvLzcDCdDig==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@asamuzakjp/css-color": "^5.0.0",
|
||||
"@asamuzakjp/css-color": "^5.0.1",
|
||||
"@csstools/css-syntax-patches-for-csstree": "^1.0.28",
|
||||
"css-tree": "^3.1.0",
|
||||
"lru-cache": "^11.2.6"
|
||||
@@ -4716,9 +4716,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.302",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz",
|
||||
"integrity": "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==",
|
||||
"version": "1.5.307",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.307.tgz",
|
||||
"integrity": "sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
@@ -5305,9 +5305,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/flatted": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz",
|
||||
"integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==",
|
||||
"version": "3.3.4",
|
||||
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.4.tgz",
|
||||
"integrity": "sha512-3+mMldrTAPdta5kjX2G2J7iX4zxtnwpdA8Tr2ZSjkyPSanvbZAcy6flmtnXbEybHrDcU9641lxrMfFuUxVz9vA==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
@@ -5651,9 +5651,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/i18next": {
|
||||
"version": "25.8.13",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-25.8.13.tgz",
|
||||
"integrity": "sha512-E0vzjBY1yM+nsFrtgkjLhST2NBkirkvOVoQa0MSldhsuZ3jUge7ZNpuwG0Cfc74zwo5ZwRzg3uOgT+McBn32iA==",
|
||||
"version": "25.8.14",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-25.8.14.tgz",
|
||||
"integrity": "sha512-paMUYkfWJMsWPeE/Hejcw+XLhHrQPehem+4wMo+uELnvIwvCG019L9sAIljwjCmEMtFQQO3YeitJY8Kctei3iA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
@@ -6343,9 +6343,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-react": {
|
||||
"version": "0.575.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.575.0.tgz",
|
||||
"integrity": "sha512-VuXgKZrk0uiDlWjGGXmKV6MSk9Yy4l10qgVvzGn2AWBx1Ylt0iBexKOAoA6I7JO3m+M9oeovJd3yYENfkUbOeg==",
|
||||
"version": "0.577.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.577.0.tgz",
|
||||
"integrity": "sha512-4LjoFv2eEPwYDPg/CUdBJQSDfPyzXCRrVW1X7jrx/trgxnxkHFjnVZINbzvzxjN70dxychOfg+FTYwBiS3pQ5A==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
@@ -7418,9 +7418,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/node-releases": {
|
||||
"version": "2.0.27",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
|
||||
"integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==",
|
||||
"version": "2.0.36",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz",
|
||||
"integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -7623,9 +7623,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.6",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
|
||||
"version": "8.5.8",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
|
||||
"integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -8297,21 +8297,21 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tldts": {
|
||||
"version": "7.0.23",
|
||||
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.23.tgz",
|
||||
"integrity": "sha512-ASdhgQIBSay0R/eXggAkQ53G4nTJqTXqC2kbaBbdDwM7SkjyZyO0OaaN1/FH7U/yCeqOHDwFO5j8+Os/IS1dXw==",
|
||||
"version": "7.0.24",
|
||||
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.24.tgz",
|
||||
"integrity": "sha512-1r6vQTTt1rUiJkI5vX7KG8PR342Ru/5Oh13kEQP2SMbRSZpOey9SrBe27IDxkoWulx8ShWu4K6C0BkctP8Z1bQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tldts-core": "^7.0.23"
|
||||
"tldts-core": "^7.0.24"
|
||||
},
|
||||
"bin": {
|
||||
"tldts": "bin/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/tldts-core": {
|
||||
"version": "7.0.23",
|
||||
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.23.tgz",
|
||||
"integrity": "sha512-0g9vrtDQLrNIiCj22HSe9d4mLVG3g5ph5DZ8zCKBr4OtrspmNB6ss7hVyzArAeE88ceZocIEGkyW1Ime7fxPtQ==",
|
||||
"version": "7.0.24",
|
||||
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.24.tgz",
|
||||
"integrity": "sha512-pj7yygNMoMRqG7ML2SDQ0xNIOfN3IBDUcPVM2Sg6hP96oFNN2nqnzHreT3z9xLq85IWJyNTvD38O002DdOrPMw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/to-regex-range": {
|
||||
|
||||
@@ -38,9 +38,9 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"i18next": "^25.8.13",
|
||||
"i18next": "^25.8.14",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"lucide-react": "^0.575.0",
|
||||
"lucide-react": "^0.577.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-hook-form": "^7.71.2",
|
||||
@@ -48,7 +48,7 @@
|
||||
"react-i18next": "^16.5.4",
|
||||
"react-router-dom": "^7.13.1",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tldts": "^7.0.23"
|
||||
"tldts": "^7.0.24"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/css": "^0.14.1",
|
||||
@@ -75,7 +75,7 @@
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"jsdom": "28.1.0",
|
||||
"knip": "^5.85.0",
|
||||
"postcss": "^8.5.6",
|
||||
"postcss": "^8.5.8",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.56.1",
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ToastContainer } from './components/Toast'
|
||||
import { SetupGuard } from './components/SetupGuard'
|
||||
import { LoadingOverlay } from './components/LoadingStates'
|
||||
import RequireAuth from './components/RequireAuth'
|
||||
import RequireRole from './components/RequireRole'
|
||||
import { AuthProvider } from './context/AuthContext'
|
||||
|
||||
// Lazy load pages for code splitting
|
||||
@@ -23,7 +24,6 @@ const DNSProviders = lazy(() => import('./pages/DNSProviders'))
|
||||
const SystemSettings = lazy(() => import('./pages/SystemSettings'))
|
||||
const SMTPSettings = lazy(() => import('./pages/SMTPSettings'))
|
||||
const CrowdSecConfig = lazy(() => import('./pages/CrowdSecConfig'))
|
||||
const Account = lazy(() => import('./pages/Account'))
|
||||
const Settings = lazy(() => import('./pages/Settings'))
|
||||
const Backups = lazy(() => import('./pages/Backups'))
|
||||
const Tasks = lazy(() => import('./pages/Tasks'))
|
||||
@@ -43,6 +43,7 @@ const Plugins = lazy(() => import('./pages/Plugins'))
|
||||
const Login = lazy(() => import('./pages/Login'))
|
||||
const Setup = lazy(() => import('./pages/Setup'))
|
||||
const AcceptInvite = lazy(() => import('./pages/AcceptInvite'))
|
||||
const PassthroughLanding = lazy(() => import('./pages/PassthroughLanding'))
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
@@ -53,6 +54,11 @@ export default function App() {
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/setup" element={<Setup />} />
|
||||
<Route path="/accept-invite" element={<AcceptInvite />} />
|
||||
<Route path="/passthrough" element={
|
||||
<RequireAuth>
|
||||
<PassthroughLanding />
|
||||
</RequireAuth>
|
||||
} />
|
||||
<Route path="/" element={
|
||||
<SetupGuard>
|
||||
<RequireAuth>
|
||||
@@ -88,19 +94,23 @@ export default function App() {
|
||||
<Route path="security/encryption" element={<EncryptionManagement />} />
|
||||
<Route path="access-lists" element={<AccessLists />} />
|
||||
<Route path="uptime" element={<Uptime />} />
|
||||
<Route path="users" element={<UsersPage />} />
|
||||
|
||||
{/* Legacy redirects for old user management paths */}
|
||||
<Route path="users" element={<Navigate to="/settings/users" replace />} />
|
||||
<Route path="admin/plugins" element={<Navigate to="/dns/plugins" replace />} />
|
||||
<Route path="import" element={<Navigate to="/tasks/import/caddyfile" replace />} />
|
||||
|
||||
{/* Settings Routes */}
|
||||
<Route path="settings" element={<Settings />}>
|
||||
<Route path="settings" element={<RequireRole allowed={['admin', 'user']}><Settings /></RequireRole>}>
|
||||
<Route index element={<SystemSettings />} />
|
||||
<Route path="system" element={<SystemSettings />} />
|
||||
<Route path="notifications" element={<Notifications />} />
|
||||
<Route path="smtp" element={<SMTPSettings />} />
|
||||
<Route path="crowdsec" element={<Navigate to="/security/crowdsec" replace />} />
|
||||
<Route path="account" element={<Account />} />
|
||||
<Route path="account-management" element={<UsersPage />} />
|
||||
<Route path="users" element={<RequireRole allowed={['admin']}><UsersPage /></RequireRole>} />
|
||||
{/* Legacy redirects */}
|
||||
<Route path="account" element={<Navigate to="/settings/users" replace />} />
|
||||
<Route path="account-management" element={<Navigate to="/settings/users" replace />} />
|
||||
</Route>
|
||||
|
||||
{/* Tasks Routes */}
|
||||
|
||||
@@ -6,12 +6,14 @@ vi.mock('../client', () => ({
|
||||
default: {
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('import API', () => {
|
||||
const mockedGet = vi.mocked(client.get);
|
||||
const mockedPost = vi.mocked(client.post);
|
||||
const mockedDelete = vi.mocked(client.delete);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -71,11 +73,25 @@ describe('import API', () => {
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
|
||||
it('cancelImport posts cancel', async () => {
|
||||
mockedPost.mockResolvedValue({});
|
||||
it('cancelImport deletes cancel with required session_uuid query', async () => {
|
||||
const sessionUUID = 'uuid-cancel-123';
|
||||
mockedDelete.mockResolvedValue({});
|
||||
|
||||
await cancelImport();
|
||||
expect(client.post).toHaveBeenCalledWith('/import/cancel');
|
||||
await cancelImport(sessionUUID);
|
||||
|
||||
expect(client.delete).toHaveBeenCalledTimes(1);
|
||||
expect(client.delete).toHaveBeenCalledWith('/import/cancel', {
|
||||
params: {
|
||||
session_uuid: sessionUUID,
|
||||
},
|
||||
});
|
||||
|
||||
const [, requestConfig] = mockedDelete.mock.calls[0];
|
||||
expect(requestConfig).toEqual({
|
||||
params: {
|
||||
session_uuid: sessionUUID,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('forwards commitImport errors', async () => {
|
||||
@@ -87,9 +103,9 @@ describe('import API', () => {
|
||||
|
||||
it('forwards cancelImport errors', async () => {
|
||||
const error = new Error('cancel failed');
|
||||
mockedPost.mockRejectedValue(error);
|
||||
mockedDelete.mockRejectedValue(error);
|
||||
|
||||
await expect(cancelImport()).rejects.toBe(error);
|
||||
await expect(cancelImport('uuid-cancel-123')).rejects.toBe(error);
|
||||
});
|
||||
|
||||
it('getImportStatus gets status', async () => {
|
||||
|
||||
96
frontend/src/api/__tests__/jsonImport.test.ts
Normal file
96
frontend/src/api/__tests__/jsonImport.test.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { uploadJSONExport, commitJSONImport, cancelJSONImport } from '../jsonImport';
|
||||
import client from '../client';
|
||||
|
||||
vi.mock('../client', () => ({
|
||||
default: {
|
||||
post: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('jsonImport API', () => {
|
||||
const mockedPost = vi.mocked(client.post);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('cancelJSONImport posts cancel endpoint with required session_uuid body', async () => {
|
||||
const sessionUUID = 'json-session-123';
|
||||
mockedPost.mockResolvedValue({});
|
||||
|
||||
await cancelJSONImport(sessionUUID);
|
||||
|
||||
expect(client.post).toHaveBeenCalledWith('/import/json/cancel', {
|
||||
session_uuid: sessionUUID,
|
||||
});
|
||||
});
|
||||
|
||||
it('uploadJSONExport posts upload endpoint with content payload', async () => {
|
||||
const content = '{"proxy_hosts":[]}';
|
||||
const mockResponse = {
|
||||
session: {
|
||||
id: 'json-session-456',
|
||||
state: 'reviewing',
|
||||
source: 'json',
|
||||
},
|
||||
preview: {
|
||||
hosts: [],
|
||||
conflicts: [],
|
||||
errors: [],
|
||||
},
|
||||
conflict_details: {},
|
||||
};
|
||||
|
||||
mockedPost.mockResolvedValue({ data: mockResponse });
|
||||
|
||||
const result = await uploadJSONExport(content);
|
||||
|
||||
expect(client.post).toHaveBeenCalledWith('/import/json/upload', { content });
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
|
||||
it('commitJSONImport posts commit endpoint with session_uuid, resolutions, and names body', async () => {
|
||||
const sessionUUID = 'json-session-789';
|
||||
const resolutions = { 'json.example.com': 'replace' };
|
||||
const names = { 'json.example.com': 'JSON Example' };
|
||||
const mockResponse = {
|
||||
created: 1,
|
||||
updated: 1,
|
||||
skipped: 0,
|
||||
errors: [],
|
||||
};
|
||||
|
||||
mockedPost.mockResolvedValue({ data: mockResponse });
|
||||
|
||||
const result = await commitJSONImport(sessionUUID, resolutions, names);
|
||||
|
||||
expect(client.post).toHaveBeenCalledWith('/import/json/commit', {
|
||||
session_uuid: sessionUUID,
|
||||
resolutions,
|
||||
names,
|
||||
});
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
|
||||
it('forwards uploadJSONExport errors', async () => {
|
||||
const error = new Error('upload failed');
|
||||
mockedPost.mockRejectedValue(error);
|
||||
|
||||
await expect(uploadJSONExport('{"proxy_hosts":[]}')).rejects.toBe(error);
|
||||
});
|
||||
|
||||
it('forwards commitJSONImport errors', async () => {
|
||||
const error = new Error('commit failed');
|
||||
mockedPost.mockRejectedValue(error);
|
||||
|
||||
await expect(commitJSONImport('json-session-123', {}, {})).rejects.toBe(error);
|
||||
});
|
||||
|
||||
it('forwards cancelJSONImport errors', async () => {
|
||||
const error = new Error('cancel failed');
|
||||
mockedPost.mockRejectedValue(error);
|
||||
|
||||
await expect(cancelJSONImport('json-session-123')).rejects.toBe(error);
|
||||
});
|
||||
});
|
||||
96
frontend/src/api/__tests__/npmImport.test.ts
Normal file
96
frontend/src/api/__tests__/npmImport.test.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { uploadNPMExport, commitNPMImport, cancelNPMImport } from '../npmImport';
|
||||
import client from '../client';
|
||||
|
||||
vi.mock('../client', () => ({
|
||||
default: {
|
||||
post: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('npmImport API', () => {
|
||||
const mockedPost = vi.mocked(client.post);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('cancelNPMImport posts cancel endpoint with required session_uuid body', async () => {
|
||||
const sessionUUID = 'npm-session-123';
|
||||
mockedPost.mockResolvedValue({});
|
||||
|
||||
await cancelNPMImport(sessionUUID);
|
||||
|
||||
expect(client.post).toHaveBeenCalledWith('/import/npm/cancel', {
|
||||
session_uuid: sessionUUID,
|
||||
});
|
||||
});
|
||||
|
||||
it('uploadNPMExport posts upload endpoint with content payload', async () => {
|
||||
const content = '{"proxy_hosts":[]}';
|
||||
const mockResponse = {
|
||||
session: {
|
||||
id: 'npm-session-456',
|
||||
state: 'reviewing',
|
||||
source: 'npm',
|
||||
},
|
||||
preview: {
|
||||
hosts: [],
|
||||
conflicts: [],
|
||||
errors: [],
|
||||
},
|
||||
conflict_details: {},
|
||||
};
|
||||
|
||||
mockedPost.mockResolvedValue({ data: mockResponse });
|
||||
|
||||
const result = await uploadNPMExport(content);
|
||||
|
||||
expect(client.post).toHaveBeenCalledWith('/import/npm/upload', { content });
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
|
||||
it('commitNPMImport posts commit endpoint with session_uuid, resolutions, and names body', async () => {
|
||||
const sessionUUID = 'npm-session-789';
|
||||
const resolutions = { 'npm.example.com': 'replace' };
|
||||
const names = { 'npm.example.com': 'NPM Example' };
|
||||
const mockResponse = {
|
||||
created: 1,
|
||||
updated: 1,
|
||||
skipped: 0,
|
||||
errors: [],
|
||||
};
|
||||
|
||||
mockedPost.mockResolvedValue({ data: mockResponse });
|
||||
|
||||
const result = await commitNPMImport(sessionUUID, resolutions, names);
|
||||
|
||||
expect(client.post).toHaveBeenCalledWith('/import/npm/commit', {
|
||||
session_uuid: sessionUUID,
|
||||
resolutions,
|
||||
names,
|
||||
});
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
|
||||
it('forwards uploadNPMExport errors', async () => {
|
||||
const error = new Error('upload failed');
|
||||
mockedPost.mockRejectedValue(error);
|
||||
|
||||
await expect(uploadNPMExport('{"proxy_hosts":[]}')).rejects.toBe(error);
|
||||
});
|
||||
|
||||
it('forwards commitNPMImport errors', async () => {
|
||||
const error = new Error('commit failed');
|
||||
mockedPost.mockRejectedValue(error);
|
||||
|
||||
await expect(commitNPMImport('npm-session-123', {}, {})).rejects.toBe(error);
|
||||
});
|
||||
|
||||
it('forwards cancelNPMImport errors', async () => {
|
||||
const error = new Error('cancel failed');
|
||||
mockedPost.mockRejectedValue(error);
|
||||
|
||||
await expect(cancelNPMImport('npm-session-123')).rejects.toBe(error);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import client from '../client'
|
||||
import { getProfile, regenerateApiKey, updateProfile } from '../user'
|
||||
import { getProfile, regenerateApiKey, updateProfile } from '../users'
|
||||
|
||||
vi.mock('../client', () => ({
|
||||
default: {
|
||||
|
||||
@@ -110,10 +110,15 @@ export const commitImport = async (
|
||||
|
||||
/**
|
||||
* Cancels the current import session.
|
||||
* @param sessionUUID - The import session UUID
|
||||
* @throws {AxiosError} If cancellation fails
|
||||
*/
|
||||
export const cancelImport = async (): Promise<void> => {
|
||||
await client.post('/import/cancel');
|
||||
export const cancelImport = async (sessionUUID: string): Promise<void> => {
|
||||
await client.delete('/import/cancel', {
|
||||
params: {
|
||||
session_uuid: sessionUUID,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -83,8 +83,11 @@ export const commitJSONImport = async (
|
||||
|
||||
/**
|
||||
* Cancels the current JSON import session.
|
||||
* @param sessionUuid - The import session UUID
|
||||
* @throws {AxiosError} If cancellation fails
|
||||
*/
|
||||
export const cancelJSONImport = async (): Promise<void> => {
|
||||
await client.post('/import/json/cancel');
|
||||
export const cancelJSONImport = async (sessionUuid: string): Promise<void> => {
|
||||
await client.post('/import/json/cancel', {
|
||||
session_uuid: sessionUuid,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -83,8 +83,11 @@ export const commitNPMImport = async (
|
||||
|
||||
/**
|
||||
* Cancels the current NPM import session.
|
||||
* @param sessionUuid - The import session UUID
|
||||
* @throws {AxiosError} If cancellation fails
|
||||
*/
|
||||
export const cancelNPMImport = async (): Promise<void> => {
|
||||
await client.post('/import/npm/cancel');
|
||||
export const cancelNPMImport = async (sessionUuid: string): Promise<void> => {
|
||||
await client.post('/import/npm/cancel', {
|
||||
session_uuid: sessionUuid,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
import client from './client'
|
||||
|
||||
/** Current user profile information. */
|
||||
export interface UserProfile {
|
||||
id: number
|
||||
email: string
|
||||
name: string
|
||||
role: string
|
||||
has_api_key: boolean
|
||||
api_key_masked: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the current user's profile.
|
||||
* @returns Promise resolving to UserProfile
|
||||
* @throws {AxiosError} If the request fails or not authenticated
|
||||
*/
|
||||
export const getProfile = async (): Promise<UserProfile> => {
|
||||
const response = await client.get('/user/profile')
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Regenerates the current user's API key.
|
||||
* @returns Promise resolving to object containing the new API key
|
||||
* @throws {AxiosError} If regeneration fails
|
||||
*/
|
||||
export interface RegenerateApiKeyResponse {
|
||||
message: string
|
||||
has_api_key: boolean
|
||||
api_key_masked: string
|
||||
api_key_updated: string
|
||||
}
|
||||
|
||||
export const regenerateApiKey = async (): Promise<RegenerateApiKeyResponse> => {
|
||||
const response = await client.post<RegenerateApiKeyResponse>('/user/api-key')
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the current user's profile.
|
||||
* @param data - Object with name, email, and optional current_password for verification
|
||||
* @returns Promise resolving to success message
|
||||
* @throws {AxiosError} If update fails or password verification fails
|
||||
*/
|
||||
export const updateProfile = async (data: { name: string; email: string; current_password?: string }): Promise<{ message: string }> => {
|
||||
const response = await client.post('/user/profile', data)
|
||||
return response.data
|
||||
}
|
||||
@@ -9,7 +9,7 @@ export interface User {
|
||||
uuid: string
|
||||
email: string
|
||||
name: string
|
||||
role: 'admin' | 'user' | 'viewer'
|
||||
role: 'admin' | 'user' | 'passthrough'
|
||||
enabled: boolean
|
||||
last_login?: string
|
||||
invite_status?: 'pending' | 'accepted' | 'expired'
|
||||
@@ -212,3 +212,51 @@ export const resendInvite = async (id: number): Promise<InviteUserResponse> => {
|
||||
const response = await client.post<InviteUserResponse>(`/users/${id}/resend-invite`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
// --- Self-service profile endpoints (merged from api/user.ts) ---
|
||||
|
||||
/** Current user profile information. */
|
||||
export interface UserProfile {
|
||||
id: number
|
||||
email: string
|
||||
name: string
|
||||
role: 'admin' | 'user' | 'passthrough'
|
||||
has_api_key: boolean
|
||||
api_key_masked: string
|
||||
}
|
||||
|
||||
/** Response from API key regeneration. */
|
||||
export interface RegenerateApiKeyResponse {
|
||||
message: string
|
||||
has_api_key: boolean
|
||||
api_key_masked: string
|
||||
api_key_updated: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the current user's profile.
|
||||
* @returns Promise resolving to UserProfile
|
||||
*/
|
||||
export const getProfile = async (): Promise<UserProfile> => {
|
||||
const response = await client.get<UserProfile>('/user/profile')
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the current user's profile.
|
||||
* @param data - Object with name, email, and optional current_password for verification
|
||||
* @returns Promise resolving to success message
|
||||
*/
|
||||
export const updateProfile = async (data: { name: string; email: string; current_password?: string }): Promise<{ message: string }> => {
|
||||
const response = await client.post<{ message: string }>('/user/profile', data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Regenerates the current user's API key.
|
||||
* @returns Promise resolving to object containing the new API key
|
||||
*/
|
||||
export const regenerateApiKey = async (): Promise<RegenerateApiKeyResponse> => {
|
||||
const response = await client.post<RegenerateApiKeyResponse>('/user/api-key')
|
||||
return response.data
|
||||
}
|
||||
|
||||
@@ -85,8 +85,7 @@ export default function Layout({ children }: LayoutProps) {
|
||||
{ name: t('navigation.system'), path: '/settings/system', icon: '⚙️' },
|
||||
{ name: t('navigation.notifications'), path: '/settings/notifications', icon: '🔔' },
|
||||
{ name: t('navigation.email'), path: '/settings/smtp', icon: '📧' },
|
||||
{ name: t('navigation.adminAccount'), path: '/settings/account', icon: '🛡️' },
|
||||
{ name: t('navigation.accountManagement'), path: '/settings/account-management', icon: '👥' },
|
||||
...(user?.role === 'admin' ? [{ name: t('navigation.users'), path: '/settings/users', icon: '👥' }] : []),
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -109,6 +108,8 @@ export default function Layout({ children }: LayoutProps) {
|
||||
]
|
||||
},
|
||||
].filter(item => {
|
||||
// Passthrough users see no navigation — they're redirected to /passthrough
|
||||
if (user?.role === 'passthrough') return false
|
||||
// Optional Features Logic
|
||||
// Default to visible (true) if flags are loading or undefined
|
||||
if (item.name === t('navigation.uptime')) return featureFlags?.['feature.uptime.enabled'] !== false
|
||||
@@ -362,7 +363,7 @@ export default function Layout({ children }: LayoutProps) {
|
||||
</div>
|
||||
<div className="w-1/3 flex justify-end items-center gap-4">
|
||||
{user && (
|
||||
<Link to="/settings/account" className="text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 transition-colors">
|
||||
<Link to="/settings/users" className="text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 transition-colors">
|
||||
{user.name}
|
||||
</Link>
|
||||
)}
|
||||
|
||||
25
frontend/src/components/RequireRole.tsx
Normal file
25
frontend/src/components/RequireRole.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import React from 'react'
|
||||
import { Navigate } from 'react-router-dom'
|
||||
import { useAuth } from '../hooks/useAuth'
|
||||
|
||||
interface RequireRoleProps {
|
||||
allowed: Array<'admin' | 'user' | 'passthrough'>
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
const RequireRole: React.FC<RequireRoleProps> = ({ allowed, children }) => {
|
||||
const { user } = useAuth()
|
||||
|
||||
if (!user) {
|
||||
return <Navigate to="/login" replace />
|
||||
}
|
||||
|
||||
if (!allowed.includes(user.role)) {
|
||||
const redirectTarget = user.role === 'passthrough' ? '/passthrough' : '/'
|
||||
return <Navigate to={redirectTarget} replace />
|
||||
}
|
||||
|
||||
return children
|
||||
}
|
||||
|
||||
export default RequireRole
|
||||
@@ -2,7 +2,7 @@ import { createContext } from 'react';
|
||||
|
||||
export interface User {
|
||||
user_id: number;
|
||||
role: string;
|
||||
role: 'admin' | 'user' | 'passthrough';
|
||||
name?: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ describe('useAuth hook', () => {
|
||||
})
|
||||
|
||||
it('returns context inside provider', () => {
|
||||
const fakeCtx = { user: { user_id: 1, role: 'admin', name: 'Test', email: 't@example.com' }, login: async () => {}, logout: () => {}, changePassword: async () => {}, isAuthenticated: true, isLoading: false }
|
||||
const fakeCtx = { user: { user_id: 1, role: 'admin' as const, name: 'Test', email: 't@example.com' }, login: async () => {}, logout: () => {}, changePassword: async () => {}, isAuthenticated: true, isLoading: false }
|
||||
render(
|
||||
<AuthContext.Provider value={fakeCtx}>
|
||||
<TestComponent />
|
||||
|
||||
@@ -208,7 +208,7 @@ describe('useImport', () => {
|
||||
await result.current.cancel()
|
||||
})
|
||||
|
||||
expect(api.cancelImport).toHaveBeenCalled()
|
||||
expect(api.cancelImport).toHaveBeenCalledWith('session-3')
|
||||
await waitFor(() => {
|
||||
expect(result.current.session).toBeNull()
|
||||
})
|
||||
|
||||
190
frontend/src/hooks/__tests__/useJSONImport.test.tsx
Normal file
190
frontend/src/hooks/__tests__/useJSONImport.test.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { renderHook, act, waitFor } from '@testing-library/react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import React from 'react'
|
||||
import { useJSONImport } from '../useJSONImport'
|
||||
import * as api from '../../api/jsonImport'
|
||||
|
||||
vi.mock('../../api/jsonImport', () => ({
|
||||
uploadJSONExport: vi.fn(),
|
||||
commitJSONImport: vi.fn(),
|
||||
cancelJSONImport: vi.fn(),
|
||||
}))
|
||||
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
})
|
||||
|
||||
return ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('useJSONImport', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('sets preview and sessionId after successful upload', async () => {
|
||||
const uploadResponse = {
|
||||
session: {
|
||||
id: 'json-session-upload',
|
||||
state: 'reviewing',
|
||||
source: 'json',
|
||||
},
|
||||
preview: {
|
||||
hosts: [],
|
||||
conflicts: [],
|
||||
errors: [],
|
||||
},
|
||||
conflict_details: {},
|
||||
}
|
||||
|
||||
vi.mocked(api.uploadJSONExport).mockResolvedValue(uploadResponse)
|
||||
|
||||
const { result } = renderHook(() => useJSONImport(), { wrapper: createWrapper() })
|
||||
|
||||
await act(async () => {
|
||||
await result.current.upload('{"proxy_hosts":[]}')
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.sessionId).toBe('json-session-upload')
|
||||
expect(result.current.preview).toEqual(uploadResponse)
|
||||
})
|
||||
})
|
||||
|
||||
it('commits active session and clears preview/session state', async () => {
|
||||
const uploadResponse = {
|
||||
session: {
|
||||
id: 'json-session-commit',
|
||||
state: 'reviewing',
|
||||
source: 'json',
|
||||
},
|
||||
preview: {
|
||||
hosts: [],
|
||||
conflicts: [],
|
||||
errors: [],
|
||||
},
|
||||
conflict_details: {},
|
||||
}
|
||||
const commitResponse = {
|
||||
created: 1,
|
||||
updated: 0,
|
||||
skipped: 0,
|
||||
errors: [],
|
||||
}
|
||||
|
||||
vi.mocked(api.uploadJSONExport).mockResolvedValue(uploadResponse)
|
||||
vi.mocked(api.commitJSONImport).mockResolvedValue(commitResponse)
|
||||
|
||||
const { result } = renderHook(() => useJSONImport(), { wrapper: createWrapper() })
|
||||
|
||||
await act(async () => {
|
||||
await result.current.upload('{"proxy_hosts":[]}')
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.sessionId).toBe('json-session-commit')
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.commit({ 'json.example.com': 'replace' }, { 'json.example.com': 'JSON Example' })
|
||||
})
|
||||
|
||||
expect(api.commitJSONImport).toHaveBeenCalledWith(
|
||||
'json-session-commit',
|
||||
{ 'json.example.com': 'replace' },
|
||||
{ 'json.example.com': 'JSON Example' }
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.sessionId).toBeNull()
|
||||
expect(result.current.preview).toBeNull()
|
||||
expect(result.current.commitResult).toEqual(commitResponse)
|
||||
})
|
||||
})
|
||||
|
||||
it('passes active session UUID to cancelJSONImport', async () => {
|
||||
const sessionId = 'json-session-123'
|
||||
vi.mocked(api.uploadJSONExport).mockResolvedValue({
|
||||
session: {
|
||||
id: sessionId,
|
||||
state: 'reviewing',
|
||||
source: 'json',
|
||||
},
|
||||
preview: {
|
||||
hosts: [],
|
||||
conflicts: [],
|
||||
errors: [],
|
||||
},
|
||||
conflict_details: {},
|
||||
})
|
||||
vi.mocked(api.cancelJSONImport).mockResolvedValue(undefined)
|
||||
|
||||
const { result } = renderHook(() => useJSONImport(), { wrapper: createWrapper() })
|
||||
|
||||
await act(async () => {
|
||||
await result.current.upload('{}')
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.sessionId).toBe(sessionId)
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.cancel()
|
||||
})
|
||||
|
||||
expect(api.cancelJSONImport).toHaveBeenCalledWith(sessionId)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.sessionId).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
it('returns No active session and skips cancel API call when session is missing', async () => {
|
||||
const { result } = renderHook(() => useJSONImport(), { wrapper: createWrapper() })
|
||||
|
||||
await expect(result.current.cancel()).rejects.toThrow('No active session')
|
||||
expect(api.cancelJSONImport).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('exposes commit error and preserves session on commit failure', async () => {
|
||||
const uploadResponse = {
|
||||
session: {
|
||||
id: 'json-session-error',
|
||||
state: 'reviewing',
|
||||
source: 'json',
|
||||
},
|
||||
preview: {
|
||||
hosts: [],
|
||||
conflicts: [],
|
||||
errors: [],
|
||||
},
|
||||
conflict_details: {},
|
||||
}
|
||||
const commitError = new Error('404 Not Found')
|
||||
|
||||
vi.mocked(api.uploadJSONExport).mockResolvedValue(uploadResponse)
|
||||
vi.mocked(api.commitJSONImport).mockRejectedValue(commitError)
|
||||
|
||||
const { result } = renderHook(() => useJSONImport(), { wrapper: createWrapper() })
|
||||
|
||||
await act(async () => {
|
||||
await result.current.upload('{"proxy_hosts":[]}')
|
||||
})
|
||||
|
||||
await expect(result.current.commit({}, {})).rejects.toBe(commitError)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.commitError).toBe(commitError)
|
||||
expect(result.current.sessionId).toBe('json-session-error')
|
||||
expect(result.current.preview).not.toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
190
frontend/src/hooks/__tests__/useNPMImport.test.tsx
Normal file
190
frontend/src/hooks/__tests__/useNPMImport.test.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { renderHook, act, waitFor } from '@testing-library/react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import React from 'react'
|
||||
import { useNPMImport } from '../useNPMImport'
|
||||
import * as api from '../../api/npmImport'
|
||||
|
||||
vi.mock('../../api/npmImport', () => ({
|
||||
uploadNPMExport: vi.fn(),
|
||||
commitNPMImport: vi.fn(),
|
||||
cancelNPMImport: vi.fn(),
|
||||
}))
|
||||
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
})
|
||||
|
||||
return ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('useNPMImport', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('sets preview and sessionId after successful upload', async () => {
|
||||
const uploadResponse = {
|
||||
session: {
|
||||
id: 'npm-session-upload',
|
||||
state: 'reviewing',
|
||||
source: 'npm',
|
||||
},
|
||||
preview: {
|
||||
hosts: [],
|
||||
conflicts: [],
|
||||
errors: [],
|
||||
},
|
||||
conflict_details: {},
|
||||
}
|
||||
|
||||
vi.mocked(api.uploadNPMExport).mockResolvedValue(uploadResponse)
|
||||
|
||||
const { result } = renderHook(() => useNPMImport(), { wrapper: createWrapper() })
|
||||
|
||||
await act(async () => {
|
||||
await result.current.upload('{"proxy_hosts":[]}')
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.sessionId).toBe('npm-session-upload')
|
||||
expect(result.current.preview).toEqual(uploadResponse)
|
||||
})
|
||||
})
|
||||
|
||||
it('commits active session and clears preview/session state', async () => {
|
||||
const uploadResponse = {
|
||||
session: {
|
||||
id: 'npm-session-commit',
|
||||
state: 'reviewing',
|
||||
source: 'npm',
|
||||
},
|
||||
preview: {
|
||||
hosts: [],
|
||||
conflicts: [],
|
||||
errors: [],
|
||||
},
|
||||
conflict_details: {},
|
||||
}
|
||||
const commitResponse = {
|
||||
created: 1,
|
||||
updated: 0,
|
||||
skipped: 0,
|
||||
errors: [],
|
||||
}
|
||||
|
||||
vi.mocked(api.uploadNPMExport).mockResolvedValue(uploadResponse)
|
||||
vi.mocked(api.commitNPMImport).mockResolvedValue(commitResponse)
|
||||
|
||||
const { result } = renderHook(() => useNPMImport(), { wrapper: createWrapper() })
|
||||
|
||||
await act(async () => {
|
||||
await result.current.upload('{"proxy_hosts":[]}')
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.sessionId).toBe('npm-session-commit')
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.commit({ 'npm.example.com': 'replace' }, { 'npm.example.com': 'NPM Example' })
|
||||
})
|
||||
|
||||
expect(api.commitNPMImport).toHaveBeenCalledWith(
|
||||
'npm-session-commit',
|
||||
{ 'npm.example.com': 'replace' },
|
||||
{ 'npm.example.com': 'NPM Example' }
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.sessionId).toBeNull()
|
||||
expect(result.current.preview).toBeNull()
|
||||
expect(result.current.commitResult).toEqual(commitResponse)
|
||||
})
|
||||
})
|
||||
|
||||
it('passes active session UUID to cancelNPMImport', async () => {
|
||||
const sessionId = 'npm-session-123'
|
||||
vi.mocked(api.uploadNPMExport).mockResolvedValue({
|
||||
session: {
|
||||
id: sessionId,
|
||||
state: 'reviewing',
|
||||
source: 'npm',
|
||||
},
|
||||
preview: {
|
||||
hosts: [],
|
||||
conflicts: [],
|
||||
errors: [],
|
||||
},
|
||||
conflict_details: {},
|
||||
})
|
||||
vi.mocked(api.cancelNPMImport).mockResolvedValue(undefined)
|
||||
|
||||
const { result } = renderHook(() => useNPMImport(), { wrapper: createWrapper() })
|
||||
|
||||
await act(async () => {
|
||||
await result.current.upload('{}')
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.sessionId).toBe(sessionId)
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.cancel()
|
||||
})
|
||||
|
||||
expect(api.cancelNPMImport).toHaveBeenCalledWith(sessionId)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.sessionId).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
it('returns No active session and skips cancel API call when session is missing', async () => {
|
||||
const { result } = renderHook(() => useNPMImport(), { wrapper: createWrapper() })
|
||||
|
||||
await expect(result.current.cancel()).rejects.toThrow('No active session')
|
||||
expect(api.cancelNPMImport).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('exposes commit error and preserves session on commit failure', async () => {
|
||||
const uploadResponse = {
|
||||
session: {
|
||||
id: 'npm-session-error',
|
||||
state: 'reviewing',
|
||||
source: 'npm',
|
||||
},
|
||||
preview: {
|
||||
hosts: [],
|
||||
conflicts: [],
|
||||
errors: [],
|
||||
},
|
||||
conflict_details: {},
|
||||
}
|
||||
const commitError = new Error('404 Not Found')
|
||||
|
||||
vi.mocked(api.uploadNPMExport).mockResolvedValue(uploadResponse)
|
||||
vi.mocked(api.commitNPMImport).mockRejectedValue(commitError)
|
||||
|
||||
const { result } = renderHook(() => useNPMImport(), { wrapper: createWrapper() })
|
||||
|
||||
await act(async () => {
|
||||
await result.current.upload('{"proxy_hosts":[]}')
|
||||
})
|
||||
|
||||
await expect(result.current.commit({}, {})).rejects.toBe(commitError)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.commitError).toBe(commitError)
|
||||
expect(result.current.sessionId).toBe('npm-session-error')
|
||||
expect(result.current.preview).not.toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
47
frontend/src/hooks/useFocusTrap.ts
Normal file
47
frontend/src/hooks/useFocusTrap.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { useEffect, type RefObject } from 'react'
|
||||
|
||||
const FOCUSABLE_SELECTOR =
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
||||
|
||||
export function useFocusTrap(
|
||||
dialogRef: RefObject<HTMLElement | null>,
|
||||
isOpen: boolean,
|
||||
onEscape?: () => void,
|
||||
) {
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && onEscape) {
|
||||
onEscape()
|
||||
return
|
||||
}
|
||||
|
||||
if (e.key === 'Tab' && dialogRef.current) {
|
||||
const focusable =
|
||||
dialogRef.current.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)
|
||||
if (focusable.length === 0) return
|
||||
|
||||
const first = focusable[0]
|
||||
const last = focusable[focusable.length - 1]
|
||||
|
||||
if (e.shiftKey && document.activeElement === first) {
|
||||
e.preventDefault()
|
||||
last.focus()
|
||||
} else if (!e.shiftKey && document.activeElement === last) {
|
||||
e.preventDefault()
|
||||
first.focus()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const first = dialogRef.current?.querySelector<HTMLElement>(FOCUSABLE_SELECTOR)
|
||||
first?.focus()
|
||||
})
|
||||
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [isOpen, onEscape, dialogRef])
|
||||
}
|
||||
@@ -77,7 +77,11 @@ export function useImport() {
|
||||
});
|
||||
|
||||
const cancelMutation = useMutation({
|
||||
mutationFn: () => cancelImport(),
|
||||
mutationFn: () => {
|
||||
const sessionId = uploadPreview?.session?.id || statusQuery.data?.session?.id;
|
||||
if (!sessionId) throw new Error('No active session');
|
||||
return cancelImport(sessionId);
|
||||
},
|
||||
onSuccess: () => {
|
||||
// Clear upload preview and remove query cache
|
||||
setUploadPreview(null);
|
||||
|
||||
@@ -46,7 +46,10 @@ export function useJSONImport() {
|
||||
});
|
||||
|
||||
const cancelMutation = useMutation({
|
||||
mutationFn: cancelJSONImport,
|
||||
mutationFn: () => {
|
||||
if (!sessionId) throw new Error('No active session');
|
||||
return cancelJSONImport(sessionId);
|
||||
},
|
||||
onSuccess: () => {
|
||||
setPreview(null);
|
||||
setSessionId(null);
|
||||
|
||||
@@ -46,7 +46,10 @@ export function useNPMImport() {
|
||||
});
|
||||
|
||||
const cancelMutation = useMutation({
|
||||
mutationFn: cancelNPMImport,
|
||||
mutationFn: () => {
|
||||
if (!sessionId) throw new Error('No active session');
|
||||
return cancelNPMImport(sessionId);
|
||||
},
|
||||
onSuccess: () => {
|
||||
setPreview(null);
|
||||
setSessionId(null);
|
||||
|
||||
@@ -66,8 +66,6 @@
|
||||
"settings": "Einstellungen",
|
||||
"system": "System",
|
||||
"email": "E-Mail (SMTP)",
|
||||
"adminAccount": "Admin-Konto",
|
||||
"accountManagement": "Kontoverwaltung",
|
||||
"import": "Importieren",
|
||||
"caddyfile": "Caddyfile",
|
||||
"backups": "Sicherungen",
|
||||
@@ -538,6 +536,10 @@
|
||||
"role": "Rolle",
|
||||
"roleUser": "Benutzer",
|
||||
"roleAdmin": "Administrator",
|
||||
"rolePassthrough": "Passthrough",
|
||||
"roleUserDescription": "Kann nur auf erlaubte Proxy-Hosts zugreifen.",
|
||||
"roleAdminDescription": "Vollzugriff auf alle Funktionen und Einstellungen.",
|
||||
"rolePassthroughDescription": "Nur Proxy-Zugriff — keine Verwaltungsoberfläche.",
|
||||
"permissionMode": "Berechtigungsmodus",
|
||||
"allowAllBlacklist": "Alles erlauben (Blacklist)",
|
||||
"denyAllWhitelist": "Alles verweigern (Whitelist)",
|
||||
@@ -571,7 +573,23 @@
|
||||
"resendInvite": "Einladung erneut senden",
|
||||
"inviteResent": "Einladung erfolgreich erneut gesendet",
|
||||
"inviteCreatedNoEmail": "Neue Einladung erstellt. E-Mail konnte nicht gesendet werden.",
|
||||
"resendFailed": "Einladung konnte nicht erneut gesendet werden"
|
||||
"resendFailed": "Einladung konnte nicht erneut gesendet werden",
|
||||
"myProfile": "Mein Profil",
|
||||
"editUser": "Benutzer bearbeiten",
|
||||
"changePassword": "Passwort ändern",
|
||||
"currentPassword": "Aktuelles Passwort",
|
||||
"newPassword": "Neues Passwort",
|
||||
"confirmPassword": "Passwort bestätigen",
|
||||
"passwordChanged": "Passwort erfolgreich geändert",
|
||||
"passwordChangeFailed": "Passwort konnte nicht geändert werden",
|
||||
"passwordMismatch": "Passwörter stimmen nicht überein",
|
||||
"apiKey": "API-Schlüssel",
|
||||
"regenerateApiKey": "API-Schlüssel neu generieren",
|
||||
"apiKeyRegenerated": "API-Schlüssel neu generiert",
|
||||
"apiKeyRegenerateFailed": "API-Schlüssel konnte nicht neu generiert werden",
|
||||
"apiKeyConfirm": "Sind Sie sicher? Der aktuelle API-Schlüssel wird ungültig.",
|
||||
"profileUpdated": "Profil erfolgreich aktualisiert",
|
||||
"profileUpdateFailed": "Profil konnte nicht aktualisiert werden"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
@@ -1018,5 +1036,10 @@
|
||||
"dns": {
|
||||
"title": "DNS-Verwaltung",
|
||||
"description": "DNS-Anbieter und Plugins für die Zertifikatsautomatisierung verwalten"
|
||||
},
|
||||
"passthrough": {
|
||||
"title": "Willkommen",
|
||||
"description": "Ihr Konto hat Passthrough-Zugriff. Sie können Ihre zugewiesenen Dienste direkt erreichen — keine Verwaltungsoberfläche verfügbar.",
|
||||
"noAccessToManagement": "Sie haben keinen Zugriff auf die Verwaltungsoberfläche."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,8 +70,6 @@
|
||||
"settings": "Settings",
|
||||
"system": "System",
|
||||
"email": "Email (SMTP)",
|
||||
"adminAccount": "Admin Account",
|
||||
"accountManagement": "Account Management",
|
||||
"import": "Import",
|
||||
"caddyfile": "Caddyfile",
|
||||
"importNPM": "Import NPM",
|
||||
@@ -618,6 +616,10 @@
|
||||
"role": "Role",
|
||||
"roleUser": "User",
|
||||
"roleAdmin": "Admin",
|
||||
"rolePassthrough": "Passthrough",
|
||||
"roleUserDescription": "Can access permitted proxy hosts only.",
|
||||
"roleAdminDescription": "Full access to all features and settings.",
|
||||
"rolePassthroughDescription": "Proxy access only — no management interface.",
|
||||
"permissionMode": "Permission Mode",
|
||||
"allowAllBlacklist": "Allow All (Blacklist)",
|
||||
"denyAllWhitelist": "Deny All (Whitelist)",
|
||||
@@ -651,7 +653,23 @@
|
||||
"resendInvite": "Resend Invite",
|
||||
"inviteResent": "Invitation resent successfully",
|
||||
"inviteCreatedNoEmail": "New invite created. Email could not be sent.",
|
||||
"resendFailed": "Failed to resend invitation"
|
||||
"resendFailed": "Failed to resend invitation",
|
||||
"myProfile": "My Profile",
|
||||
"editUser": "Edit User",
|
||||
"changePassword": "Change Password",
|
||||
"currentPassword": "Current Password",
|
||||
"newPassword": "New Password",
|
||||
"confirmPassword": "Confirm Password",
|
||||
"passwordChanged": "Password changed successfully",
|
||||
"passwordChangeFailed": "Failed to change password",
|
||||
"passwordMismatch": "Passwords do not match",
|
||||
"apiKey": "API Key",
|
||||
"regenerateApiKey": "Regenerate API Key",
|
||||
"apiKeyRegenerated": "API key regenerated",
|
||||
"apiKeyRegenerateFailed": "Failed to regenerate API key",
|
||||
"apiKeyConfirm": "Are you sure? The current API key will be invalidated.",
|
||||
"profileUpdated": "Profile updated successfully",
|
||||
"profileUpdateFailed": "Failed to update profile"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
@@ -1360,5 +1378,10 @@
|
||||
"validationError": "Key configuration validation failed. Check errors below.",
|
||||
"validationFailed": "Validation request failed: {{error}}",
|
||||
"failedToLoadStatus": "Failed to load encryption status. Please refresh the page."
|
||||
},
|
||||
"passthrough": {
|
||||
"title": "Welcome",
|
||||
"description": "Your account has passthrough access. You can reach your assigned services directly — no management interface is available.",
|
||||
"noAccessToManagement": "You do not have access to the management interface."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,8 +66,6 @@
|
||||
"settings": "Configuración",
|
||||
"system": "Sistema",
|
||||
"email": "Correo Electrónico (SMTP)",
|
||||
"adminAccount": "Cuenta de Administrador",
|
||||
"accountManagement": "Gestión de Cuentas",
|
||||
"import": "Importar",
|
||||
"caddyfile": "Caddyfile",
|
||||
"backups": "Copias de Seguridad",
|
||||
@@ -538,6 +536,10 @@
|
||||
"role": "Rol",
|
||||
"roleUser": "Usuario",
|
||||
"roleAdmin": "Administrador",
|
||||
"rolePassthrough": "Passthrough",
|
||||
"roleUserDescription": "Solo puede acceder a los hosts proxy permitidos.",
|
||||
"roleAdminDescription": "Acceso completo a todas las funciones y configuraciones.",
|
||||
"rolePassthroughDescription": "Solo acceso proxy — sin interfaz de gestión.",
|
||||
"permissionMode": "Modo de Permisos",
|
||||
"allowAllBlacklist": "Permitir Todo (Lista Negra)",
|
||||
"denyAllWhitelist": "Denegar Todo (Lista Blanca)",
|
||||
@@ -571,7 +573,23 @@
|
||||
"resendInvite": "Reenviar invitación",
|
||||
"inviteResent": "Invitación reenviada exitosamente",
|
||||
"inviteCreatedNoEmail": "Nueva invitación creada. No se pudo enviar el correo electrónico.",
|
||||
"resendFailed": "Error al reenviar la invitación"
|
||||
"resendFailed": "Error al reenviar la invitación",
|
||||
"myProfile": "Mi Perfil",
|
||||
"editUser": "Editar Usuario",
|
||||
"changePassword": "Cambiar Contraseña",
|
||||
"currentPassword": "Contraseña Actual",
|
||||
"newPassword": "Nueva Contraseña",
|
||||
"confirmPassword": "Confirmar Contraseña",
|
||||
"passwordChanged": "Contraseña cambiada exitosamente",
|
||||
"passwordChangeFailed": "Error al cambiar la contraseña",
|
||||
"passwordMismatch": "Las contraseñas no coinciden",
|
||||
"apiKey": "Clave API",
|
||||
"regenerateApiKey": "Regenerar Clave API",
|
||||
"apiKeyRegenerated": "Clave API regenerada",
|
||||
"apiKeyRegenerateFailed": "Error al regenerar la clave API",
|
||||
"apiKeyConfirm": "¿Está seguro? La clave API actual será invalidada.",
|
||||
"profileUpdated": "Perfil actualizado exitosamente",
|
||||
"profileUpdateFailed": "Error al actualizar el perfil"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Panel de Control",
|
||||
@@ -1018,5 +1036,10 @@
|
||||
"dns": {
|
||||
"title": "Gestión DNS",
|
||||
"description": "Administrar proveedores DNS y plugins para la automatización de certificados"
|
||||
},
|
||||
"passthrough": {
|
||||
"title": "Bienvenido",
|
||||
"description": "Su cuenta tiene acceso passthrough. Puede acceder a sus servicios asignados directamente — no hay interfaz de gestión disponible.",
|
||||
"noAccessToManagement": "No tiene acceso a la interfaz de gestión."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,8 +66,6 @@
|
||||
"settings": "Paramètres",
|
||||
"system": "Système",
|
||||
"email": "Email (SMTP)",
|
||||
"adminAccount": "Compte Administrateur",
|
||||
"accountManagement": "Gestion des Comptes",
|
||||
"import": "Importer",
|
||||
"caddyfile": "Caddyfile",
|
||||
"backups": "Sauvegardes",
|
||||
@@ -538,6 +536,10 @@
|
||||
"role": "Rôle",
|
||||
"roleUser": "Utilisateur",
|
||||
"roleAdmin": "Administrateur",
|
||||
"rolePassthrough": "Passthrough",
|
||||
"roleUserDescription": "Peut accéder uniquement aux hôtes proxy autorisés.",
|
||||
"roleAdminDescription": "Accès complet à toutes les fonctionnalités et paramètres.",
|
||||
"rolePassthroughDescription": "Accès proxy uniquement — aucune interface de gestion.",
|
||||
"permissionMode": "Mode de Permission",
|
||||
"allowAllBlacklist": "Tout Autoriser (Liste Noire)",
|
||||
"denyAllWhitelist": "Tout Refuser (Liste Blanche)",
|
||||
@@ -571,7 +573,23 @@
|
||||
"resendInvite": "Renvoyer l'invitation",
|
||||
"inviteResent": "Invitation renvoyée avec succès",
|
||||
"inviteCreatedNoEmail": "Nouvelle invitation créée. L'e-mail n'a pas pu être envoyé.",
|
||||
"resendFailed": "Échec du renvoi de l'invitation"
|
||||
"resendFailed": "Échec du renvoi de l'invitation",
|
||||
"myProfile": "Mon Profil",
|
||||
"editUser": "Modifier l'utilisateur",
|
||||
"changePassword": "Changer le mot de passe",
|
||||
"currentPassword": "Mot de passe actuel",
|
||||
"newPassword": "Nouveau mot de passe",
|
||||
"confirmPassword": "Confirmer le mot de passe",
|
||||
"passwordChanged": "Mot de passe changé avec succès",
|
||||
"passwordChangeFailed": "Échec du changement de mot de passe",
|
||||
"passwordMismatch": "Les mots de passe ne correspondent pas",
|
||||
"apiKey": "Clé API",
|
||||
"regenerateApiKey": "Régénérer la clé API",
|
||||
"apiKeyRegenerated": "Clé API régénérée",
|
||||
"apiKeyRegenerateFailed": "Échec de la régénération de la clé API",
|
||||
"apiKeyConfirm": "Êtes-vous sûr ? La clé API actuelle sera invalidée.",
|
||||
"profileUpdated": "Profil mis à jour avec succès",
|
||||
"profileUpdateFailed": "Échec de la mise à jour du profil"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Tableau de bord",
|
||||
@@ -1018,5 +1036,10 @@
|
||||
"dns": {
|
||||
"title": "Gestion DNS",
|
||||
"description": "Gérer les fournisseurs DNS et les plugins pour l'automatisation des certificats"
|
||||
},
|
||||
"passthrough": {
|
||||
"title": "Bienvenue",
|
||||
"description": "Votre compte a un accès passthrough. Vous pouvez accéder directement à vos services assignés — aucune interface de gestion n'est disponible.",
|
||||
"noAccessToManagement": "Vous n'avez pas accès à l'interface de gestion."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,8 +66,6 @@
|
||||
"settings": "设置",
|
||||
"system": "系统",
|
||||
"email": "电子邮件 (SMTP)",
|
||||
"adminAccount": "管理员账户",
|
||||
"accountManagement": "账户管理",
|
||||
"import": "导入",
|
||||
"caddyfile": "Caddyfile",
|
||||
"backups": "备份",
|
||||
@@ -538,6 +536,10 @@
|
||||
"role": "角色",
|
||||
"roleUser": "用户",
|
||||
"roleAdmin": "管理员",
|
||||
"rolePassthrough": "Passthrough",
|
||||
"roleUserDescription": "只能访问允许的代理主机。",
|
||||
"roleAdminDescription": "完全访问所有功能和设置。",
|
||||
"rolePassthroughDescription": "仅代理访问 — 无管理界面。",
|
||||
"permissionMode": "权限模式",
|
||||
"allowAllBlacklist": "允许所有(黑名单)",
|
||||
"denyAllWhitelist": "拒绝所有(白名单)",
|
||||
@@ -571,7 +573,23 @@
|
||||
"resendInvite": "重新发送邀请",
|
||||
"inviteResent": "邀请重新发送成功",
|
||||
"inviteCreatedNoEmail": "新邀请已创建。无法发送电子邮件。",
|
||||
"resendFailed": "重新发送邀请失败"
|
||||
"resendFailed": "重新发送邀请失败",
|
||||
"myProfile": "我的资料",
|
||||
"editUser": "编辑用户",
|
||||
"changePassword": "修改密码",
|
||||
"currentPassword": "当前密码",
|
||||
"newPassword": "新密码",
|
||||
"confirmPassword": "确认密码",
|
||||
"passwordChanged": "密码修改成功",
|
||||
"passwordChangeFailed": "密码修改失败",
|
||||
"passwordMismatch": "密码不匹配",
|
||||
"apiKey": "API密钥",
|
||||
"regenerateApiKey": "重新生成API密钥",
|
||||
"apiKeyRegenerated": "API密钥已重新生成",
|
||||
"apiKeyRegenerateFailed": "重新生成API密钥失败",
|
||||
"apiKeyConfirm": "确定吗?当前的API密钥将失效。",
|
||||
"profileUpdated": "资料更新成功",
|
||||
"profileUpdateFailed": "资料更新失败"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "仪表板",
|
||||
@@ -1020,5 +1038,10 @@
|
||||
"dns": {
|
||||
"title": "DNS 管理",
|
||||
"description": "管理 DNS 提供商和插件以实现证书自动化"
|
||||
},
|
||||
"passthrough": {
|
||||
"title": "欢迎",
|
||||
"description": "您的账户拥有 Passthrough 访问权限。您可以直接访问分配给您的服务 — 无管理界面可用。",
|
||||
"noAccessToManagement": "您无权访问管理界面。"
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user