Compare commits

...

69 Commits

Author SHA1 Message Date
Jeremy
bfe535d36a Merge pull request #816 from Wikid82/hotfix/docker_build
fix(docker): update CADDY_VERSION to 2.11.2 for improved stability
2026-03-09 13:47:14 -04:00
GitHub Actions
aaf52475ee fix(docker): update Caddy version to 2.11.2 for consistency across documentation and scripts 2026-03-09 16:51:01 +00:00
GitHub Actions
cd35f6d8c7 fix(docker): update CADDY_CANDIDATE_VERSION to 2.11.2 for consistency 2026-03-09 16:47:48 +00:00
GitHub Actions
85b0bb1f5e fix(docker): update CADDY_VERSION to 2.11.2 for improved stability 2026-03-09 16:40:30 +00:00
Jeremy
36d561bbb8 Merge pull request #815 from Wikid82/nightly
Weekly: Promote nightly to main (2026-03-09)
2026-03-09 08:36:28 -04:00
Jeremy
fccb1f06ac Merge pull request #814 from Wikid82/bot/update-geolite2-checksum
chore(docker): update GeoLite2-Country.mmdb checksum
2026-03-09 08:36:09 -04:00
Wikid82
cf46ff0a3b chore(docker): update GeoLite2-Country.mmdb checksum
Automated checksum update for GeoLite2-Country.mmdb database.

Old: d3031e02196523cbb5f74291122033f2be277b2130abedd4b5bee52ba79832be
New: b79afc28a0a52f89c15e8d92b05c173f314dd4f687719f96cf921012d900fcce

Auto-generated by: .github/workflows/update-geolite2.yml
2026-03-09 02:56:06 +00:00
Jeremy
a66659476d Merge pull request #794 from Wikid82/feature/beta-release
Restructure User Management
2026-03-04 13:31:05 -05:00
GitHub Actions
7a8b0343e4 fix: update user record to trigger user_update audit event in E2E workflow 2026-03-04 15:36:02 +00:00
Jeremy
cc3077d709 Merge pull request #798 from Wikid82/renovate/feature/beta-release-docker-login-action-4.x
chore(deps): update docker/login-action action to v4 (feature/beta-release)
2026-03-04 08:36:19 -05:00
renovate[bot]
d1362a7fba chore(deps): update docker/login-action action to v4 2026-03-04 13:35:15 +00:00
GitHub Actions
4e9e1919a8 fix: update UserProfile role type and enhance API response typings for getProfile and updateProfile 2026-03-04 12:43:41 +00:00
GitHub Actions
f19f53ed9a fix(e2e): update user lifecycle audit entry checks to ensure both user_create and user_update events are present 2026-03-04 12:41:56 +00:00
GitHub Actions
f062dc206e fix: restrict email changes for non-admin users to profile settings 2026-03-04 12:38:28 +00:00
GitHub Actions
a97cb334a2 fix(deps): update @exodus/bytes, electron-to-chromium, and node-releases to latest versions 2026-03-04 12:28:05 +00:00
Jeremy
cf52a943b5 Merge pull request #797 from Wikid82/renovate/feature/beta-release-docker-setup-qemu-action-4.x
chore(deps): update docker/setup-qemu-action action to v4 (feature/beta-release)
2026-03-04 07:18:01 -05:00
Jeremy
46d0ecc4fb Merge pull request #796 from Wikid82/renovate/feature/beta-release-non-major-updates
fix(deps): update non-major-updates (feature/beta-release)
2026-03-04 07:17:31 -05:00
renovate[bot]
348c5e5405 chore(deps): update docker/setup-qemu-action action to v4 2026-03-04 12:16:35 +00:00
renovate[bot]
25dbe82360 fix(deps): update non-major-updates 2026-03-04 12:16:29 +00:00
GitHub Actions
fc404da455 fix(e2e): resolve shard 4 failures from 3-tier role model changes
Three tests broke when the Admin/User/Passthrough privilege model replaced
the old admin/user/guest hierarchy in PR-3.

- user-management: tighten heading locator to name='User Management' to avoid
  strict mode violation; the settings layout now renders a second h1
  ('Settings') alongside the page content heading
- user-lifecycle: update audit trail assertion from 2 to 1; users are now
  created with a role in a single API call so the backend does not emit a
  user_update audit entry when STEP 2 sends the same role value as creation
- auth-fixtures: replace invalid role='guest' with role='passthrough' in the
  guestUser fixture; the 'guest' role was removed in PR-3 and 'passthrough' is
  the equivalent lowest-privilege role in the new model

Verified: all three previously-failing tests now pass locally.
2026-03-03 13:10:44 +00:00
GitHub Actions
ed27fb0da9 fix(e2e): update account navigation locator and skip legacy Account.tsx test sections
The Account.tsx page was removed in PR-2b and replaced by UsersPage.tsx with
a UserDetailModal. Several E2E test sections still referenced UI elements that
only existed in the deleted page, causing CI failures across shards.

- admin-onboarding: update header profile link locator from /settings/account
  to /settings/users to match the new navigation target in Layout.tsx
- account-settings: skip five legacy test sections (Profile Management,
  Certificate Email, Password Change, API Key Management, Accessibility) that
  reference deleted Account.tsx elements (#profile-name, #profile-email,
  #useUserEmail, #cert-email) or assume these fields are directly on the page
  rather than inside the UserDetailModal
- Each skipped section includes an explanatory comment pointing to the PR-3
  'Self-Service Profile via Users Page (F10)' suite as the equivalent coverage

Verified: admin-onboarding 8/8 pass; account-settings 8 pass / 20 skipped
2026-03-03 10:27:13 +00:00
GitHub Actions
afbd50b43f fix: update @floating-ui and caniuse-lite packages to latest versions for improved functionality 2026-03-03 09:17:54 +00:00
GitHub Actions
ad2d30b525 fix: update postcss to version 8.5.8 for improved stability 2026-03-03 09:17:25 +00:00
GitHub Actions
a570a3327f fix: update opentelemetry http instrumentation to v0.66.0 2026-03-03 09:16:34 +00:00
GitHub Actions
0fd00575a2 feat: Add passthrough role support and related tests
- Implemented middleware to restrict access for passthrough users in management routes.
- Added unit tests for management access requirements based on user roles.
- Updated user model tests to include passthrough role validation.
- Enhanced frontend user management to support passthrough role in invite modal.
- Created end-to-end tests for passthrough user access restrictions and navigation visibility.
- Verified self-service profile management for admins and regular users.
2026-03-03 09:14:33 +00:00
GitHub Actions
a3d1ae3742 fix: update checkout ref to use full GitHub ref path for accurate branch handling 2026-03-03 04:31:42 +00:00
GitHub Actions
6f408f62ba fix: prevent stale-SHA checkout in scheduled CodeQL security scan
The scheduled CodeQL analysis explicitly passed ref: github.sha, which
is frozen when a cron job is queued, not when it runs. Under load or
during a long queue, the analysis could scan code that is days old,
missing vulnerabilities introduced since the last scheduling window.

Replace with ref: github.ref_name so all trigger types — scheduled,
push, and pull_request — consistently scan the current HEAD of the
branch being processed.
2026-03-03 04:24:47 +00:00
GitHub Actions
e92e7edd70 fix: prevent stale-SHA checkout and pin caddy-security in weekly security rebuild
The scheduled weekly rebuild was failing because GitHub Actions froze
github.sha at job-queue time. When the Sunday cron queued a job on
March 1 with Feb 23 code (CADDY_VERSION=2.11.0-beta.2), that job ran
two days later on March 3 still using the old code, missing the caddy
version fix that had since landed on main.

Additionally, caddy-security was unpinned, so xcaddy auto-resolved it
to v1.1.36 which requires caddy/v2@v2.11.1 — conflicting with xcaddy's
internally bundled v2.11.0-beta.2 reference.

- Add ref: github.ref_name to checkout step so the rebuild always
  fetches current branch HEAD at run time, not the SHA frozen at queue
  time
- Add CADDY_SECURITY_VERSION=1.1.36 ARG to pin the caddy-security
  plugin to a known-compatible version; pass it via --with so xcaddy
  picks up the pinned release
- Add --with github.com/caddyserver/caddy/v2@v${CADDY_TARGET_VERSION}
  to force xcaddy to use the declared Caddy version, overriding its own
  internal go.sum pin for caddy
- Add Renovate custom manager for CADDY_SECURITY_VERSION so future
  caddy-security releases trigger an automated PR instead of silently
  breaking the build

Fixes weekly security rebuild CI failures introduced ~Feb 22 when
caddy-security v1.1.36 was published.
2026-03-03 04:22:39 +00:00
GitHub Actions
4e4c4581ea fix: update Caddy Server version to 2.11.1 in architecture documentation 2026-03-03 03:52:57 +00:00
GitHub Actions
3f12ca05a3 feat: implement role-based access for settings route and add focus trap hook
- Wrapped the Settings component in RequireRole to enforce access control for admin and user roles.
- Introduced a new custom hook `useFocusTrap` to manage focus within modal dialogs, enhancing accessibility.
- Applied the focus trap in InviteModal, PermissionsModal, and UserDetailModal to prevent focus from leaving the dialog.
- Updated PassthroughLanding to focus on the heading when the component mounts.
2026-03-03 03:10:02 +00:00
GitHub Actions
a681d6aa30 feat: remove Account page and add PassthroughLanding page
- Deleted the Account page and its associated logic.
- Introduced a new PassthroughLanding page for users without management access.
- Updated Settings page to conditionally display the Users link for admin users.
- Enhanced UsersPage to support passthrough user role, including invite functionality and user detail modal.
- Updated tests to reflect changes in user roles and navigation.
2026-03-03 03:10:02 +00:00
GitHub Actions
3632d0d88c fix: user roles to use UserRole type and update related tests
- Changed user role representation from string to UserRole type in User model.
- Updated role assignments in various services and handlers to use the new UserRole constants.
- Modified middleware to handle UserRole type for role checks.
- Refactored tests to align with the new UserRole type.
- Added migration function to convert legacy "viewer" roles to "passthrough".
- Ensured all role checks and assignments are consistent across the application.
2026-03-03 03:10:02 +00:00
GitHub Actions
a1a9ab2ece chore(docs): archive uptime monitoring regression investigation plan to address false DOWN states 2026-03-03 03:10:02 +00:00
Jeremy
9c203914dd Merge pull request #795 from Wikid82/renovate/feature/beta-release-non-major-updates
chore(deps): update dependency postcss to ^8.5.8 (feature/beta-release)
2026-03-02 19:25:08 -05:00
renovate[bot]
6cfe8ca9f2 chore(deps): update dependency postcss to ^8.5.8 2026-03-03 00:22:16 +00:00
Jeremy
938b170d98 Merge branch 'development' into feature/beta-release 2026-03-02 17:41:57 -05:00
Jeremy
9d6d2cbe53 Merge pull request #793 from Wikid82/renovate/feature/beta-release-non-major-updates
chore(deps): update dependency postcss to ^8.5.7 (feature/beta-release)
2026-03-02 17:33:09 -05:00
renovate[bot]
136dd7ef62 chore(deps): update dependency postcss to ^8.5.7 2026-03-02 22:31:09 +00:00
Jeremy
f0c754cc52 Merge pull request #785 from Wikid82/feature/beta-release
Save and Import Functions Hotfix
2026-03-02 17:28:03 -05:00
GitHub Actions
28be62dee0 fix(tests): update cancel endpoint mock to match DELETE requests with session UUID 2026-03-02 22:09:53 +00:00
Jeremy
49bfbf3f76 Merge branch 'development' into feature/beta-release 2026-03-02 16:04:39 -05:00
GitHub Actions
2f90d936bf fix(tests): simplify back/cancel button handling in cross-browser import tests 2026-03-02 21:02:34 +00:00
GitHub Actions
4a60400af9 chore(deps): add tracking for Syft and Grype versions in workflows and scripts 2026-03-02 21:01:42 +00:00
GitHub Actions
18d0c235fa fix(deps): update OpenTelemetry dependencies to v1.41.0 2026-03-02 20:31:45 +00:00
GitHub Actions
fe8225753b fix(tests): remove visibility check for banner in cancel session flow 2026-03-02 20:28:40 +00:00
GitHub Actions
273fb3cf21 fix(tests): improve cancel session flow in cross-browser import tests 2026-03-02 20:04:34 +00:00
GitHub Actions
e3b6693402 fix: correct version-check hook to use global latest tag
The pre-commit version check hook was incorrectly using `git describe`
to find the latest tag, which only traverses the current branch's
ancestry. On feature branches that predate release tags applied to
main/nightly, this caused false failures — reporting v0.19.1 as latest
even though v0.20.0 and v0.21.0 existed globally.

Replaced with `git tag --sort=-v:refname | grep semver | head -1` so
the check always compares .version against the true latest release tag
in the repository, independent of which branch is checked out.
2026-03-02 19:52:47 +00:00
Jeremy
ac915f14c7 Merge pull request #792 from Wikid82/renovate/feature/beta-release-non-major-updates
chore(deps): update aquasecurity/trivy-action action to v0.34.2 (feature/beta-release)
2026-03-02 14:08:07 -05:00
renovate[bot]
5ee52dd4d6 chore(deps): update aquasecurity/trivy-action action to v0.34.2 2026-03-02 19:02:20 +00:00
GitHub Actions
b5fd5d5774 fix(tests): update import handler test to use temporary directory for Caddyfile path 2026-03-02 15:29:49 +00:00
Jeremy
ae4f5936b3 Merge pull request #787 from Wikid82/main
Propagate changes from main into development
2026-03-02 10:29:25 -05:00
GitHub Actions
5017fdf4c1 fix: correct spelling of 'linting' in Management agent instructions 2026-03-02 15:25:36 +00:00
GitHub Actions
f0eda7c93c chore: remove workflow_dispatch trigger from quality checks workflow 2026-03-02 15:14:25 +00:00
GitHub Actions
f60a99d0bd fix(tests): update route validation functions to ensure canonical success responses in import/save regression tests 2026-03-02 15:05:05 +00:00
Jeremy
1440b2722e Merge pull request #786 from Wikid82/renovate/feature/beta-release-non-major-updates
fix(deps): update non-major-updates (feature/beta-release)
2026-03-02 10:02:56 -05:00
renovate[bot]
3b92700b5b fix(deps): update non-major-updates 2026-03-02 14:58:14 +00:00
GitHub Actions
5c0a543669 chore: update flatted, tldts, and tldts-core to version 7.0.24 in package-lock.json 2026-03-02 14:55:30 +00:00
GitHub Actions
317b695efb chore: update tldts and tldts-core to version 7.0.24 in package-lock.json 2026-03-02 14:54:51 +00:00
GitHub Actions
077e3c1d2b chore: add integration tests for import/save route regression coverage 2026-03-02 14:53:59 +00:00
GitHub Actions
b5c5ab0bc3 chore: add workflow_dispatch trigger to quality checks workflow 2026-03-02 14:53:59 +00:00
Jeremy
a6188bf2f1 Merge branch 'development' into feature/beta-release 2026-03-02 09:48:21 -05:00
GitHub Actions
16752f4bb1 fixt(import): update cancel functions to accept session UUID and modify related tests 2026-03-02 14:30:24 +00:00
GitHub Actions
a75dd2dcdd chore: refactor agent tools and improve documentation
- Consolidated tools for Management, Planning, Playwright Dev, QA Security, and Supervisor agents to streamline functionality and reduce redundancy.
- Updated terminology from "Proper" fix to "Long Term" fix in Management agent for clarity on implementation choices.
- Added mandatory lintr and type checks before declaring slices "DONE" in Management agent to enhance code quality.
- Enhanced argument hints and descriptions across agents for better guidance on usage.
2026-03-02 14:24:31 +00:00
GitHub Actions
63e79664cc test(routes): add strict route matrix tests for import and save workflows 2026-03-02 14:11:54 +00:00
GitHub Actions
005b7bdf5b fix(handler): enforce session UUID requirement in Cancel method and add related tests 2026-03-02 14:11:20 +00:00
GitHub Actions
0f143af5bc fix(handler): validate session UUID in Cancel method of JSONImportHandler 2026-03-02 14:10:45 +00:00
GitHub Actions
76fb800922 fix(deps): update @csstools/css-syntax-patches-for-csstree and cssstyle to latest versions 2026-03-02 08:39:22 +00:00
Jeremy
58f5295652 Merge pull request #782 from Wikid82/renovate/feature/beta-release-non-major-updates
fix(deps): update non-major-updates (feature/beta-release)
2026-03-02 03:32:42 -05:00
renovate[bot]
0917a1ae95 fix(deps): update non-major-updates 2026-03-02 08:19:58 +00:00
117 changed files with 5666 additions and 1526 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -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
View File

@@ -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>.*)$"
}
],

View File

@@ -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'

View File

@@ -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 }}

View File

@@ -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'

View File

@@ -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 }}

View File

@@ -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 }}

View File

@@ -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 }}

View File

@@ -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'

View File

@@ -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 }}

View File

@@ -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'

View File

@@ -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 }}

View File

@@ -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 }}

View File

@@ -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'

View File

@@ -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

View File

@@ -1 +1 @@
v0.19.1
v0.21.0

2
.vscode/tasks.json vendored
View File

@@ -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": []
},

View File

@@ -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 |

View File

@@ -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)"; \

View File

@@ -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 {

View File

@@ -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",
}

View File

@@ -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

View File

@@ -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=

View File

@@ -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

View File

@@ -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,
}

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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 {

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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")
}

View File

@@ -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)

View File

@@ -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()
}
}

View File

@@ -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)
}

View File

@@ -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()
}
}

View File

@@ -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)

View File

@@ -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
}

View 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]",
)
}

View File

@@ -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

View 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")
}

View File

@@ -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)

View 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")
}

View File

@@ -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)
}

View File

@@ -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,

View File

@@ -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")

View File

@@ -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
}

View File

@@ -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

View File

@@ -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),

View File

@@ -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)),

View File

@@ -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(),
}

View 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

View File

@@ -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

View File

@@ -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`

View File

@@ -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

View 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:151159` |
| 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` |

View File

@@ -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": {

View File

@@ -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",

View File

@@ -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 */}

View File

@@ -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 () => {

View 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);
});
});

View 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);
});
});

View File

@@ -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: {

View File

@@ -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,
},
});
};
/**

View File

@@ -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,
});
};

View File

@@ -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,
});
};

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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>
)}

View 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

View File

@@ -2,7 +2,7 @@ import { createContext } from 'react';
export interface User {
user_id: number;
role: string;
role: 'admin' | 'user' | 'passthrough';
name?: string;
email?: string;
}

View File

@@ -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 />

View File

@@ -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()
})

View 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()
})
})
})

View 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()
})
})
})

View 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])
}

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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."
}
}

View File

@@ -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."
}
}

View File

@@ -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."
}
}

View File

@@ -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."
}
}

View File

@@ -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