mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2026-09-09 20:08:59 -04:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
87bb92fac0 | ||
|
|
01dc90bfa7 | ||
|
|
c37e6384a5 | ||
|
|
70fc955d37 | ||
|
|
a44f226e8a | ||
|
|
edafde5810 | ||
|
|
54cd037cd2 | ||
|
|
2ebf67d44d | ||
|
|
d2d294b93c | ||
|
|
39eb91ac48 | ||
|
|
53376d94d9 | ||
|
|
f51973bb7b | ||
|
|
33d1756fdd | ||
|
|
63931763c4 | ||
|
|
bdb956ec84 | ||
|
|
41b48d1104 | ||
|
|
57fef26726 | ||
|
|
ea94dc4548 | ||
|
|
13a7232bc5 | ||
|
|
5fea8a0041 | ||
|
|
2333c86a08 | ||
|
|
cbfc1b2875 | ||
|
|
62e10afe7b | ||
|
|
d542c716f9 | ||
|
|
7eeaefaa65 | ||
|
|
bd55356881 |
No files matched your search
@@ -22,23 +22,32 @@ concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
env:
|
||||
REGISTRY_IMAGE: ghcr.io/cleanuparr/cleanuparr
|
||||
|
||||
jobs:
|
||||
build_app:
|
||||
# Compute tags, version, and push decision for downstream jobs
|
||||
prepare:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
||||
outputs:
|
||||
tags: ${{ steps.build-info.outputs.tags }}
|
||||
version: ${{ steps.build-info.outputs.version }}
|
||||
version_docker_tag: ${{ steps.build-info.outputs.version_docker_tag }}
|
||||
branch: ${{ steps.build-info.outputs.branch }}
|
||||
push: ${{ steps.build-info.outputs.push }}
|
||||
github_sha: ${{ github.sha }}
|
||||
steps:
|
||||
|
||||
- name: Set github context
|
||||
timeout-minutes: 1
|
||||
run: |
|
||||
echo 'githubRepository=${{ github.repository }}' >> $GITHUB_ENV
|
||||
echo 'githubSha=${{ github.sha }}' >> $GITHUB_ENV
|
||||
echo 'githubRef=${{ github.ref }}' >> $GITHUB_ENV
|
||||
echo 'githubHeadRef=${{ github.head_ref }}' >> $GITHUB_ENV
|
||||
|
||||
- name: Initialize build info
|
||||
id: build-info
|
||||
timeout-minutes: 1
|
||||
run: |
|
||||
githubHeadRef=${{ env.githubHeadRef }}
|
||||
githubHeadRef="${{ github.head_ref }}"
|
||||
githubRef="${{ github.ref }}"
|
||||
inputVersion="${{ inputs.app_version }}"
|
||||
latestDockerTag=""
|
||||
versionDockerTag=""
|
||||
@@ -71,10 +80,8 @@ jobs:
|
||||
minorVersionDockerTag="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}"
|
||||
fi
|
||||
else
|
||||
# Determine if this run is for the main branch or another branch
|
||||
if [[ -z "$githubHeadRef" ]]; then
|
||||
# Main branch
|
||||
githubRef=${{ env.githubRef }}
|
||||
branch=${githubRef##*/}
|
||||
versionDockerTag="$branch"
|
||||
else
|
||||
@@ -85,25 +92,45 @@ jobs:
|
||||
fi
|
||||
|
||||
githubTags=""
|
||||
|
||||
if [ -n "$latestDockerTag" ]; then
|
||||
githubTags="$githubTags,ghcr.io/cleanuparr/cleanuparr:$latestDockerTag"
|
||||
githubTags="$githubTags,$REGISTRY_IMAGE:$latestDockerTag"
|
||||
fi
|
||||
if [ -n "$versionDockerTag" ]; then
|
||||
githubTags="$githubTags,ghcr.io/cleanuparr/cleanuparr:$versionDockerTag"
|
||||
githubTags="$githubTags,$REGISTRY_IMAGE:$versionDockerTag"
|
||||
fi
|
||||
if [ -n "$minorVersionDockerTag" ]; then
|
||||
githubTags="$githubTags,ghcr.io/cleanuparr/cleanuparr:$minorVersionDockerTag"
|
||||
githubTags="$githubTags,$REGISTRY_IMAGE:$minorVersionDockerTag"
|
||||
fi
|
||||
if [ -n "$majorVersionDockerTag" ]; then
|
||||
githubTags="$githubTags,ghcr.io/cleanuparr/cleanuparr:$majorVersionDockerTag"
|
||||
githubTags="$githubTags,$REGISTRY_IMAGE:$majorVersionDockerTag"
|
||||
fi
|
||||
|
||||
# set env vars
|
||||
echo "branch=$branch" >> $GITHUB_ENV
|
||||
echo "githubTags=$githubTags" >> $GITHUB_ENV
|
||||
echo "versionDockerTag=$versionDockerTag" >> $GITHUB_ENV
|
||||
echo "version=$version" >> $GITHUB_ENV
|
||||
githubTags="${githubTags#,}"
|
||||
|
||||
# Determine push decision
|
||||
push="${{ github.event_name == 'pull_request' || inputs.push_docker == true }}"
|
||||
|
||||
echo "tags=$githubTags" >> $GITHUB_OUTPUT
|
||||
echo "version=$version" >> $GITHUB_OUTPUT
|
||||
echo "version_docker_tag=$versionDockerTag" >> $GITHUB_OUTPUT
|
||||
echo "branch=$branch" >> $GITHUB_OUTPUT
|
||||
echo "push=$push" >> $GITHUB_OUTPUT
|
||||
|
||||
# Build each platform in parallel
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [prepare]
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
platform:
|
||||
- linux/amd64
|
||||
- linux/arm64
|
||||
steps:
|
||||
- name: Prepare platform pair
|
||||
run: |
|
||||
platform=${{ matrix.platform }}
|
||||
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
|
||||
|
||||
- name: Get vault secrets
|
||||
uses: hashicorp/vault-action@v2
|
||||
@@ -113,8 +140,6 @@ jobs:
|
||||
roleId: ${{ secrets.VAULT_ROLE_ID }}
|
||||
secretId: ${{ secrets.VAULT_SECRET_ID }}
|
||||
secrets:
|
||||
secrets/data/docker username | DOCKER_USERNAME;
|
||||
secrets/data/docker password | DOCKER_PASSWORD;
|
||||
secrets/data/github repo_readonly_pat | REPO_READONLY_PAT;
|
||||
secrets/data/github packages_pat | PACKAGES_PAT
|
||||
|
||||
@@ -122,16 +147,97 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
timeout-minutes: 1
|
||||
with:
|
||||
repository: ${{ env.githubRepository }}
|
||||
ref: ${{ env.branch }}
|
||||
repository: ${{ github.repository }}
|
||||
ref: ${{ needs.prepare.outputs.branch }}
|
||||
token: ${{ env.REPO_READONLY_PAT }}
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
timeout-minutes: 5
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Login to GitHub Container Registry
|
||||
if: needs.prepare.outputs.push == 'true'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push by digest
|
||||
if: needs.prepare.outputs.push == 'true'
|
||||
id: build-push
|
||||
timeout-minutes: 30
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ${{ github.workspace }}/code
|
||||
file: ${{ github.workspace }}/code/Dockerfile
|
||||
provenance: false
|
||||
labels: |
|
||||
commit=sha-${{ needs.prepare.outputs.github_sha }}
|
||||
version=${{ needs.prepare.outputs.version_docker_tag }}
|
||||
build-args: |
|
||||
VERSION=${{ needs.prepare.outputs.version }}
|
||||
PACKAGES_USERNAME=${{ secrets.PACKAGES_USERNAME }}
|
||||
PACKAGES_PAT=${{ env.PACKAGES_PAT }}
|
||||
platforms: ${{ matrix.platform }}
|
||||
outputs: type=image,"name=${{ env.REGISTRY_IMAGE }}",push-by-digest=true,name-canonical=true,push=true
|
||||
cache-from: type=gha,scope=build-${{ env.PLATFORM_PAIR }}
|
||||
cache-to: type=gha,scope=build-${{ env.PLATFORM_PAIR }},mode=max
|
||||
|
||||
- name: Build (no push)
|
||||
if: needs.prepare.outputs.push != 'true'
|
||||
timeout-minutes: 30
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ${{ github.workspace }}/code
|
||||
file: ${{ github.workspace }}/code/Dockerfile
|
||||
provenance: false
|
||||
labels: |
|
||||
commit=sha-${{ needs.prepare.outputs.github_sha }}
|
||||
version=${{ needs.prepare.outputs.version_docker_tag }}
|
||||
build-args: |
|
||||
VERSION=${{ needs.prepare.outputs.version }}
|
||||
PACKAGES_USERNAME=${{ secrets.PACKAGES_USERNAME }}
|
||||
PACKAGES_PAT=${{ env.PACKAGES_PAT }}
|
||||
platforms: ${{ matrix.platform }}
|
||||
push: false
|
||||
cache-from: type=gha,scope=build-${{ env.PLATFORM_PAIR }}
|
||||
cache-to: type=gha,scope=build-${{ env.PLATFORM_PAIR }},mode=max
|
||||
|
||||
- name: Export digest
|
||||
if: needs.prepare.outputs.push == 'true'
|
||||
run: |
|
||||
mkdir -p ${{ runner.temp }}/digests
|
||||
digest="${{ steps.build-push.outputs.digest }}"
|
||||
touch "${{ runner.temp }}/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
if: needs.prepare.outputs.push == 'true'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digests-${{ env.PLATFORM_PAIR }}
|
||||
path: ${{ runner.temp }}/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
# Create multi-platform manifest and push with final tags
|
||||
merge:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [prepare, build]
|
||||
if: needs.prepare.outputs.push == 'true'
|
||||
steps:
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: ${{ runner.temp }}/digests
|
||||
pattern: digests-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
@@ -140,27 +246,25 @@ jobs:
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push docker image
|
||||
id: docker-build
|
||||
timeout-minutes: 15
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ${{ github.workspace }}/code
|
||||
file: ${{ github.workspace }}/code/Dockerfile
|
||||
provenance: false
|
||||
labels: |
|
||||
commit=sha-${{ env.githubSha }}
|
||||
version=${{ env.versionDockerTag }}
|
||||
build-args: |
|
||||
VERSION=${{ env.version }}
|
||||
PACKAGES_USERNAME=${{ secrets.PACKAGES_USERNAME }}
|
||||
PACKAGES_PAT=${{ env.PACKAGES_PAT }}
|
||||
platforms: |
|
||||
linux/amd64
|
||||
linux/arm64
|
||||
push: ${{ github.event_name == 'pull_request' || inputs.push_docker == true }}
|
||||
tags: |
|
||||
${{ env.githubTags }}
|
||||
# Enable BuildKit cache for faster builds
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
- name: Create manifest list and push
|
||||
timeout-minutes: 5
|
||||
working-directory: ${{ runner.temp }}/digests
|
||||
run: |
|
||||
tags="${{ needs.prepare.outputs.tags }}"
|
||||
tag_args=""
|
||||
IFS=',' read -ra TAG_ARRAY <<< "$tags"
|
||||
for tag in "${TAG_ARRAY[@]}"; do
|
||||
tag=$(echo "$tag" | xargs)
|
||||
if [ -n "$tag" ]; then
|
||||
tag_args="$tag_args -t $tag"
|
||||
fi
|
||||
done
|
||||
|
||||
docker buildx imagetools create $tag_args \
|
||||
$(printf '${{ env.REGISTRY_IMAGE }}@sha256:%s ' *)
|
||||
|
||||
- name: Inspect image
|
||||
run: |
|
||||
tags="${{ needs.prepare.outputs.tags }}"
|
||||
first_tag=$(echo "$tags" | tr ',' '\n' | grep -v '^$' | head -1 | xargs)
|
||||
docker buildx imagetools inspect "$first_tag"
|
||||
@@ -74,9 +74,9 @@ jobs:
|
||||
token: ${{ env.REPO_READONLY_PAT }}
|
||||
|
||||
- name: Setup dotnet
|
||||
uses: actions/setup-dotnet@v4
|
||||
uses: actions/setup-dotnet@v5
|
||||
with:
|
||||
dotnet-version: 10.0.x
|
||||
dotnet-version: 10.0.200
|
||||
|
||||
- name: Cache NuGet packages
|
||||
uses: actions/cache@v4
|
||||
|
||||
@@ -84,9 +84,9 @@ jobs:
|
||||
path: code/frontend/dist/ui/browser
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
uses: actions/setup-dotnet@v5
|
||||
with:
|
||||
dotnet-version: 10.0.x
|
||||
dotnet-version: 10.0.200
|
||||
|
||||
- name: Restore .NET dependencies
|
||||
run: |
|
||||
|
||||
@@ -68,9 +68,9 @@ jobs:
|
||||
path: code/frontend/dist/ui/browser
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
uses: actions/setup-dotnet@v5
|
||||
with:
|
||||
dotnet-version: 10.0.x
|
||||
dotnet-version: 10.0.200
|
||||
|
||||
- name: Restore .NET dependencies
|
||||
run: |
|
||||
|
||||
@@ -20,6 +20,10 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Create directory for static files
|
||||
run: |
|
||||
mkdir -p Cloudflare/static
|
||||
|
||||
- name: Copy root static files to Cloudflare static directory
|
||||
run: |
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
name: E2E Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'code/**'
|
||||
- 'e2e/**'
|
||||
- '.github/workflows/e2e.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'code/**'
|
||||
- 'e2e/**'
|
||||
- '.github/workflows/e2e.yml'
|
||||
workflow_call:
|
||||
|
||||
concurrency:
|
||||
group: E2E Tests-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
e2e:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
timeout-minutes: 1
|
||||
|
||||
- name: Get vault secrets
|
||||
uses: hashicorp/vault-action@v2
|
||||
with:
|
||||
url: ${{ secrets.VAULT_HOST }}
|
||||
method: approle
|
||||
roleId: ${{ secrets.VAULT_ROLE_ID }}
|
||||
secretId: ${{ secrets.VAULT_SECRET_ID }}
|
||||
secrets:
|
||||
secrets/data/github packages_pat | PACKAGES_PAT
|
||||
|
||||
- name: Start services
|
||||
working-directory: e2e
|
||||
run: docker compose -f docker-compose.e2e.yml up -d --build
|
||||
env:
|
||||
PACKAGES_USERNAME: ${{ github.repository_owner }}
|
||||
PACKAGES_PAT: ${{ env.PACKAGES_PAT }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Install E2E dependencies
|
||||
working-directory: e2e
|
||||
run: npm ci
|
||||
|
||||
- name: Install Playwright browsers
|
||||
working-directory: e2e
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: Wait for Keycloak
|
||||
run: |
|
||||
echo "Waiting for Keycloak realm to be ready..."
|
||||
timeout 120 bash -c 'until curl -sf http://localhost:8080/realms/cleanuparr-test/.well-known/openid-configuration; do sleep 3; done'
|
||||
echo "Keycloak ready!"
|
||||
|
||||
- name: Wait for app
|
||||
run: |
|
||||
echo "Waiting for Cleanuparr to be ready..."
|
||||
timeout 120 bash -c 'until curl -sf http://localhost:5000/health; do sleep 3; done'
|
||||
echo "App ready!"
|
||||
|
||||
- name: Run E2E tests
|
||||
working-directory: e2e
|
||||
run: npx playwright test
|
||||
|
||||
- name: Upload test results
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: e2e-test-results
|
||||
path: |
|
||||
e2e/playwright-report/
|
||||
e2e/test-results/
|
||||
retention-days: 7
|
||||
|
||||
- name: Stop services
|
||||
if: always()
|
||||
working-directory: e2e
|
||||
run: docker compose -f docker-compose.e2e.yml down
|
||||
@@ -96,24 +96,33 @@ jobs:
|
||||
uses: ./.github/workflows/test.yml
|
||||
secrets: inherit
|
||||
|
||||
# Run E2E tests
|
||||
e2e:
|
||||
needs: validate
|
||||
if: ${{ needs.validate.outputs.is_tag == 'true' || github.event.inputs.runTests == 'true' }}
|
||||
uses: ./.github/workflows/e2e.yml
|
||||
secrets: inherit
|
||||
|
||||
# Build frontend once for all build jobs and cache it
|
||||
build-frontend:
|
||||
needs: [validate, test]
|
||||
needs: [validate, test, e2e]
|
||||
if: |
|
||||
always() &&
|
||||
needs.validate.result == 'success' &&
|
||||
(needs.test.result == 'success' || needs.test.result == 'skipped') &&
|
||||
(needs.e2e.result == 'success' || needs.e2e.result == 'skipped') &&
|
||||
(needs.validate.outputs.is_tag == 'true' || github.event.inputs.buildBinaries == 'true')
|
||||
uses: ./.github/workflows/build-frontend.yml
|
||||
secrets: inherit
|
||||
|
||||
# Build portable executables
|
||||
build-executables:
|
||||
needs: [validate, test, build-frontend]
|
||||
needs: [validate, test, e2e, build-frontend]
|
||||
if: |
|
||||
always() &&
|
||||
needs.validate.result == 'success' &&
|
||||
(needs.test.result == 'success' || needs.test.result == 'skipped') &&
|
||||
(needs.e2e.result == 'success' || needs.e2e.result == 'skipped') &&
|
||||
needs.build-frontend.result == 'success' &&
|
||||
(needs.validate.outputs.is_tag == 'true' || github.event.inputs.buildBinaries == 'true')
|
||||
uses: ./.github/workflows/build-executable.yml
|
||||
@@ -123,11 +132,12 @@ jobs:
|
||||
|
||||
# Build Windows installer
|
||||
build-windows-installer:
|
||||
needs: [validate, test, build-frontend]
|
||||
needs: [validate, test, e2e, build-frontend]
|
||||
if: |
|
||||
always() &&
|
||||
needs.validate.result == 'success' &&
|
||||
(needs.test.result == 'success' || needs.test.result == 'skipped') &&
|
||||
(needs.e2e.result == 'success' || needs.e2e.result == 'skipped') &&
|
||||
needs.build-frontend.result == 'success' &&
|
||||
(needs.validate.outputs.is_tag == 'true' || github.event.inputs.buildBinaries == 'true')
|
||||
uses: ./.github/workflows/build-windows-installer.yml
|
||||
@@ -137,11 +147,12 @@ jobs:
|
||||
|
||||
# Build macOS installers (Intel and ARM)
|
||||
build-macos:
|
||||
needs: [validate, test, build-frontend]
|
||||
needs: [validate, test, e2e, build-frontend]
|
||||
if: |
|
||||
always() &&
|
||||
needs.validate.result == 'success' &&
|
||||
(needs.test.result == 'success' || needs.test.result == 'skipped') &&
|
||||
(needs.e2e.result == 'success' || needs.e2e.result == 'skipped') &&
|
||||
needs.build-frontend.result == 'success' &&
|
||||
(needs.validate.outputs.is_tag == 'true' || github.event.inputs.buildBinaries == 'true')
|
||||
uses: ./.github/workflows/build-macos-installer.yml
|
||||
@@ -151,11 +162,12 @@ jobs:
|
||||
|
||||
# Build and push Docker image(s)
|
||||
build-docker:
|
||||
needs: [validate, test]
|
||||
needs: [validate, test, e2e]
|
||||
if: |
|
||||
always() &&
|
||||
needs.validate.result == 'success' &&
|
||||
(needs.test.result == 'success' || needs.test.result == 'skipped') &&
|
||||
(needs.e2e.result == 'success' || needs.e2e.result == 'skipped') &&
|
||||
(needs.validate.outputs.is_tag == 'true' || github.event.inputs.buildDocker == 'true')
|
||||
uses: ./.github/workflows/build-docker.yml
|
||||
with:
|
||||
@@ -232,7 +244,7 @@ jobs:
|
||||
|
||||
# Summary job
|
||||
summary:
|
||||
needs: [validate, test, build-frontend, build-executables, build-windows-installer, build-macos, build-docker]
|
||||
needs: [validate, test, e2e, build-frontend, build-executables, build-windows-installer, build-macos, build-docker]
|
||||
runs-on: ubuntu-latest
|
||||
if: always()
|
||||
|
||||
@@ -277,6 +289,7 @@ jobs:
|
||||
}
|
||||
|
||||
print_result "Tests" "${{ needs.test.result }}"
|
||||
print_result "E2E Tests" "${{ needs.e2e.result }}"
|
||||
print_result "Frontend Build" "${{ needs.build-frontend.result }}"
|
||||
print_result "Portable Executables" "${{ needs.build-executables.result }}"
|
||||
print_result "Windows Installer" "${{ needs.build-windows-installer.result }}"
|
||||
|
||||
@@ -15,7 +15,7 @@ on:
|
||||
|
||||
# Cancel in-progress runs for the same PR
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
group: Tests-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
@@ -29,9 +29,9 @@ jobs:
|
||||
timeout-minutes: 1
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
uses: actions/setup-dotnet@v5
|
||||
with:
|
||||
dotnet-version: 10.0.x
|
||||
dotnet-version: 10.0.200
|
||||
|
||||
- name: Cache NuGet packages
|
||||
uses: actions/cache@v4
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
## Project Overview
|
||||
|
||||
Cleanuparr is a tool for automating the cleanup of unwanted or blocked files in Sonarr, Radarr, Lidarr, Readarr, Whisparr and supported download clients like qBittorrent, Transmission, Deluge, and µTorrent. It provides malware protection, automated cleanup, and queue management for *arr applications.
|
||||
Cleanuparr is a tool for automating the cleanup of unwanted or blocked files in Sonarr, Radarr, Lidarr, Readarr, Whisparr and supported download clients like qBittorrent, Transmission, Deluge, µTorrent and rTorrent. It provides malware protection, automated cleanup, and queue management for *arr applications.
|
||||
|
||||
**Key Features:**
|
||||
- Strike system for bad downloads
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
thepirateheaven.org
|
||||
RARBG.work
|
||||
@@ -49,6 +49,7 @@ https://cleanuparr.github.io/Cleanuparr/docs/screenshots
|
||||
- **Transmission**
|
||||
- **Deluge**
|
||||
- **µTorrent**
|
||||
- **rTorrent**
|
||||
|
||||
### Platforms
|
||||
- **Docker**
|
||||
@@ -101,14 +102,6 @@ We welcome contributions from the community! Whether it's bug fixes, new feature
|
||||
- **[Feature Requests](https://github.com/Cleanuparr/Cleanuparr/issues/new/choose)** - Share your ideas for new features
|
||||
- **[Help Test Features](https://discord.gg/SCtMCgtsc4)** - Join Discord to test pre-release features and provide feedback
|
||||
|
||||
# <img style="vertical-align: middle;" width="24px" src="./Logo/256.png" alt="Cleanuparr"> <span style="vertical-align: middle;">Cleanuparr</span> <img src="https://raw.githubusercontent.com/FortAwesome/Font-Awesome/6.x/svgs/solid/x.svg" height="24px" width="30px" style="vertical-align: middle;"> <span style="vertical-align: middle;">Huntarr</span> <img style="vertical-align: middle;" width="24px" src="https://github.com/plexguide/Huntarr.io/blob/main/frontend/static/logo/512.png?raw=true" alt Huntarr></img>
|
||||
|
||||
Think of **Cleanuparr** as the janitor of your server; it keeps your download queue spotless, removes clutter, and blocks malicious files. Now imagine combining that with **Huntarr**, the compulsive librarian who finds missing and upgradable media to complete your collection
|
||||
|
||||
While **Huntarr** fills in the blanks and improves what you already have, **Cleanuparr** makes sure that only clean downloads get through. If you're aiming for a reliable and self-sufficient setup, **Cleanuparr** and **Huntarr** will take your automated media stack to another level.
|
||||
|
||||
<span style="font-size:24px"> ➡️ [**Huntarr**](https://github.com/plexguide/Huntarr.io) <span style="vertical-align: middle"></span></span>
|
||||
|
||||
# Credits
|
||||
Special thanks for inspiration go to:
|
||||
- [ThijmenGThN/swaparr](https://github.com/ThijmenGThN/swaparr)
|
||||
|
||||
@@ -24,4 +24,8 @@
|
||||
<ProjectReference Include="..\Cleanuparr.Api\Cleanuparr.Api.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,14 +1,17 @@
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Xunit;
|
||||
|
||||
// Integration tests share file-system state (config-dir used by SetupGuardMiddleware),
|
||||
// so they must be run sequentially to avoid interference between factories.
|
||||
[assembly: CollectionBehavior(DisableTestParallelization = true)]
|
||||
|
||||
namespace Cleanuparr.Api.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Custom WebApplicationFactory that uses an isolated SQLite database for each test fixture.
|
||||
/// The database file is created in a temp directory so both DI and static contexts share the same data.
|
||||
/// Custom WebApplicationFactory that redirects all database contexts to an isolated temp directory
|
||||
/// </summary>
|
||||
public class CustomWebApplicationFactory : WebApplicationFactory<Program>
|
||||
{
|
||||
@@ -18,6 +21,8 @@ public class CustomWebApplicationFactory : WebApplicationFactory<Program>
|
||||
{
|
||||
_tempDir = Path.Combine(Path.GetTempPath(), $"cleanuparr-test-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_tempDir);
|
||||
|
||||
ConfigurationPathProvider.SetConfigPath(_tempDir);
|
||||
}
|
||||
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
@@ -26,26 +31,12 @@ public class CustomWebApplicationFactory : WebApplicationFactory<Program>
|
||||
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
// Remove the existing UsersContext registration
|
||||
var descriptor = services.SingleOrDefault(d => d.ServiceType == typeof(DbContextOptions<UsersContext>));
|
||||
if (descriptor != null) services.Remove(descriptor);
|
||||
|
||||
// Also remove the DbContext registration itself
|
||||
var contextDescriptor = services.SingleOrDefault(d => d.ServiceType == typeof(UsersContext));
|
||||
if (contextDescriptor != null) services.Remove(contextDescriptor);
|
||||
|
||||
var dbPath = Path.Combine(_tempDir, "users.db");
|
||||
|
||||
services.AddDbContext<UsersContext>(options =>
|
||||
// Remove all hosted services (Quartz scheduler, BackgroundJobManager) to prevent
|
||||
// Quartz.Logging.LogProvider.ResolvedLogProvider (a cached Lazy<T>) from being accessed
|
||||
foreach (var hostedService in services.Where(d => d.ServiceType == typeof(IHostedService)).ToList())
|
||||
{
|
||||
options.UseSqlite($"Data Source={dbPath}");
|
||||
});
|
||||
|
||||
// Ensure DB is created
|
||||
var sp = services.BuildServiceProvider();
|
||||
using var scope = sp.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<UsersContext>();
|
||||
db.Database.EnsureCreated();
|
||||
services.Remove(hostedService);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,423 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Cleanuparr.Infrastructure.Features.Auth;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Auth;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Shouldly;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Features.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for the OIDC account linking flow (POST /api/account/oidc/link and
|
||||
/// GET /api/account/oidc/link/callback). Uses a mock IOidcAuthService that tracks the
|
||||
/// initiatorUserId passed from StartOidcLink so OidcLinkCallback can complete the flow.
|
||||
/// </summary>
|
||||
[Collection("Auth Integration Tests")]
|
||||
[TestCaseOrderer("Cleanuparr.Api.Tests.PriorityOrderer", "Cleanuparr.Api.Tests")]
|
||||
public class AccountControllerOidcTests : IClassFixture<AccountControllerOidcTests.OidcLinkWebApplicationFactory>
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
private readonly OidcLinkWebApplicationFactory _factory;
|
||||
|
||||
// Shared across ordered tests
|
||||
private static string? _accessToken;
|
||||
|
||||
public AccountControllerOidcTests(OidcLinkWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
_client = factory.CreateClient(new WebApplicationFactoryClientOptions
|
||||
{
|
||||
AllowAutoRedirect = false
|
||||
});
|
||||
|
||||
if (_accessToken is not null)
|
||||
{
|
||||
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _accessToken);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact, TestPriority(0)]
|
||||
public async Task Setup_CreateAccountAndComplete()
|
||||
{
|
||||
var createResponse = await _client.PostAsJsonAsync("/api/auth/setup/account", new
|
||||
{
|
||||
username = "linkadmin",
|
||||
password = "LinkPassword123!"
|
||||
});
|
||||
createResponse.StatusCode.ShouldBe(HttpStatusCode.Created);
|
||||
|
||||
var completeResponse = await _client.PostAsJsonAsync("/api/auth/setup/complete", new { });
|
||||
completeResponse.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(1)]
|
||||
public async Task Login_StoreAccessToken()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/login", new
|
||||
{
|
||||
username = "linkadmin",
|
||||
password = "LinkPassword123!"
|
||||
});
|
||||
|
||||
var bodyText = await response.Content.ReadAsStringAsync();
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK, $"Login failed. Body: {bodyText}");
|
||||
|
||||
var body = JsonSerializer.Deserialize<JsonElement>(bodyText);
|
||||
body.TryGetProperty("requiresTwoFactor", out var rtf)
|
||||
.ShouldBeTrue($"Missing 'requiresTwoFactor' in body: {bodyText}");
|
||||
rtf.GetBoolean().ShouldBeFalse();
|
||||
// Tokens are nested: { "requiresTwoFactor": false, "tokens": { "accessToken": "..." } }
|
||||
_accessToken = body.GetProperty("tokens").GetProperty("accessToken").GetString();
|
||||
_accessToken.ShouldNotBeNullOrEmpty();
|
||||
|
||||
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _accessToken);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(2)]
|
||||
public async Task OidcLink_WhenOidcDisabled_ReturnsBadRequest()
|
||||
{
|
||||
var response = await _client.PostAsync("/api/account/oidc/link", null);
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
body.GetProperty("error").GetString().ShouldContain("OIDC is not enabled");
|
||||
}
|
||||
|
||||
[Fact, TestPriority(3)]
|
||||
public async Task EnableOidcConfig_ViaDirectDbUpdate()
|
||||
{
|
||||
await _factory.EnableOidcAsync();
|
||||
|
||||
var statusResponse = await _client.GetAsync("/api/auth/status");
|
||||
statusResponse.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var body = await statusResponse.Content.ReadFromJsonAsync<JsonElement>();
|
||||
body.GetProperty("oidcEnabled").GetBoolean().ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact, TestPriority(4)]
|
||||
public async Task OidcLink_WhenAuthenticated_ReturnsAuthorizationUrl()
|
||||
{
|
||||
var response = await _client.PostAsync("/api/account/oidc/link", null);
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
var authUrl = body.GetProperty("authorizationUrl").GetString();
|
||||
authUrl.ShouldNotBeNullOrEmpty();
|
||||
authUrl.ShouldContain("authorize");
|
||||
}
|
||||
|
||||
[Fact, TestPriority(5)]
|
||||
public async Task OidcLinkCallback_WithErrorParam_RedirectsToSettingsWithError()
|
||||
{
|
||||
var response = await _client.GetAsync("/api/account/oidc/link/callback?error=access_denied");
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Redirect);
|
||||
var location = response.Headers.Location?.ToString();
|
||||
location.ShouldNotBeNull();
|
||||
location.ShouldContain("/settings/account");
|
||||
location.ShouldContain("oidc_link_error=failed");
|
||||
}
|
||||
|
||||
[Fact, TestPriority(6)]
|
||||
public async Task OidcLinkCallback_MissingCodeOrState_RedirectsWithError()
|
||||
{
|
||||
var noParams = await _client.GetAsync("/api/account/oidc/link/callback");
|
||||
noParams.StatusCode.ShouldBe(HttpStatusCode.Redirect);
|
||||
noParams.Headers.Location?.ToString().ShouldContain("oidc_link_error=failed");
|
||||
|
||||
var onlyCode = await _client.GetAsync("/api/account/oidc/link/callback?code=some-code");
|
||||
onlyCode.StatusCode.ShouldBe(HttpStatusCode.Redirect);
|
||||
onlyCode.Headers.Location?.ToString().ShouldContain("oidc_link_error=failed");
|
||||
}
|
||||
|
||||
[Fact, TestPriority(7)]
|
||||
public async Task OidcLinkCallback_ValidFlow_SavesSubjectAndRedirectsToSuccess()
|
||||
{
|
||||
// First trigger StartOidcLink so the mock captures the initiatorUserId
|
||||
var linkResponse = await _client.PostAsync("/api/account/oidc/link", null);
|
||||
linkResponse.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
// Now simulate the IdP callback with the mock's success state
|
||||
var callbackResponse = await _client.GetAsync(
|
||||
$"/api/account/oidc/link/callback?code=valid-code&state={MockOidcAuthService.LinkSuccessState}");
|
||||
|
||||
callbackResponse.StatusCode.ShouldBe(HttpStatusCode.Redirect);
|
||||
var location = callbackResponse.Headers.Location?.ToString();
|
||||
location.ShouldNotBeNull();
|
||||
location.ShouldContain("/settings/account");
|
||||
location.ShouldContain("oidc_link=success");
|
||||
location.ShouldNotContain("oidc_link_error");
|
||||
|
||||
// Verify the subject was saved to config
|
||||
var savedSubject = await _factory.GetAuthorizedSubjectAsync();
|
||||
savedSubject.ShouldBe(MockOidcAuthService.LinkedSubject);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(8)]
|
||||
public async Task OidcLinkCallback_NoInitiatorUserId_RedirectsWithError()
|
||||
{
|
||||
var response = await _client.GetAsync(
|
||||
$"/api/account/oidc/link/callback?code=valid-code&state={MockOidcAuthService.NoInitiatorState}");
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Redirect);
|
||||
var location = response.Headers.Location?.ToString();
|
||||
location.ShouldNotBeNull();
|
||||
location.ShouldContain("oidc_link_error=failed");
|
||||
}
|
||||
|
||||
[Fact, TestPriority(9)]
|
||||
public async Task OidcLink_WhenUnauthenticated_ReturnsUnauthorized()
|
||||
{
|
||||
// Create a fresh unauthenticated client
|
||||
var unauthClient = _factory.CreateClient(new WebApplicationFactoryClientOptions
|
||||
{
|
||||
AllowAutoRedirect = false
|
||||
});
|
||||
|
||||
var response = await unauthClient.PostAsync("/api/account/oidc/link", null);
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
|
||||
}
|
||||
|
||||
#region Exclusive Mode
|
||||
|
||||
[Fact, TestPriority(10)]
|
||||
public async Task EnableExclusiveMode_ViaDirectDbUpdate()
|
||||
{
|
||||
await _factory.SetOidcExclusiveModeAsync(true);
|
||||
|
||||
var response = await _client.GetAsync("/api/auth/status");
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
body.GetProperty("oidcExclusiveMode").GetBoolean().ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact, TestPriority(11)]
|
||||
public async Task ChangePassword_Blocked_WhenExclusiveModeActive()
|
||||
{
|
||||
var response = await _client.PutAsJsonAsync("/api/account/password", new
|
||||
{
|
||||
currentPassword = "LinkPassword123!",
|
||||
newPassword = "NewPassword456!"
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Forbidden);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(12)]
|
||||
public async Task PlexLink_Blocked_WhenExclusiveModeActive()
|
||||
{
|
||||
var response = await _client.PostAsync("/api/account/plex/link", null);
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Forbidden);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(13)]
|
||||
public async Task PlexUnlink_Blocked_WhenExclusiveModeActive()
|
||||
{
|
||||
var response = await _client.DeleteAsync("/api/account/plex/link");
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Forbidden);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(14)]
|
||||
public async Task OidcConfigUpdate_StillWorks_WhenExclusiveModeActive()
|
||||
{
|
||||
var response = await _client.PutAsJsonAsync("/api/account/oidc", new
|
||||
{
|
||||
enabled = true,
|
||||
issuerUrl = "https://mock-oidc-provider.test",
|
||||
clientId = "test-client",
|
||||
clientSecret = "test-secret",
|
||||
scopes = "openid profile email",
|
||||
authorizedSubject = MockOidcAuthService.LinkedSubject,
|
||||
providerName = "TestProvider",
|
||||
redirectUrl = "",
|
||||
exclusiveMode = true
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(15)]
|
||||
public async Task OidcUnlink_ResetsExclusiveMode()
|
||||
{
|
||||
var response = await _client.DeleteAsync("/api/account/oidc/link");
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
// Verify exclusive mode was reset
|
||||
var exclusiveMode = await _factory.GetExclusiveModeAsync();
|
||||
exclusiveMode.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact, TestPriority(16)]
|
||||
public async Task DisableExclusiveMode_PasswordChangeWorks_Again()
|
||||
{
|
||||
// Re-enable OIDC with a linked subject but without exclusive mode
|
||||
await _factory.EnableOidcAsync();
|
||||
await _factory.SetOidcExclusiveModeAsync(false);
|
||||
|
||||
var response = await _client.PutAsJsonAsync("/api/account/password", new
|
||||
{
|
||||
currentPassword = "LinkPassword123!",
|
||||
newPassword = "NewPassword789!"
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Test Infrastructure
|
||||
|
||||
public class OidcLinkWebApplicationFactory : CustomWebApplicationFactory
|
||||
{
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
base.ConfigureWebHost(builder);
|
||||
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
var oidcDescriptor = services.SingleOrDefault(d => d.ServiceType == typeof(IOidcAuthService));
|
||||
if (oidcDescriptor != null) services.Remove(oidcDescriptor);
|
||||
|
||||
services.AddSingleton<IOidcAuthService, MockOidcAuthService>();
|
||||
});
|
||||
}
|
||||
|
||||
public async Task EnableOidcAsync()
|
||||
{
|
||||
using var scope = Services.CreateScope();
|
||||
var usersContext = scope.ServiceProvider.GetRequiredService<UsersContext>();
|
||||
|
||||
var user = await usersContext.Users.FirstOrDefaultAsync();
|
||||
if (user is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
user.Oidc = new OidcConfig
|
||||
{
|
||||
Enabled = true,
|
||||
IssuerUrl = "https://mock-oidc-provider.test",
|
||||
ClientId = "test-client",
|
||||
ClientSecret = "test-secret",
|
||||
Scopes = "openid profile email",
|
||||
AuthorizedSubject = "initial-subject",
|
||||
ProviderName = "TestProvider"
|
||||
};
|
||||
|
||||
await usersContext.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task<string?> GetAuthorizedSubjectAsync()
|
||||
{
|
||||
using var scope = Services.CreateScope();
|
||||
var usersContext = scope.ServiceProvider.GetRequiredService<UsersContext>();
|
||||
|
||||
var user = await usersContext.Users.AsNoTracking().FirstOrDefaultAsync();
|
||||
return user?.Oidc.AuthorizedSubject;
|
||||
}
|
||||
|
||||
public async Task SetOidcExclusiveModeAsync(bool enabled)
|
||||
{
|
||||
using var scope = Services.CreateScope();
|
||||
var usersContext = scope.ServiceProvider.GetRequiredService<UsersContext>();
|
||||
|
||||
var user = await usersContext.Users.FirstOrDefaultAsync();
|
||||
if (user is not null)
|
||||
{
|
||||
user.Oidc.ExclusiveMode = enabled;
|
||||
await usersContext.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> GetExclusiveModeAsync()
|
||||
{
|
||||
using var scope = Services.CreateScope();
|
||||
var usersContext = scope.ServiceProvider.GetRequiredService<UsersContext>();
|
||||
|
||||
var user = await usersContext.Users.AsNoTracking().FirstOrDefaultAsync();
|
||||
return user?.Oidc.ExclusiveMode ?? false;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private sealed class MockOidcAuthService : IOidcAuthService
|
||||
{
|
||||
public const string LinkSuccessState = "mock-link-success-state";
|
||||
public const string NoInitiatorState = "mock-no-initiator-state";
|
||||
public const string LinkedSubject = "newly-linked-subject-123";
|
||||
|
||||
private string? _lastInitiatorUserId;
|
||||
private readonly ConcurrentDictionary<string, OidcTokenExchangeResult> _oneTimeCodes = new();
|
||||
|
||||
public Task<OidcAuthorizationResult> StartAuthorization(string redirectUri, string? initiatorUserId = null)
|
||||
{
|
||||
_lastInitiatorUserId = initiatorUserId;
|
||||
return Task.FromResult(new OidcAuthorizationResult
|
||||
{
|
||||
AuthorizationUrl = $"https://mock-oidc-provider.test/authorize?state={LinkSuccessState}",
|
||||
State = LinkSuccessState
|
||||
});
|
||||
}
|
||||
|
||||
public Task<OidcCallbackResult> HandleCallback(string code, string state, string redirectUri)
|
||||
{
|
||||
if (state == LinkSuccessState)
|
||||
{
|
||||
return Task.FromResult(new OidcCallbackResult
|
||||
{
|
||||
Success = true,
|
||||
Subject = LinkedSubject,
|
||||
PreferredUsername = "linkuser",
|
||||
Email = "link@example.com",
|
||||
InitiatorUserId = _lastInitiatorUserId
|
||||
});
|
||||
}
|
||||
|
||||
if (state == NoInitiatorState)
|
||||
{
|
||||
return Task.FromResult(new OidcCallbackResult
|
||||
{
|
||||
Success = true,
|
||||
Subject = LinkedSubject,
|
||||
InitiatorUserId = null // No initiator — controller should redirect with error
|
||||
});
|
||||
}
|
||||
|
||||
return Task.FromResult(new OidcCallbackResult
|
||||
{
|
||||
Success = false,
|
||||
Error = "Invalid or expired OIDC state"
|
||||
});
|
||||
}
|
||||
|
||||
public string StoreOneTimeCode(string accessToken, string refreshToken, int expiresIn)
|
||||
{
|
||||
var code = Guid.NewGuid().ToString("N");
|
||||
_oneTimeCodes.TryAdd(code, new OidcTokenExchangeResult
|
||||
{
|
||||
AccessToken = accessToken,
|
||||
RefreshToken = refreshToken,
|
||||
ExpiresIn = expiresIn
|
||||
});
|
||||
return code;
|
||||
}
|
||||
|
||||
public OidcTokenExchangeResult? ExchangeOneTimeCode(string code) =>
|
||||
_oneTimeCodes.TryRemove(code, out var result) ? result : null;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -10,6 +10,7 @@ namespace Cleanuparr.Api.Tests.Features.Auth;
|
||||
/// Uses a single shared factory to avoid static state conflicts.
|
||||
/// Tests are ordered to build on each other: setup → login → protected endpoints.
|
||||
/// </summary>
|
||||
[Collection("Auth Integration Tests")]
|
||||
[TestCaseOrderer("Cleanuparr.Api.Tests.PriorityOrderer", "Cleanuparr.Api.Tests")]
|
||||
public class AuthControllerTests : IClassFixture<CustomWebApplicationFactory>
|
||||
{
|
||||
@@ -31,6 +32,16 @@ public class AuthControllerTests : IClassFixture<CustomWebApplicationFactory>
|
||||
body.GetProperty("setupCompleted").GetBoolean().ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact, TestPriority(0)]
|
||||
public async Task AuthEndpoints_AlwaysReturnNoCacheHeaders()
|
||||
{
|
||||
var response = await _client.GetAsync("/api/auth/status");
|
||||
|
||||
response.Headers.CacheControl.ShouldNotBeNull();
|
||||
response.Headers.CacheControl!.NoCache.ShouldBeTrue();
|
||||
response.Headers.CacheControl!.NoStore.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact, TestPriority(1)]
|
||||
public async Task Setup_CreateAccount_ReturnsCreated()
|
||||
{
|
||||
@@ -178,6 +189,85 @@ public class AuthControllerTests : IClassFixture<CustomWebApplicationFactory>
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(11)]
|
||||
public async Task Setup_2FAGenerate_AfterCompletion_IsBlocked()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/setup/2fa/generate", new { });
|
||||
|
||||
// Blocked by middleware (403) or controller defense-in-depth (409)
|
||||
new[] { HttpStatusCode.Forbidden, HttpStatusCode.Conflict }
|
||||
.ShouldContain(response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(12)]
|
||||
public async Task Setup_PlexPin_AfterCompletion_IsBlocked()
|
||||
{
|
||||
var response = await _client.PostAsync("/api/auth/setup/plex/pin", null);
|
||||
|
||||
// Blocked by middleware (403) or controller defense-in-depth (409)
|
||||
new[] { HttpStatusCode.Forbidden, HttpStatusCode.Conflict }
|
||||
.ShouldContain(response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(13)]
|
||||
public async Task Setup_Complete_AfterCompletion_IsBlocked()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/setup/complete", new { });
|
||||
|
||||
// Blocked by middleware (403) or controller defense-in-depth (409)
|
||||
new[] { HttpStatusCode.Forbidden, HttpStatusCode.Conflict }
|
||||
.ShouldContain(response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(14)]
|
||||
public async Task Login_NotBlockedByMiddleware_AfterSetupEndpointsBlocked()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/login", new
|
||||
{
|
||||
username = "admin",
|
||||
password = "TestPassword123!"
|
||||
});
|
||||
|
||||
// Login endpoint must NOT be blocked by the middleware (403).
|
||||
// It may return OK (200) or TooManyRequests (429) due to brute force lockout from earlier tests.
|
||||
response.StatusCode.ShouldNotBe(HttpStatusCode.Forbidden);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(15)]
|
||||
public async Task AuthStatus_StillWorks_AfterSetupEndpointsBlocked()
|
||||
{
|
||||
var response = await _client.GetAsync("/api/auth/status");
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
body.GetProperty("setupCompleted").GetBoolean().ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact, TestPriority(16)]
|
||||
public async Task OidcExchange_WithNonexistentCode_ReturnsNotFound()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/oidc/exchange", new
|
||||
{
|
||||
code = "nonexistent-one-time-code"
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.NotFound);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(17)]
|
||||
public async Task AuthStatus_IncludesOidcFields()
|
||||
{
|
||||
var response = await _client.GetAsync("/api/auth/status");
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
// Verify OIDC fields exist in the response (values depend on shared static DB state)
|
||||
body.TryGetProperty("oidcEnabled", out _).ShouldBeTrue();
|
||||
body.TryGetProperty("oidcProviderName", out _).ShouldBeTrue();
|
||||
}
|
||||
|
||||
#region TOTP helpers
|
||||
|
||||
private static string _totpSecret = "";
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using Shouldly;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Features.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Tests that the login endpoint always runs BCrypt verification regardless of
|
||||
/// username validity, preventing timing-based username enumeration.
|
||||
/// </summary>
|
||||
[Collection("Login Timing Tests")]
|
||||
[TestCaseOrderer("Cleanuparr.Api.Tests.PriorityOrderer", "Cleanuparr.Api.Tests")]
|
||||
public class LoginTimingTests : IClassFixture<TimingTestWebApplicationFactory>
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
private readonly TimingTestWebApplicationFactory _factory;
|
||||
|
||||
public LoginTimingTests(TimingTestWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
_client = factory.CreateClient();
|
||||
}
|
||||
|
||||
[Fact, TestPriority(0)]
|
||||
public async Task Login_NoUserExists_StillCallsPasswordVerification()
|
||||
{
|
||||
_factory.TrackingPasswordService.Reset();
|
||||
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/login", new
|
||||
{
|
||||
username = "nouser",
|
||||
password = "SomePassword123!"
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
|
||||
_factory.TrackingPasswordService.VerifyPasswordCallCount.ShouldBeGreaterThanOrEqualTo(1);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(1)]
|
||||
public async Task Setup_CreateAccountAndComplete()
|
||||
{
|
||||
var createResponse = await _client.PostAsJsonAsync("/api/auth/setup/account", new
|
||||
{
|
||||
username = "timingtest",
|
||||
password = "TimingTestPassword123!"
|
||||
});
|
||||
createResponse.StatusCode.ShouldBe(HttpStatusCode.Created);
|
||||
|
||||
var completeResponse = await _client.PostAsJsonAsync("/api/auth/setup/complete", new { });
|
||||
completeResponse.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(2)]
|
||||
public async Task Login_ValidUsername_CallsPasswordVerification()
|
||||
{
|
||||
_factory.TrackingPasswordService.Reset();
|
||||
|
||||
await _client.PostAsJsonAsync("/api/auth/login", new
|
||||
{
|
||||
username = "timingtest",
|
||||
password = "TimingTestPassword123!"
|
||||
});
|
||||
|
||||
_factory.TrackingPasswordService.VerifyPasswordCallCount.ShouldBeGreaterThanOrEqualTo(1);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(3)]
|
||||
public async Task Login_NonexistentUsername_StillCallsPasswordVerification()
|
||||
{
|
||||
_factory.TrackingPasswordService.Reset();
|
||||
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/login", new
|
||||
{
|
||||
username = "doesnotexist",
|
||||
password = "SomePassword123!"
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
|
||||
_factory.TrackingPasswordService.VerifyPasswordCallCount.ShouldBeGreaterThanOrEqualTo(1);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(4)]
|
||||
public async Task Login_LockedOutUser_StillCallsPasswordVerification()
|
||||
{
|
||||
// Trigger lockout by making several failed login attempts
|
||||
for (var i = 0; i < 5; i++)
|
||||
{
|
||||
await _client.PostAsJsonAsync("/api/auth/login", new
|
||||
{
|
||||
username = "timingtest",
|
||||
password = "WrongPassword!"
|
||||
});
|
||||
}
|
||||
|
||||
_factory.TrackingPasswordService.Reset();
|
||||
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/login", new
|
||||
{
|
||||
username = "timingtest",
|
||||
password = "WrongPassword!"
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.TooManyRequests);
|
||||
_factory.TrackingPasswordService.VerifyPasswordCallCount.ShouldBeGreaterThanOrEqualTo(1);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(5)]
|
||||
public async Task Login_TimingConsistency_InvalidAndValidUsernamesTakeSimilarTime()
|
||||
{
|
||||
const int iterations = 10;
|
||||
|
||||
// Warm up the server and BCrypt static init
|
||||
await _client.PostAsJsonAsync("/api/auth/login", new
|
||||
{
|
||||
username = "warmup",
|
||||
password = "WarmupPassword123!"
|
||||
});
|
||||
|
||||
var invalidTimings = new List<long>(iterations);
|
||||
var validTimings = new List<long>(iterations);
|
||||
|
||||
for (var i = 0; i < iterations; i++)
|
||||
{
|
||||
// Alternate to avoid ordering bias
|
||||
var invalidSw = Stopwatch.StartNew();
|
||||
await _client.PostAsJsonAsync("/api/auth/login", new
|
||||
{
|
||||
username = $"nonexistent_{i}",
|
||||
password = "SomePassword123!"
|
||||
});
|
||||
invalidSw.Stop();
|
||||
invalidTimings.Add(invalidSw.ElapsedMilliseconds);
|
||||
|
||||
var validSw = Stopwatch.StartNew();
|
||||
await _client.PostAsJsonAsync("/api/auth/login", new
|
||||
{
|
||||
username = "timingtest",
|
||||
password = "WrongPasswordForTiming!"
|
||||
});
|
||||
validSw.Stop();
|
||||
validTimings.Add(validSw.ElapsedMilliseconds);
|
||||
}
|
||||
|
||||
var invalidMedian = Median(invalidTimings);
|
||||
var validMedian = Median(validTimings);
|
||||
|
||||
// The invalid-username path must not be suspiciously fast
|
||||
invalidMedian.ShouldBeGreaterThan(50,
|
||||
$"Non-existent username median too fast ({invalidMedian}ms) — BCrypt may have been skipped");
|
||||
|
||||
// Medians should be in the same ballpark
|
||||
var ratio = invalidMedian > validMedian
|
||||
? (double)invalidMedian / validMedian
|
||||
: (double)validMedian / invalidMedian;
|
||||
|
||||
ratio.ShouldBeLessThan(3.0,
|
||||
$"Timing difference too large: invalid median={invalidMedian}ms, valid median={validMedian}ms (ratio={ratio:F1}x)");
|
||||
}
|
||||
|
||||
private static long Median(List<long> values)
|
||||
{
|
||||
values.Sort();
|
||||
var mid = values.Count / 2;
|
||||
return values.Count % 2 == 0
|
||||
? (values[mid - 1] + values[mid]) / 2
|
||||
: values[mid];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,628 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Cleanuparr.Infrastructure.Features.Auth;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Auth;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Shouldly;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Features.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for the OIDC authentication flow.
|
||||
/// Uses a mock IOidcAuthService to simulate IdP behavior.
|
||||
/// Tests are ordered to build on each other: setup → enable OIDC → test flow.
|
||||
/// </summary>
|
||||
[Collection("Auth Integration Tests")]
|
||||
[TestCaseOrderer("Cleanuparr.Api.Tests.PriorityOrderer", "Cleanuparr.Api.Tests")]
|
||||
public class OidcAuthControllerTests : IClassFixture<OidcAuthControllerTests.OidcWebApplicationFactory>
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
private readonly OidcWebApplicationFactory _factory;
|
||||
|
||||
public OidcAuthControllerTests(OidcWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
_client = factory.CreateClient(new WebApplicationFactoryClientOptions
|
||||
{
|
||||
AllowAutoRedirect = false // We want to inspect redirects
|
||||
});
|
||||
}
|
||||
|
||||
[Fact, TestPriority(0)]
|
||||
public async Task OidcStart_BeforeSetup_ReturnsBadRequest()
|
||||
{
|
||||
var response = await _client.PostAsync("/api/auth/oidc/start", null);
|
||||
|
||||
// OIDC start is on /api/auth/ path (not blocked by SetupGuardMiddleware)
|
||||
// but the controller returns BadRequest because OIDC is not configured
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(1)]
|
||||
public async Task Setup_CreateAccountAndComplete()
|
||||
{
|
||||
// Create account
|
||||
var createResponse = await _client.PostAsJsonAsync("/api/auth/setup/account", new
|
||||
{
|
||||
username = "admin",
|
||||
password = "TestPassword123!"
|
||||
});
|
||||
createResponse.StatusCode.ShouldBe(HttpStatusCode.Created);
|
||||
|
||||
// Complete setup (skip 2FA for this test suite)
|
||||
var completeResponse = await _client.PostAsJsonAsync("/api/auth/setup/complete", new { });
|
||||
completeResponse.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(2)]
|
||||
public async Task OidcStart_WhenDisabled_ReturnsBadRequest()
|
||||
{
|
||||
var response = await _client.PostAsync("/api/auth/oidc/start", null);
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
body.GetProperty("error").GetString()!.ShouldContain("OIDC is not enabled");
|
||||
}
|
||||
|
||||
[Fact, TestPriority(3)]
|
||||
public async Task OidcExchange_WhenDisabled_ReturnsNotFound()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/oidc/exchange", new
|
||||
{
|
||||
code = "some-random-code"
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.NotFound);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(4)]
|
||||
public async Task OidcCallback_WithErrorParam_RedirectsToLoginWithError()
|
||||
{
|
||||
var response = await _client.GetAsync("/api/auth/oidc/callback?error=access_denied");
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Redirect);
|
||||
var location = response.Headers.Location?.ToString();
|
||||
location.ShouldNotBeNull();
|
||||
location.ShouldContain("/auth/login");
|
||||
location.ShouldContain("oidc_error=provider_error");
|
||||
}
|
||||
|
||||
[Fact, TestPriority(5)]
|
||||
public async Task OidcCallback_WithoutCodeOrState_RedirectsToLoginWithError()
|
||||
{
|
||||
var response = await _client.GetAsync("/api/auth/oidc/callback");
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Redirect);
|
||||
var location = response.Headers.Location?.ToString();
|
||||
location.ShouldNotBeNull();
|
||||
location.ShouldContain("oidc_error=invalid_request");
|
||||
}
|
||||
|
||||
[Fact, TestPriority(6)]
|
||||
public async Task OidcCallback_WithOnlyCode_RedirectsToLoginWithError()
|
||||
{
|
||||
var response = await _client.GetAsync("/api/auth/oidc/callback?code=some-code");
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Redirect);
|
||||
var location = response.Headers.Location?.ToString();
|
||||
location.ShouldNotBeNull();
|
||||
location.ShouldContain("oidc_error=invalid_request");
|
||||
}
|
||||
|
||||
[Fact, TestPriority(7)]
|
||||
public async Task OidcCallback_WithInvalidState_RedirectsToLoginWithError()
|
||||
{
|
||||
// Even with code and state, if the state is invalid the mock will return failure
|
||||
var response = await _client.GetAsync("/api/auth/oidc/callback?code=some-code&state=invalid-state");
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Redirect);
|
||||
var location = response.Headers.Location?.ToString();
|
||||
location.ShouldNotBeNull();
|
||||
location.ShouldContain("oidc_error=authentication_failed");
|
||||
}
|
||||
|
||||
[Fact, TestPriority(8)]
|
||||
public async Task EnableOidcConfig_ViaDirectDbUpdate()
|
||||
{
|
||||
// Simulate enabling OIDC via direct DB manipulation (since we'd normally do this through settings UI)
|
||||
await _factory.EnableOidcAsync();
|
||||
|
||||
// Verify auth status reflects OIDC enabled
|
||||
var response = await _client.GetAsync("/api/auth/status");
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
body.GetProperty("oidcEnabled").GetBoolean().ShouldBeTrue();
|
||||
body.GetProperty("oidcProviderName").GetString().ShouldBe("TestProvider");
|
||||
}
|
||||
|
||||
[Fact, TestPriority(9)]
|
||||
public async Task OidcStart_WhenEnabled_ReturnsAuthorizationUrl()
|
||||
{
|
||||
var response = await _client.PostAsync("/api/auth/oidc/start", null);
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
var authUrl = body.GetProperty("authorizationUrl").GetString();
|
||||
authUrl.ShouldNotBeNullOrEmpty();
|
||||
authUrl.ShouldContain("authorize");
|
||||
}
|
||||
|
||||
[Fact, TestPriority(10)]
|
||||
public async Task OidcCallback_ValidFlow_RedirectsWithOneTimeCode()
|
||||
{
|
||||
// Use the mock's valid state to simulate a successful callback
|
||||
var response = await _client.GetAsync(
|
||||
$"/api/auth/oidc/callback?code=valid-auth-code&state={MockOidcAuthService.ValidState}");
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Redirect);
|
||||
var location = response.Headers.Location?.ToString();
|
||||
location.ShouldNotBeNull();
|
||||
location.ShouldContain("/auth/oidc/callback");
|
||||
location.ShouldContain("code=");
|
||||
// Should NOT contain oidc_error
|
||||
location.ShouldNotContain("oidc_error");
|
||||
}
|
||||
|
||||
[Fact, TestPriority(11)]
|
||||
public async Task OidcExchange_ValidOneTimeCode_ReturnsTokens()
|
||||
{
|
||||
// First, trigger a valid callback to get a one-time code
|
||||
var callbackResponse = await _client.GetAsync(
|
||||
$"/api/auth/oidc/callback?code=valid-auth-code&state={MockOidcAuthService.ValidState}");
|
||||
callbackResponse.StatusCode.ShouldBe(HttpStatusCode.Redirect);
|
||||
|
||||
var location = callbackResponse.Headers.Location?.ToString();
|
||||
location.ShouldNotBeNull();
|
||||
|
||||
// Extract the one-time code from the redirect URL
|
||||
var uri = new Uri("http://localhost" + location);
|
||||
var queryParams = System.Web.HttpUtility.ParseQueryString(uri.Query);
|
||||
var oneTimeCode = queryParams["code"];
|
||||
oneTimeCode.ShouldNotBeNullOrEmpty();
|
||||
|
||||
// Exchange the one-time code for tokens
|
||||
var exchangeResponse = await _client.PostAsJsonAsync("/api/auth/oidc/exchange", new
|
||||
{
|
||||
code = oneTimeCode
|
||||
});
|
||||
|
||||
exchangeResponse.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var body = await exchangeResponse.Content.ReadFromJsonAsync<JsonElement>();
|
||||
body.GetProperty("accessToken").GetString().ShouldNotBeNullOrEmpty();
|
||||
body.GetProperty("refreshToken").GetString().ShouldNotBeNullOrEmpty();
|
||||
body.GetProperty("expiresIn").GetInt32().ShouldBeGreaterThan(0);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(12)]
|
||||
public async Task OidcExchange_SameCodeTwice_SecondFails()
|
||||
{
|
||||
// First, trigger a valid callback
|
||||
var callbackResponse = await _client.GetAsync(
|
||||
$"/api/auth/oidc/callback?code=valid-auth-code&state={MockOidcAuthService.ValidState}");
|
||||
var location = callbackResponse.Headers.Location?.ToString()!;
|
||||
var uri = new Uri("http://localhost" + location);
|
||||
var queryParams = System.Web.HttpUtility.ParseQueryString(uri.Query);
|
||||
var oneTimeCode = queryParams["code"]!;
|
||||
|
||||
// First exchange succeeds
|
||||
var response1 = await _client.PostAsJsonAsync("/api/auth/oidc/exchange", new { code = oneTimeCode });
|
||||
response1.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
// Second exchange with same code fails
|
||||
var response2 = await _client.PostAsJsonAsync("/api/auth/oidc/exchange", new { code = oneTimeCode });
|
||||
response2.StatusCode.ShouldBe(HttpStatusCode.NotFound);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(13)]
|
||||
public async Task OidcExchange_InvalidCode_ReturnsNotFound()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/oidc/exchange", new
|
||||
{
|
||||
code = "completely-invalid-code"
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.NotFound);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(14)]
|
||||
public async Task OidcCallback_UnauthorizedSubject_RedirectsWithError()
|
||||
{
|
||||
// Use the mock's state that returns a different subject
|
||||
var response = await _client.GetAsync(
|
||||
$"/api/auth/oidc/callback?code=valid-auth-code&state={MockOidcAuthService.WrongSubjectState}");
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Redirect);
|
||||
var location = response.Headers.Location?.ToString();
|
||||
location.ShouldNotBeNull();
|
||||
location.ShouldContain("oidc_error=unauthorized");
|
||||
}
|
||||
|
||||
[Fact, TestPriority(15)]
|
||||
public async Task AuthStatus_IncludesOidcFields()
|
||||
{
|
||||
var response = await _client.GetAsync("/api/auth/status");
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
body.GetProperty("setupCompleted").GetBoolean().ShouldBeTrue();
|
||||
body.GetProperty("oidcEnabled").GetBoolean().ShouldBeTrue();
|
||||
body.GetProperty("oidcProviderName").GetString().ShouldBe("TestProvider");
|
||||
}
|
||||
|
||||
[Fact, TestPriority(16)]
|
||||
public async Task PasswordLogin_StillWorks_AfterOidcEnabled()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/login", new
|
||||
{
|
||||
username = "admin",
|
||||
password = "TestPassword123!"
|
||||
});
|
||||
|
||||
// Should succeed (no 2FA since we skipped it in setup)
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
// No 2FA, so should have tokens directly
|
||||
body.GetProperty("requiresTwoFactor").GetBoolean().ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact, TestPriority(17)]
|
||||
public async Task OidcStatus_WhenSubjectCleared_StillEnabled()
|
||||
{
|
||||
// Clearing the authorized subject should NOT disable OIDC — it just means any user can log in
|
||||
await _factory.SetOidcAuthorizedSubjectAsync("");
|
||||
|
||||
var response = await _client.GetAsync("/api/auth/status");
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
body.GetProperty("oidcEnabled").GetBoolean().ShouldBeTrue();
|
||||
|
||||
// Restore for subsequent tests
|
||||
await _factory.SetOidcAuthorizedSubjectAsync(MockOidcAuthService.AuthorizedSubject);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(17)]
|
||||
public async Task OidcStatus_WhenMissingIssuerUrl_ReturnsFalse()
|
||||
{
|
||||
// OIDC should be disabled when essential config (IssuerUrl) is missing
|
||||
await _factory.SetOidcIssuerUrlAsync("");
|
||||
|
||||
var response = await _client.GetAsync("/api/auth/status");
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
body.GetProperty("oidcEnabled").GetBoolean().ShouldBeFalse();
|
||||
|
||||
// Restore for subsequent tests
|
||||
await _factory.SetOidcIssuerUrlAsync("https://mock-oidc-provider.test");
|
||||
}
|
||||
|
||||
[Fact, TestPriority(17)]
|
||||
public async Task OidcCallback_WithoutLinkedSubject_AllowsAnyUser()
|
||||
{
|
||||
// Clear the authorized subject — any OIDC user should be allowed
|
||||
await _factory.SetOidcAuthorizedSubjectAsync("");
|
||||
|
||||
// Use the "wrong subject" state — this returns a different subject than the authorized one
|
||||
// With no linked subject, it should still succeed
|
||||
var response = await _client.GetAsync(
|
||||
$"/api/auth/oidc/callback?code=valid-auth-code&state={MockOidcAuthService.WrongSubjectState}");
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Redirect);
|
||||
var location = response.Headers.Location?.ToString();
|
||||
location.ShouldNotBeNull();
|
||||
location.ShouldContain("code=");
|
||||
location.ShouldNotContain("oidc_error");
|
||||
|
||||
// Restore for subsequent tests
|
||||
await _factory.SetOidcAuthorizedSubjectAsync(MockOidcAuthService.AuthorizedSubject);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(18)]
|
||||
public async Task OidcExchange_RandomCode_ReturnsNotFound()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/oidc/exchange", new
|
||||
{
|
||||
code = "completely-random-nonexistent-code"
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.NotFound);
|
||||
}
|
||||
|
||||
#region Exclusive Mode
|
||||
|
||||
[Fact, TestPriority(19)]
|
||||
public async Task EnableExclusiveMode_AuthStatusReflectsIt()
|
||||
{
|
||||
await _factory.SetOidcExclusiveModeAsync(true);
|
||||
|
||||
var response = await _client.GetAsync("/api/auth/status");
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
body.GetProperty("oidcExclusiveMode").GetBoolean().ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact, TestPriority(20)]
|
||||
public async Task PasswordLogin_Blocked_WhenExclusiveModeActive()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/login", new
|
||||
{
|
||||
username = "admin",
|
||||
password = "TestPassword123!"
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Forbidden);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(21)]
|
||||
public async Task TwoFactorLogin_Blocked_WhenExclusiveModeActive()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/login/2fa", new
|
||||
{
|
||||
loginToken = "some-token",
|
||||
code = "123456"
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Forbidden);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(22)]
|
||||
public async Task PlexLoginPin_Blocked_WhenExclusiveModeActive()
|
||||
{
|
||||
var response = await _client.PostAsync("/api/auth/login/plex/pin", null);
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Forbidden);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(23)]
|
||||
public async Task PlexLoginVerify_Blocked_WhenExclusiveModeActive()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/login/plex/verify", new
|
||||
{
|
||||
pinId = 12345
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Forbidden);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(24)]
|
||||
public async Task OidcStart_StillWorks_WhenExclusiveModeActive()
|
||||
{
|
||||
var response = await _client.PostAsync("/api/auth/oidc/start", null);
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
body.GetProperty("authorizationUrl").GetString().ShouldNotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact, TestPriority(25)]
|
||||
public async Task OidcCallback_StillWorks_WhenExclusiveModeActive()
|
||||
{
|
||||
var response = await _client.GetAsync(
|
||||
$"/api/auth/oidc/callback?code=valid-auth-code&state={MockOidcAuthService.ValidState}");
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Redirect);
|
||||
var location = response.Headers.Location?.ToString();
|
||||
location.ShouldNotBeNull();
|
||||
location.ShouldContain("code=");
|
||||
location.ShouldNotContain("oidc_error");
|
||||
}
|
||||
|
||||
[Fact, TestPriority(26)]
|
||||
public async Task DisableExclusiveMode_PasswordLoginWorks_Again()
|
||||
{
|
||||
await _factory.SetOidcExclusiveModeAsync(false);
|
||||
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/login", new
|
||||
{
|
||||
username = "admin",
|
||||
password = "TestPassword123!"
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Test Infrastructure
|
||||
|
||||
/// <summary>
|
||||
/// Custom factory that replaces IOidcAuthService with a mock for testing.
|
||||
/// </summary>
|
||||
public class OidcWebApplicationFactory : WebApplicationFactory<Program>
|
||||
{
|
||||
private readonly string _tempDir;
|
||||
|
||||
public OidcWebApplicationFactory()
|
||||
{
|
||||
_tempDir = Path.Combine(Path.GetTempPath(), $"cleanuparr-oidc-test-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_tempDir);
|
||||
|
||||
// Redirect all database contexts to this factory's temp directory.
|
||||
ConfigurationPathProvider.SetConfigPath(_tempDir);
|
||||
}
|
||||
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
builder.UseEnvironment("Testing");
|
||||
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
// Replace IOidcAuthService with mock
|
||||
var oidcDescriptor = services.SingleOrDefault(d => d.ServiceType == typeof(IOidcAuthService));
|
||||
if (oidcDescriptor != null) services.Remove(oidcDescriptor);
|
||||
|
||||
services.AddSingleton<IOidcAuthService, MockOidcAuthService>();
|
||||
|
||||
// Remove all hosted services (Quartz scheduler, BackgroundJobManager) to prevent
|
||||
// Quartz.Logging.LogProvider.ResolvedLogProvider (a cached Lazy<T>) from being accessed
|
||||
// with a disposed ILoggerFactory from the previous factory lifecycle.
|
||||
// Auth tests don't depend on background job scheduling, so this is safe.
|
||||
foreach (var hostedService in services.Where(d => d.ServiceType == typeof(IHostedService)).ToList())
|
||||
services.Remove(hostedService);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enables OIDC on the user in the UsersContext database.
|
||||
/// </summary>
|
||||
public async Task EnableOidcAsync()
|
||||
{
|
||||
using var scope = Services.CreateScope();
|
||||
var usersContext = scope.ServiceProvider.GetRequiredService<UsersContext>();
|
||||
|
||||
var user = await usersContext.Users.FirstOrDefaultAsync();
|
||||
if (user is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
user.Oidc = new OidcConfig
|
||||
{
|
||||
Enabled = true,
|
||||
IssuerUrl = "https://mock-oidc-provider.test",
|
||||
ClientId = "test-client",
|
||||
ClientSecret = "test-secret",
|
||||
Scopes = "openid profile email",
|
||||
AuthorizedSubject = MockOidcAuthService.AuthorizedSubject,
|
||||
ProviderName = "TestProvider"
|
||||
};
|
||||
|
||||
await usersContext.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task SetOidcIssuerUrlAsync(string issuerUrl)
|
||||
{
|
||||
using var scope = Services.CreateScope();
|
||||
var usersContext = scope.ServiceProvider.GetRequiredService<UsersContext>();
|
||||
|
||||
var user = await usersContext.Users.FirstOrDefaultAsync();
|
||||
if (user is not null)
|
||||
{
|
||||
user.Oidc.IssuerUrl = issuerUrl;
|
||||
await usersContext.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SetOidcAuthorizedSubjectAsync(string subject)
|
||||
{
|
||||
using var scope = Services.CreateScope();
|
||||
var usersContext = scope.ServiceProvider.GetRequiredService<UsersContext>();
|
||||
|
||||
var user = await usersContext.Users.FirstOrDefaultAsync();
|
||||
if (user is not null)
|
||||
{
|
||||
user.Oidc.AuthorizedSubject = subject;
|
||||
await usersContext.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SetOidcExclusiveModeAsync(bool enabled)
|
||||
{
|
||||
using var scope = Services.CreateScope();
|
||||
var usersContext = scope.ServiceProvider.GetRequiredService<UsersContext>();
|
||||
|
||||
var user = await usersContext.Users.FirstOrDefaultAsync();
|
||||
if (user is not null)
|
||||
{
|
||||
user.Oidc.ExclusiveMode = enabled;
|
||||
await usersContext.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
base.Dispose(disposing);
|
||||
|
||||
if (disposing && Directory.Exists(_tempDir))
|
||||
{
|
||||
try { Directory.Delete(_tempDir, true); } catch { /* best effort */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mock OIDC auth service that simulates IdP behavior without network calls.
|
||||
/// </summary>
|
||||
private sealed class MockOidcAuthService : IOidcAuthService
|
||||
{
|
||||
public const string ValidState = "mock-valid-state";
|
||||
public const string WrongSubjectState = "mock-wrong-subject-state";
|
||||
public const string AuthorizedSubject = "mock-authorized-subject-123";
|
||||
|
||||
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, OidcTokenExchangeResult> _oneTimeCodes = new();
|
||||
|
||||
public Task<OidcAuthorizationResult> StartAuthorization(string redirectUri, string? initiatorUserId = null)
|
||||
{
|
||||
return Task.FromResult(new OidcAuthorizationResult
|
||||
{
|
||||
AuthorizationUrl = $"https://mock-oidc-provider.test/authorize?redirect_uri={Uri.EscapeDataString(redirectUri)}&state={ValidState}",
|
||||
State = ValidState
|
||||
});
|
||||
}
|
||||
|
||||
public Task<OidcCallbackResult> HandleCallback(string code, string state, string redirectUri)
|
||||
{
|
||||
if (state == ValidState)
|
||||
{
|
||||
return Task.FromResult(new OidcCallbackResult
|
||||
{
|
||||
Success = true,
|
||||
Subject = AuthorizedSubject,
|
||||
PreferredUsername = "testuser",
|
||||
Email = "testuser@example.com"
|
||||
});
|
||||
}
|
||||
|
||||
if (state == WrongSubjectState)
|
||||
{
|
||||
return Task.FromResult(new OidcCallbackResult
|
||||
{
|
||||
Success = true,
|
||||
Subject = "wrong-subject-that-doesnt-match",
|
||||
PreferredUsername = "wronguser",
|
||||
Email = "wrong@example.com"
|
||||
});
|
||||
}
|
||||
|
||||
return Task.FromResult(new OidcCallbackResult
|
||||
{
|
||||
Success = false,
|
||||
Error = "Invalid or expired OIDC state"
|
||||
});
|
||||
}
|
||||
|
||||
public string StoreOneTimeCode(string accessToken, string refreshToken, int expiresIn)
|
||||
{
|
||||
var code = Guid.NewGuid().ToString("N");
|
||||
_oneTimeCodes.TryAdd(code, new OidcTokenExchangeResult
|
||||
{
|
||||
AccessToken = accessToken,
|
||||
RefreshToken = refreshToken,
|
||||
ExpiresIn = expiresIn
|
||||
});
|
||||
return code;
|
||||
}
|
||||
|
||||
public OidcTokenExchangeResult? ExchangeOneTimeCode(string code)
|
||||
{
|
||||
return _oneTimeCodes.TryRemove(code, out var result) ? result : null;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using Cleanuparr.Infrastructure.Features.Auth;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Features.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Factory variant that replaces <see cref="IPasswordService"/> with a
|
||||
/// <see cref="TrackingPasswordService"/> spy so tests can assert that
|
||||
/// password verification is always called regardless of username validity.
|
||||
/// </summary>
|
||||
public class TimingTestWebApplicationFactory : CustomWebApplicationFactory
|
||||
{
|
||||
public TrackingPasswordService TrackingPasswordService { get; } = new();
|
||||
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
base.ConfigureWebHost(builder);
|
||||
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
// Replace IPasswordService with our tracking spy
|
||||
var descriptor = services.SingleOrDefault(d => d.ServiceType == typeof(IPasswordService));
|
||||
if (descriptor != null) services.Remove(descriptor);
|
||||
|
||||
services.AddSingleton<IPasswordService>(TrackingPasswordService);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using Cleanuparr.Infrastructure.Features.Auth;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Features.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Spy wrapper around <see cref="PasswordService"/> that tracks calls to
|
||||
/// <see cref="VerifyPassword"/> for behavioral assertions in timing tests.
|
||||
/// </summary>
|
||||
public sealed class TrackingPasswordService : IPasswordService
|
||||
{
|
||||
private readonly PasswordService _inner = new();
|
||||
private int _verifyPasswordCallCount;
|
||||
|
||||
public int VerifyPasswordCallCount => _verifyPasswordCallCount;
|
||||
|
||||
public string DummyHash => _inner.DummyHash;
|
||||
|
||||
public string HashPassword(string password)
|
||||
{
|
||||
return _inner.HashPassword(password);
|
||||
}
|
||||
|
||||
public bool VerifyPassword(string password, string hash)
|
||||
{
|
||||
Interlocked.Increment(ref _verifyPasswordCallCount);
|
||||
return _inner.VerifyPassword(password, hash);
|
||||
}
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
Interlocked.Exchange(ref _verifyPasswordCallCount, 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
using Shouldly;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Features.SensitiveData;
|
||||
|
||||
public class SensitiveDataHelperTests
|
||||
{
|
||||
[Fact]
|
||||
public void IsPlaceholder_WithPlaceholder_ReturnsTrue()
|
||||
{
|
||||
SensitiveDataHelper.Placeholder.IsPlaceholder().ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsPlaceholder_WithAppriseStyledPlaceholder_ReturnsTrue()
|
||||
{
|
||||
$"discord://{SensitiveDataHelper.Placeholder}".IsPlaceholder().ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsPlaceholder_WithNull_ReturnsFalse()
|
||||
{
|
||||
((string?)null).IsPlaceholder().ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsPlaceholder_WithEmptyString_ReturnsFalse()
|
||||
{
|
||||
"".IsPlaceholder().ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsPlaceholder_WithRealValue_ReturnsFalse()
|
||||
{
|
||||
"my-secret-api-key-123".IsPlaceholder().ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("discord://webhook_id/webhook_token", "discord://••••••••")]
|
||||
[InlineData("slack://tokenA/tokenB/tokenC", "slack://••••••••")]
|
||||
[InlineData("mailto://user:pass@gmail.com", "mailto://••••••••")]
|
||||
[InlineData("json+http://user:pass@host/path", "json+http://••••••••")]
|
||||
public void MaskAppriseUrls_SingleUrl_MasksCorrectly(string input, string expected)
|
||||
{
|
||||
SensitiveDataHelper.MaskAppriseUrls(input).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MaskAppriseUrls_MultipleUrls_MasksAll()
|
||||
{
|
||||
var input = "discord://token1 slack://tokenA/tokenB";
|
||||
var result = SensitiveDataHelper.MaskAppriseUrls(input);
|
||||
|
||||
result.ShouldContain("discord://••••••••");
|
||||
result.ShouldContain("slack://••••••••");
|
||||
result.ShouldNotContain("token1");
|
||||
result.ShouldNotContain("tokenA");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MaskAppriseUrls_MultilineUrls_MasksAll()
|
||||
{
|
||||
var input = "discord://token1\nslack://tokenA/tokenB";
|
||||
var result = SensitiveDataHelper.MaskAppriseUrls(input);
|
||||
|
||||
result.ShouldContain("discord://••••••••");
|
||||
result.ShouldContain("slack://••••••••");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void MaskAppriseUrls_EmptyOrNull_ReturnsAsIs(string? input)
|
||||
{
|
||||
SensitiveDataHelper.MaskAppriseUrls(input).ShouldBe(input);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
using Cleanuparr.Api.Features.Arr.Contracts.Requests;
|
||||
using Cleanuparr.Api.Features.DownloadClient.Contracts.Requests;
|
||||
using Cleanuparr.Api.Features.Auth.Contracts.Requests;
|
||||
using Cleanuparr.Api.Features.General.Contracts.Requests;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Persistence.Models.Auth;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
using Shouldly;
|
||||
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Features.SensitiveData;
|
||||
|
||||
/// <summary>
|
||||
/// Tests that placeholder values are correctly handled on the input side:
|
||||
/// - UPDATE operations preserve the existing DB value when a placeholder is sent
|
||||
/// - CREATE operations reject placeholder values
|
||||
/// - TEST operations reject placeholder values
|
||||
/// </summary>
|
||||
public class SensitiveDataInputTests
|
||||
{
|
||||
private const string Placeholder = SensitiveDataHelper.Placeholder;
|
||||
|
||||
#region ArrInstanceRequest — UPDATE
|
||||
|
||||
[Fact]
|
||||
public void ArrInstanceRequest_ApplyTo_WithPlaceholderApiKey_PreservesExistingValue()
|
||||
{
|
||||
var request = new ArrInstanceRequest
|
||||
{
|
||||
Name = "Updated Sonarr",
|
||||
Url = "http://sonarr:8989",
|
||||
ApiKey = Placeholder,
|
||||
Version = 4,
|
||||
};
|
||||
|
||||
var existingInstance = new ArrInstance
|
||||
{
|
||||
Name = "Sonarr",
|
||||
Url = new Uri("http://sonarr:8989"),
|
||||
ApiKey = "original-secret-key",
|
||||
ArrConfigId = Guid.NewGuid(),
|
||||
Version = 4,
|
||||
};
|
||||
|
||||
request.ApplyTo(existingInstance);
|
||||
|
||||
existingInstance.ApiKey.ShouldBe("original-secret-key");
|
||||
existingInstance.Name.ShouldBe("Updated Sonarr");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ArrInstanceRequest_ApplyTo_WithRealApiKey_UpdatesValue()
|
||||
{
|
||||
var request = new ArrInstanceRequest
|
||||
{
|
||||
Name = "Sonarr",
|
||||
Url = "http://sonarr:8989",
|
||||
ApiKey = "brand-new-api-key",
|
||||
Version = 4,
|
||||
};
|
||||
|
||||
var existingInstance = new ArrInstance
|
||||
{
|
||||
Name = "Sonarr",
|
||||
Url = new Uri("http://sonarr:8989"),
|
||||
ApiKey = "original-secret-key",
|
||||
ArrConfigId = Guid.NewGuid(),
|
||||
Version = 4,
|
||||
};
|
||||
|
||||
request.ApplyTo(existingInstance);
|
||||
|
||||
existingInstance.ApiKey.ShouldBe("brand-new-api-key");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ArrInstanceRequest — CREATE
|
||||
|
||||
[Fact]
|
||||
public void ArrInstanceRequest_ToEntity_WithPlaceholderApiKey_ThrowsValidationException()
|
||||
{
|
||||
var request = new ArrInstanceRequest
|
||||
{
|
||||
Name = "Sonarr",
|
||||
Url = "http://sonarr:8989",
|
||||
ApiKey = Placeholder,
|
||||
Version = 4,
|
||||
};
|
||||
|
||||
Should.Throw<ValidationException>(() => request.ToEntity(Guid.NewGuid()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ArrInstanceRequest_ToEntity_WithRealApiKey_Succeeds()
|
||||
{
|
||||
var request = new ArrInstanceRequest
|
||||
{
|
||||
Name = "Sonarr",
|
||||
Url = "http://sonarr:8989",
|
||||
ApiKey = "real-api-key-123",
|
||||
Version = 4,
|
||||
};
|
||||
|
||||
var entity = request.ToEntity(Guid.NewGuid());
|
||||
entity.ApiKey.ShouldBe("real-api-key-123");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region TestArrInstanceRequest — TEST
|
||||
|
||||
[Fact]
|
||||
public void TestArrInstanceRequest_ToTestInstance_WithPlaceholderApiKey_AndNoResolvedKey_ThrowsValidationException()
|
||||
{
|
||||
var request = new TestArrInstanceRequest
|
||||
{
|
||||
Url = "http://sonarr:8989",
|
||||
ApiKey = Placeholder,
|
||||
Version = 4,
|
||||
};
|
||||
|
||||
Should.Throw<ValidationException>(() => request.ToTestInstance());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TestArrInstanceRequest_ToTestInstance_WithPlaceholderApiKey_AndResolvedKey_UsesResolvedKey()
|
||||
{
|
||||
var request = new TestArrInstanceRequest
|
||||
{
|
||||
Url = "http://sonarr:8989",
|
||||
ApiKey = Placeholder,
|
||||
Version = 4,
|
||||
InstanceId = Guid.NewGuid(),
|
||||
};
|
||||
|
||||
var instance = request.ToTestInstance("resolved-api-key-from-db");
|
||||
instance.ApiKey.ShouldBe("resolved-api-key-from-db");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TestArrInstanceRequest_ToTestInstance_WithRealApiKey_Succeeds()
|
||||
{
|
||||
var request = new TestArrInstanceRequest
|
||||
{
|
||||
Url = "http://sonarr:8989",
|
||||
ApiKey = "real-api-key",
|
||||
Version = 4,
|
||||
};
|
||||
|
||||
var instance = request.ToTestInstance();
|
||||
instance.ApiKey.ShouldBe("real-api-key");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region UpdateDownloadClientRequest — UPDATE
|
||||
|
||||
[Fact]
|
||||
public void UpdateDownloadClientRequest_ApplyTo_WithPlaceholderPassword_PreservesExistingValue()
|
||||
{
|
||||
var request = new UpdateDownloadClientRequest
|
||||
{
|
||||
Name = "Updated qBit",
|
||||
TypeName = DownloadClientTypeName.qBittorrent,
|
||||
Type = DownloadClientType.Torrent,
|
||||
Host = "http://qbit:8080",
|
||||
Username = "admin",
|
||||
Password = Placeholder,
|
||||
};
|
||||
|
||||
var existing = new DownloadClientConfig
|
||||
{
|
||||
Name = "qBittorrent",
|
||||
TypeName = DownloadClientTypeName.qBittorrent,
|
||||
Type = DownloadClientType.Torrent,
|
||||
Host = new Uri("http://qbit:8080"),
|
||||
Username = "admin",
|
||||
Password = "original-secret-password",
|
||||
};
|
||||
|
||||
var result = request.ApplyTo(existing);
|
||||
|
||||
result.Password.ShouldBe("original-secret-password");
|
||||
result.Name.ShouldBe("Updated qBit");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateDownloadClientRequest_ApplyTo_WithRealPassword_UpdatesValue()
|
||||
{
|
||||
var request = new UpdateDownloadClientRequest
|
||||
{
|
||||
Name = "qBittorrent",
|
||||
TypeName = DownloadClientTypeName.qBittorrent,
|
||||
Type = DownloadClientType.Torrent,
|
||||
Host = "http://qbit:8080",
|
||||
Username = "admin",
|
||||
Password = "new-password-123",
|
||||
};
|
||||
|
||||
var existing = new DownloadClientConfig
|
||||
{
|
||||
Name = "qBittorrent",
|
||||
TypeName = DownloadClientTypeName.qBittorrent,
|
||||
Type = DownloadClientType.Torrent,
|
||||
Host = new Uri("http://qbit:8080"),
|
||||
Username = "admin",
|
||||
Password = "original-secret-password",
|
||||
};
|
||||
|
||||
var result = request.ApplyTo(existing);
|
||||
|
||||
result.Password.ShouldBe("new-password-123");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region CreateDownloadClientRequest — CREATE
|
||||
|
||||
[Fact]
|
||||
public void CreateDownloadClientRequest_Validate_WithPlaceholderPassword_ThrowsValidationException()
|
||||
{
|
||||
var request = new CreateDownloadClientRequest
|
||||
{
|
||||
Name = "qBittorrent",
|
||||
TypeName = DownloadClientTypeName.qBittorrent,
|
||||
Type = DownloadClientType.Torrent,
|
||||
Host = "http://qbit:8080",
|
||||
Password = Placeholder,
|
||||
};
|
||||
|
||||
Should.Throw<ValidationException>(() => request.Validate());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateDownloadClientRequest_Validate_WithRealPassword_Succeeds()
|
||||
{
|
||||
var request = new CreateDownloadClientRequest
|
||||
{
|
||||
Name = "qBittorrent",
|
||||
TypeName = DownloadClientTypeName.qBittorrent,
|
||||
Type = DownloadClientType.Torrent,
|
||||
Host = "http://qbit:8080",
|
||||
Password = "real-password",
|
||||
};
|
||||
|
||||
Should.NotThrow(() => request.Validate());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateDownloadClientRequest_Validate_WithNullPassword_Succeeds()
|
||||
{
|
||||
var request = new CreateDownloadClientRequest
|
||||
{
|
||||
Name = "qBittorrent",
|
||||
TypeName = DownloadClientTypeName.qBittorrent,
|
||||
Type = DownloadClientType.Torrent,
|
||||
Host = "http://qbit:8080",
|
||||
Password = null,
|
||||
};
|
||||
|
||||
Should.NotThrow(() => request.Validate());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region TestDownloadClientRequest — TEST
|
||||
|
||||
[Fact]
|
||||
public void TestDownloadClientRequest_ToTestConfig_WithPlaceholderPassword_AndNoResolvedPassword_ThrowsValidationException()
|
||||
{
|
||||
var request = new TestDownloadClientRequest
|
||||
{
|
||||
TypeName = DownloadClientTypeName.qBittorrent,
|
||||
Type = DownloadClientType.Torrent,
|
||||
Host = "http://qbit:8080",
|
||||
Password = Placeholder,
|
||||
};
|
||||
|
||||
request.Validate();
|
||||
Should.Throw<ValidationException>(() => request.ToTestConfig());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TestDownloadClientRequest_ToTestConfig_WithPlaceholderPassword_AndResolvedPassword_UsesResolvedPassword()
|
||||
{
|
||||
var request = new TestDownloadClientRequest
|
||||
{
|
||||
TypeName = DownloadClientTypeName.qBittorrent,
|
||||
Type = DownloadClientType.Torrent,
|
||||
Host = "http://qbit:8080",
|
||||
Password = Placeholder,
|
||||
ClientId = Guid.NewGuid(),
|
||||
};
|
||||
|
||||
request.Validate();
|
||||
var config = request.ToTestConfig("resolved-password-from-db");
|
||||
config.Password.ShouldBe("resolved-password-from-db");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TestDownloadClientRequest_ToTestConfig_WithRealPassword_Succeeds()
|
||||
{
|
||||
var request = new TestDownloadClientRequest
|
||||
{
|
||||
TypeName = DownloadClientTypeName.qBittorrent,
|
||||
Type = DownloadClientType.Torrent,
|
||||
Host = "http://qbit:8080",
|
||||
Password = "real-password",
|
||||
};
|
||||
|
||||
request.Validate();
|
||||
var config = request.ToTestConfig();
|
||||
config.Password.ShouldBe("real-password");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region UpdateOidcConfigRequest — UPDATE
|
||||
|
||||
[Fact]
|
||||
public void UpdateOidcConfigRequest_ApplyTo_WithPlaceholderClientSecret_PreservesExistingValue()
|
||||
{
|
||||
var request = new UpdateOidcConfigRequest
|
||||
{
|
||||
Enabled = true,
|
||||
IssuerUrl = "http://localhost:8080/realms/test",
|
||||
ClientId = "cleanuparr",
|
||||
ClientSecret = Placeholder,
|
||||
Scopes = "openid profile email",
|
||||
ProviderName = "Keycloak",
|
||||
};
|
||||
|
||||
var existingConfig = new OidcConfig
|
||||
{
|
||||
Enabled = true,
|
||||
IssuerUrl = "http://localhost:8080/realms/test",
|
||||
ClientId = "cleanuparr",
|
||||
ClientSecret = "original-secret",
|
||||
Scopes = "openid profile email",
|
||||
ProviderName = "OIDC",
|
||||
};
|
||||
|
||||
request.ApplyTo(existingConfig);
|
||||
|
||||
existingConfig.ClientSecret.ShouldBe("original-secret");
|
||||
existingConfig.ProviderName.ShouldBe("Keycloak");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateOidcConfigRequest_ApplyTo_WithRealClientSecret_UpdatesValue()
|
||||
{
|
||||
var request = new UpdateOidcConfigRequest
|
||||
{
|
||||
Enabled = true,
|
||||
IssuerUrl = "http://localhost:8080/realms/test",
|
||||
ClientId = "cleanuparr",
|
||||
ClientSecret = "brand-new-secret",
|
||||
Scopes = "openid profile email",
|
||||
ProviderName = "Keycloak",
|
||||
};
|
||||
|
||||
var existingConfig = new OidcConfig
|
||||
{
|
||||
Enabled = true,
|
||||
IssuerUrl = "http://localhost:8080/realms/test",
|
||||
ClientId = "cleanuparr",
|
||||
ClientSecret = "original-secret",
|
||||
Scopes = "openid profile email",
|
||||
ProviderName = "OIDC",
|
||||
};
|
||||
|
||||
request.ApplyTo(existingConfig);
|
||||
|
||||
existingConfig.ClientSecret.ShouldBe("brand-new-secret");
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
+461
@@ -0,0 +1,461 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization.Metadata;
|
||||
using Cleanuparr.Api.Json;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.Arr.Dtos;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Notification;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
using Shouldly;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Features.SensitiveData;
|
||||
|
||||
/// <summary>
|
||||
/// Tests that the SensitiveDataResolver correctly masks all [SensitiveData] properties
|
||||
/// during JSON serialization — this is what controls the API response output.
|
||||
/// </summary>
|
||||
public class SensitiveDataResolverTests
|
||||
{
|
||||
private readonly JsonSerializerOptions _options;
|
||||
private const string Placeholder = SensitiveDataHelper.Placeholder;
|
||||
|
||||
public SensitiveDataResolverTests()
|
||||
{
|
||||
_options = new JsonSerializerOptions
|
||||
{
|
||||
TypeInfoResolver = new SensitiveDataResolver(new DefaultJsonTypeInfoResolver()),
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
};
|
||||
}
|
||||
|
||||
#region ArrInstance
|
||||
|
||||
[Fact]
|
||||
public void ArrInstance_ApiKey_IsMasked()
|
||||
{
|
||||
var instance = new ArrInstance
|
||||
{
|
||||
Name = "Sonarr",
|
||||
Url = new Uri("http://sonarr:8989"),
|
||||
ApiKey = "super-secret-api-key-12345",
|
||||
ArrConfigId = Guid.NewGuid(),
|
||||
Version = 4
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(instance, _options);
|
||||
var doc = JsonDocument.Parse(json);
|
||||
|
||||
doc.RootElement.GetProperty("apiKey").GetString().ShouldBe(Placeholder);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ArrInstance_NonSensitiveFields_AreVisible()
|
||||
{
|
||||
var instance = new ArrInstance
|
||||
{
|
||||
Name = "Sonarr",
|
||||
Url = new Uri("http://sonarr:8989"),
|
||||
ExternalUrl = new Uri("https://sonarr.example.com"),
|
||||
ApiKey = "super-secret-api-key-12345",
|
||||
ArrConfigId = Guid.NewGuid(),
|
||||
Version = 4
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(instance, _options);
|
||||
var doc = JsonDocument.Parse(json);
|
||||
|
||||
doc.RootElement.GetProperty("name").GetString().ShouldBe("Sonarr");
|
||||
doc.RootElement.GetProperty("url").GetString().ShouldBe("http://sonarr:8989");
|
||||
doc.RootElement.GetProperty("externalUrl").GetString().ShouldBe("https://sonarr.example.com");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ArrInstance_NullApiKey_RemainsNull()
|
||||
{
|
||||
// ApiKey is required, but let's test with the DTO which might handle null
|
||||
var dto = new ArrInstanceDto
|
||||
{
|
||||
Name = "Sonarr",
|
||||
Url = "http://sonarr:8989",
|
||||
ApiKey = null!,
|
||||
Version = 4
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(dto, _options);
|
||||
var doc = JsonDocument.Parse(json);
|
||||
|
||||
doc.RootElement.GetProperty("apiKey").ValueKind.ShouldBe(JsonValueKind.Null);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ArrInstanceDto
|
||||
|
||||
[Fact]
|
||||
public void ArrInstanceDto_ApiKey_IsMasked()
|
||||
{
|
||||
var dto = new ArrInstanceDto
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Radarr",
|
||||
Url = "http://radarr:7878",
|
||||
ApiKey = "dto-secret-api-key-67890",
|
||||
Version = 5
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(dto, _options);
|
||||
var doc = JsonDocument.Parse(json);
|
||||
|
||||
doc.RootElement.GetProperty("apiKey").GetString().ShouldBe(Placeholder);
|
||||
doc.RootElement.GetProperty("name").GetString().ShouldBe("Radarr");
|
||||
doc.RootElement.GetProperty("url").GetString().ShouldBe("http://radarr:7878");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region DownloadClientConfig
|
||||
|
||||
[Fact]
|
||||
public void DownloadClientConfig_Password_IsMasked()
|
||||
{
|
||||
var config = new DownloadClientConfig
|
||||
{
|
||||
Name = "qBittorrent",
|
||||
TypeName = DownloadClientTypeName.qBittorrent,
|
||||
Type = DownloadClientType.Torrent,
|
||||
Host = new Uri("http://qbit:8080"),
|
||||
Username = "admin",
|
||||
Password = "my-secret-password",
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(config, _options);
|
||||
var doc = JsonDocument.Parse(json);
|
||||
|
||||
doc.RootElement.GetProperty("password").GetString().ShouldBe(Placeholder);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DownloadClientConfig_Username_IsVisible()
|
||||
{
|
||||
var config = new DownloadClientConfig
|
||||
{
|
||||
Name = "qBittorrent",
|
||||
TypeName = DownloadClientTypeName.qBittorrent,
|
||||
Type = DownloadClientType.Torrent,
|
||||
Host = new Uri("http://qbit:8080"),
|
||||
Username = "admin",
|
||||
Password = "my-secret-password",
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(config, _options);
|
||||
var doc = JsonDocument.Parse(json);
|
||||
|
||||
doc.RootElement.GetProperty("username").GetString().ShouldBe("admin");
|
||||
doc.RootElement.GetProperty("name").GetString().ShouldBe("qBittorrent");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DownloadClientConfig_NullPassword_RemainsNull()
|
||||
{
|
||||
var config = new DownloadClientConfig
|
||||
{
|
||||
Name = "qBittorrent",
|
||||
TypeName = DownloadClientTypeName.qBittorrent,
|
||||
Type = DownloadClientType.Torrent,
|
||||
Host = new Uri("http://qbit:8080"),
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(config, _options);
|
||||
var doc = JsonDocument.Parse(json);
|
||||
|
||||
doc.RootElement.GetProperty("password").ValueKind.ShouldBe(JsonValueKind.Null);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region NotifiarrConfig
|
||||
|
||||
[Fact]
|
||||
public void NotifiarrConfig_ApiKey_IsMasked()
|
||||
{
|
||||
var config = new NotifiarrConfig
|
||||
{
|
||||
ApiKey = "notifiarr-api-key-secret",
|
||||
ChannelId = "123456789"
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(config, _options);
|
||||
var doc = JsonDocument.Parse(json);
|
||||
|
||||
doc.RootElement.GetProperty("apiKey").GetString().ShouldBe(Placeholder);
|
||||
doc.RootElement.GetProperty("channelId").GetString().ShouldBe("123456789");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region DiscordConfig
|
||||
|
||||
[Fact]
|
||||
public void DiscordConfig_WebhookUrl_IsMasked()
|
||||
{
|
||||
var config = new DiscordConfig
|
||||
{
|
||||
WebhookUrl = "https://discord.com/api/webhooks/123456/secret-token",
|
||||
Username = "Cleanuparr Bot",
|
||||
AvatarUrl = "https://example.com/avatar.png"
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(config, _options);
|
||||
var doc = JsonDocument.Parse(json);
|
||||
|
||||
doc.RootElement.GetProperty("webhookUrl").GetString().ShouldBe(Placeholder);
|
||||
doc.RootElement.GetProperty("username").GetString().ShouldBe("Cleanuparr Bot");
|
||||
doc.RootElement.GetProperty("avatarUrl").GetString().ShouldBe("https://example.com/avatar.png");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region TelegramConfig
|
||||
|
||||
[Fact]
|
||||
public void TelegramConfig_BotToken_IsMasked()
|
||||
{
|
||||
var config = new TelegramConfig
|
||||
{
|
||||
BotToken = "1234567890:ABCdefGHIjklmnoPQRstuvWXyz",
|
||||
ChatId = "-1001234567890",
|
||||
TopicId = "42",
|
||||
SendSilently = true
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(config, _options);
|
||||
var doc = JsonDocument.Parse(json);
|
||||
|
||||
doc.RootElement.GetProperty("botToken").GetString().ShouldBe(Placeholder);
|
||||
doc.RootElement.GetProperty("chatId").GetString().ShouldBe("-1001234567890");
|
||||
doc.RootElement.GetProperty("topicId").GetString().ShouldBe("42");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region NtfyConfig
|
||||
|
||||
[Fact]
|
||||
public void NtfyConfig_PasswordAndAccessToken_AreMasked()
|
||||
{
|
||||
var config = new NtfyConfig
|
||||
{
|
||||
ServerUrl = "https://ntfy.example.com",
|
||||
Topics = ["test-topic"],
|
||||
AuthenticationType = NtfyAuthenticationType.BasicAuth,
|
||||
Username = "ntfy-user",
|
||||
Password = "ntfy-secret-password",
|
||||
AccessToken = "ntfy-access-token-secret",
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(config, _options);
|
||||
var doc = JsonDocument.Parse(json);
|
||||
|
||||
doc.RootElement.GetProperty("password").GetString().ShouldBe(Placeholder);
|
||||
doc.RootElement.GetProperty("accessToken").GetString().ShouldBe(Placeholder);
|
||||
doc.RootElement.GetProperty("serverUrl").GetString().ShouldBe("https://ntfy.example.com");
|
||||
doc.RootElement.GetProperty("username").GetString().ShouldBe("ntfy-user");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NtfyConfig_NullPasswordAndAccessToken_RemainNull()
|
||||
{
|
||||
var config = new NtfyConfig
|
||||
{
|
||||
ServerUrl = "https://ntfy.example.com",
|
||||
Topics = ["test-topic"],
|
||||
AuthenticationType = NtfyAuthenticationType.None,
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(config, _options);
|
||||
var doc = JsonDocument.Parse(json);
|
||||
|
||||
doc.RootElement.GetProperty("password").ValueKind.ShouldBe(JsonValueKind.Null);
|
||||
doc.RootElement.GetProperty("accessToken").ValueKind.ShouldBe(JsonValueKind.Null);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region PushoverConfig
|
||||
|
||||
[Fact]
|
||||
public void PushoverConfig_ApiTokenAndUserKey_AreMasked()
|
||||
{
|
||||
var config = new PushoverConfig
|
||||
{
|
||||
ApiToken = "pushover-api-token-secret",
|
||||
UserKey = "pushover-user-key-secret",
|
||||
Priority = PushoverPriority.Normal,
|
||||
Devices = ["iphone", "desktop"]
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(config, _options);
|
||||
var doc = JsonDocument.Parse(json);
|
||||
|
||||
doc.RootElement.GetProperty("apiToken").GetString().ShouldBe(Placeholder);
|
||||
doc.RootElement.GetProperty("userKey").GetString().ShouldBe(Placeholder);
|
||||
doc.RootElement.GetProperty("devices").GetArrayLength().ShouldBe(2);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GotifyConfig
|
||||
|
||||
[Fact]
|
||||
public void GotifyConfig_ApplicationToken_IsMasked()
|
||||
{
|
||||
var config = new GotifyConfig
|
||||
{
|
||||
ServerUrl = "https://gotify.example.com",
|
||||
ApplicationToken = "gotify-app-token-secret",
|
||||
Priority = 5
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(config, _options);
|
||||
var doc = JsonDocument.Parse(json);
|
||||
|
||||
doc.RootElement.GetProperty("applicationToken").GetString().ShouldBe(Placeholder);
|
||||
doc.RootElement.GetProperty("serverUrl").GetString().ShouldBe("https://gotify.example.com");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region AppriseConfig
|
||||
|
||||
[Fact]
|
||||
public void AppriseConfig_Key_IsMasked_WithFullMask()
|
||||
{
|
||||
var config = new AppriseConfig
|
||||
{
|
||||
Mode = AppriseMode.Api,
|
||||
Url = "https://apprise.example.com",
|
||||
Key = "apprise-config-key-secret",
|
||||
Tags = "urgent",
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(config, _options);
|
||||
var doc = JsonDocument.Parse(json);
|
||||
|
||||
doc.RootElement.GetProperty("key").GetString().ShouldBe(Placeholder);
|
||||
doc.RootElement.GetProperty("url").GetString().ShouldBe("https://apprise.example.com");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AppriseConfig_ServiceUrls_IsMasked_WithAppriseUrlMask()
|
||||
{
|
||||
var config = new AppriseConfig
|
||||
{
|
||||
Mode = AppriseMode.Cli,
|
||||
ServiceUrls = "discord://webhook_id/webhook_token slack://tokenA/tokenB/tokenC"
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(config, _options);
|
||||
var doc = JsonDocument.Parse(json);
|
||||
|
||||
var maskedUrls = doc.RootElement.GetProperty("serviceUrls").GetString();
|
||||
maskedUrls.ShouldContain("discord://••••••••");
|
||||
maskedUrls.ShouldContain("slack://••••••••");
|
||||
maskedUrls.ShouldNotContain("webhook_id");
|
||||
maskedUrls.ShouldNotContain("webhook_token");
|
||||
maskedUrls.ShouldNotContain("tokenA");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AppriseConfig_NullServiceUrls_RemainsNull()
|
||||
{
|
||||
var config = new AppriseConfig
|
||||
{
|
||||
Mode = AppriseMode.Api,
|
||||
Url = "https://apprise.example.com",
|
||||
Key = "some-key",
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(config, _options);
|
||||
var doc = JsonDocument.Parse(json);
|
||||
|
||||
doc.RootElement.GetProperty("serviceUrls").ValueKind.ShouldBe(JsonValueKind.Null);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Polymorphic serialization (as used in NotificationProviderResponse)
|
||||
|
||||
[Fact]
|
||||
public void PolymorphicSerialization_NotifiarrConfig_StillMasked()
|
||||
{
|
||||
// The notification providers endpoint casts configs to `object`.
|
||||
// Verify that the resolver still masks when serializing as a concrete type at runtime.
|
||||
object config = new NotifiarrConfig
|
||||
{
|
||||
ApiKey = "my-secret-notifiarr-key",
|
||||
ChannelId = "987654321"
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(config, config.GetType(), _options);
|
||||
var doc = JsonDocument.Parse(json);
|
||||
|
||||
doc.RootElement.GetProperty("apiKey").GetString().ShouldBe(Placeholder);
|
||||
doc.RootElement.GetProperty("channelId").GetString().ShouldBe("987654321");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PolymorphicSerialization_DiscordConfig_StillMasked()
|
||||
{
|
||||
object config = new DiscordConfig
|
||||
{
|
||||
WebhookUrl = "https://discord.com/api/webhooks/123/secret",
|
||||
Username = "Bot"
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(config, config.GetType(), _options);
|
||||
var doc = JsonDocument.Parse(json);
|
||||
|
||||
doc.RootElement.GetProperty("webhookUrl").GetString().ShouldBe(Placeholder);
|
||||
doc.RootElement.GetProperty("username").GetString().ShouldBe("Bot");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Edge cases
|
||||
|
||||
[Fact]
|
||||
public void EmptySensitiveString_IsMasked_NotReturnedEmpty()
|
||||
{
|
||||
var config = new NotifiarrConfig
|
||||
{
|
||||
ApiKey = "",
|
||||
ChannelId = "123"
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(config, _options);
|
||||
var doc = JsonDocument.Parse(json);
|
||||
|
||||
// Even empty strings get masked to the placeholder
|
||||
doc.RootElement.GetProperty("apiKey").GetString().ShouldBe(Placeholder);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MultipleSensitiveFields_AllMasked()
|
||||
{
|
||||
var config = new PushoverConfig
|
||||
{
|
||||
ApiToken = "token-abc-123",
|
||||
UserKey = "user-key-xyz-789",
|
||||
Priority = PushoverPriority.High,
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(config, _options);
|
||||
var doc = JsonDocument.Parse(json);
|
||||
|
||||
doc.RootElement.GetProperty("apiToken").GetString().ShouldBe(Placeholder);
|
||||
doc.RootElement.GetProperty("userKey").GetString().ShouldBe(Placeholder);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace Cleanuparr.Api.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Auth integration tests share the file-system config directory (users.db via
|
||||
/// SetupGuardMiddleware.CreateStaticInstance). Grouping them in one collection
|
||||
/// forces sequential execution and prevents inter-factory interference.
|
||||
/// </summary>
|
||||
[CollectionDefinition("Auth Integration Tests")]
|
||||
public class AuthIntegrationTestsCollection { }
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
|
||||
"parallelizeAssembly": false,
|
||||
"parallelizeTestCollections": false
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Cleanuparr.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api")]
|
||||
[Authorize]
|
||||
public class ApiDocumentationController : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Text.Json.Serialization;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Events;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
@@ -9,6 +10,7 @@ namespace Cleanuparr.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
[Authorize]
|
||||
public class EventsController : ControllerBase
|
||||
{
|
||||
private readonly EventsContext _context;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Cleanuparr.Infrastructure.Health;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Cleanuparr.Api.Controllers;
|
||||
@@ -8,6 +9,7 @@ namespace Cleanuparr.Api.Controllers;
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/health")]
|
||||
[Authorize]
|
||||
public class HealthCheckController : ControllerBase
|
||||
{
|
||||
private readonly ILogger<HealthCheckController> _logger;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
|
||||
@@ -8,6 +9,7 @@ namespace Cleanuparr.Api.Controllers;
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("[controller]")]
|
||||
[Authorize]
|
||||
public class HealthController : ControllerBase
|
||||
{
|
||||
private readonly HealthCheckService _healthCheckService;
|
||||
@@ -23,6 +25,7 @@ public class HealthController : ControllerBase
|
||||
/// Basic liveness probe - checks if the application is running
|
||||
/// Used by Docker HEALTHCHECK and Kubernetes liveness probes
|
||||
/// </summary>
|
||||
[AllowAnonymous]
|
||||
[HttpGet]
|
||||
[Route("/health")]
|
||||
public async Task<IActionResult> GetHealth()
|
||||
@@ -47,6 +50,7 @@ public class HealthController : ControllerBase
|
||||
/// Readiness probe - checks if the application is ready to serve traffic
|
||||
/// Used by Kubernetes readiness probes
|
||||
/// </summary>
|
||||
[AllowAnonymous]
|
||||
[HttpGet]
|
||||
[Route("/health/ready")]
|
||||
public async Task<IActionResult> GetReadiness()
|
||||
|
||||
@@ -2,12 +2,14 @@ using Cleanuparr.Api.Models;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Models;
|
||||
using Cleanuparr.Infrastructure.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Cleanuparr.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
[Authorize]
|
||||
public class JobsController : ControllerBase
|
||||
{
|
||||
private readonly IJobManagementService _jobManagementService;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Events;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
@@ -8,6 +9,7 @@ namespace Cleanuparr.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
[Authorize]
|
||||
public class ManualEventsController : ControllerBase
|
||||
{
|
||||
private readonly EventsContext _context;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Cleanuparr.Infrastructure.Stats;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Cleanuparr.Api.Controllers;
|
||||
@@ -8,6 +9,7 @@ namespace Cleanuparr.Api.Controllers;
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
[Authorize]
|
||||
public class StatsController : ControllerBase
|
||||
{
|
||||
private readonly ILogger<StatsController> _logger;
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Diagnostics;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
|
||||
using Cleanuparr.Persistence;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
@@ -9,6 +10,7 @@ namespace Cleanuparr.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
[Authorize]
|
||||
public class StatusController : ControllerBase
|
||||
{
|
||||
private readonly ILogger<StatusController> _logger;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.State;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
@@ -8,6 +9,7 @@ namespace Cleanuparr.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
[Authorize]
|
||||
public class StrikesController : ControllerBase
|
||||
{
|
||||
private readonly EventsContext _context;
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Text.Json.Serialization.Metadata;
|
||||
using Cleanuparr.Api.Filters;
|
||||
using Cleanuparr.Api.Json;
|
||||
using Cleanuparr.Infrastructure.Health;
|
||||
using Cleanuparr.Infrastructure.Hubs;
|
||||
using Microsoft.AspNetCore.Http.Json;
|
||||
@@ -17,12 +20,14 @@ public static class ApiDI
|
||||
options.SerializerOptions.PropertyNameCaseInsensitive = true;
|
||||
options.SerializerOptions.Converters.Add(new JsonStringEnumConverter());
|
||||
options.SerializerOptions.ReferenceHandler = ReferenceHandler.IgnoreCycles;
|
||||
options.SerializerOptions.TypeInfoResolver = new SensitiveDataResolver(
|
||||
options.SerializerOptions.TypeInfoResolver ?? new DefaultJsonTypeInfoResolver());
|
||||
});
|
||||
|
||||
|
||||
// Make JsonSerializerOptions available for injection
|
||||
services.AddSingleton(sp =>
|
||||
sp.GetRequiredService<IOptions<JsonOptions>>().Value.SerializerOptions);
|
||||
|
||||
|
||||
// Add API-specific services
|
||||
services
|
||||
.AddControllers()
|
||||
@@ -31,9 +36,11 @@ public static class ApiDI
|
||||
options.JsonSerializerOptions.PropertyNameCaseInsensitive = true;
|
||||
options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());
|
||||
options.JsonSerializerOptions.ReferenceHandler = ReferenceHandler.IgnoreCycles;
|
||||
options.JsonSerializerOptions.TypeInfoResolver = new SensitiveDataResolver(
|
||||
options.JsonSerializerOptions.TypeInfoResolver ?? new DefaultJsonTypeInfoResolver());
|
||||
});
|
||||
services.AddEndpointsApiExplorer();
|
||||
|
||||
|
||||
// Add SignalR for real-time updates
|
||||
services
|
||||
.AddSignalR()
|
||||
@@ -41,6 +48,8 @@ public static class ApiDI
|
||||
{
|
||||
options.PayloadSerializerOptions.PropertyNameCaseInsensitive = true;
|
||||
options.PayloadSerializerOptions.Converters.Add(new JsonStringEnumConverter());
|
||||
options.PayloadSerializerOptions.TypeInfoResolver = new SensitiveDataResolver(
|
||||
options.PayloadSerializerOptions.TypeInfoResolver ?? new DefaultJsonTypeInfoResolver());
|
||||
});
|
||||
|
||||
// Add health status broadcaster
|
||||
@@ -56,10 +65,10 @@ public static class ApiDI
|
||||
// Enable compression
|
||||
app.UseResponseCompression();
|
||||
|
||||
// Serve static files with caching
|
||||
// Serve static files without caching
|
||||
app.UseStaticFiles(new StaticFileOptions
|
||||
{
|
||||
OnPrepareResponse = _ => {}
|
||||
OnPrepareResponse = ctx => NoCacheAttribute.Apply(ctx.Context.Response.Headers)
|
||||
});
|
||||
|
||||
// Add the global exception handling middleware first
|
||||
@@ -111,6 +120,7 @@ public static class ApiDI
|
||||
);
|
||||
|
||||
context.Response.ContentType = "text/html";
|
||||
NoCacheAttribute.Apply(context.Response.Headers);
|
||||
await context.Response.WriteAsync(indexContent, Encoding.UTF8);
|
||||
}).AllowAnonymous();
|
||||
|
||||
|
||||
@@ -94,6 +94,9 @@ public static class MainDI
|
||||
// Add HTTP client for Plex authentication
|
||||
services.AddHttpClient("PlexAuth");
|
||||
|
||||
// Add HTTP client for OIDC authentication
|
||||
services.AddHttpClient("OidcAuth");
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ public static class ServicesDI
|
||||
.AddSingleton<IPasswordService, PasswordService>()
|
||||
.AddSingleton<ITotpService, TotpService>()
|
||||
.AddScoped<IPlexAuthService, PlexAuthService>()
|
||||
.AddScoped<IOidcAuthService, OidcAuthService>()
|
||||
.AddScoped<IEventPublisher, EventPublisher>()
|
||||
.AddHostedService<EventCleanupService>()
|
||||
.AddScoped<IDryRunInterceptor, DryRunInterceptor>()
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
using Cleanuparr.Infrastructure.Extensions;
|
||||
|
||||
namespace Cleanuparr.Api.Extensions;
|
||||
|
||||
public static class HttpRequestExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns the request PathBase as a safe relative path.
|
||||
/// Rejects absolute URLs (e.g. "://" or "//") to prevent open redirect attacks.
|
||||
/// </summary>
|
||||
public static string GetSafeBasePath(this HttpRequest request)
|
||||
{
|
||||
var basePath = request.PathBase.Value?.TrimEnd('/') ?? "";
|
||||
if (basePath.Contains("://") || basePath.StartsWith("//"))
|
||||
{
|
||||
return "";
|
||||
}
|
||||
return basePath;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the external base URL (scheme + host + basePath), respecting
|
||||
/// X-Forwarded-Proto and X-Forwarded-Host headers when the connection
|
||||
/// originates from a local address.
|
||||
/// </summary>
|
||||
public static string GetExternalBaseUrl(this HttpContext context)
|
||||
{
|
||||
var request = context.Request;
|
||||
var scheme = request.Scheme;
|
||||
var host = request.Host.ToString();
|
||||
var remoteIp = context.Connection.RemoteIpAddress;
|
||||
|
||||
// Trust forwarded headers only from local connections
|
||||
// (consistent with TrustedNetworkAuthenticationHandler)
|
||||
if (remoteIp is not null && remoteIp.IsLocalAddress())
|
||||
{
|
||||
scheme = request.Headers["X-Forwarded-Proto"].FirstOrDefault() ?? scheme;
|
||||
host = request.Headers["X-Forwarded-Host"].FirstOrDefault() ?? host;
|
||||
}
|
||||
|
||||
var basePath = request.GetSafeBasePath();
|
||||
return $"{scheme}://{host}{basePath}";
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
using System;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Arr.Contracts.Requests;
|
||||
|
||||
@@ -23,16 +25,24 @@ public sealed record ArrInstanceRequest
|
||||
|
||||
public string? ExternalUrl { get; init; }
|
||||
|
||||
public ArrInstance ToEntity(Guid configId) => new()
|
||||
public ArrInstance ToEntity(Guid configId)
|
||||
{
|
||||
Enabled = Enabled,
|
||||
Name = Name,
|
||||
Url = new Uri(Url),
|
||||
ExternalUrl = ExternalUrl is not null ? new Uri(ExternalUrl) : null,
|
||||
ApiKey = ApiKey,
|
||||
ArrConfigId = configId,
|
||||
Version = Version,
|
||||
};
|
||||
if (ApiKey.IsPlaceholder())
|
||||
{
|
||||
throw new ValidationException("API key is required when creating a new instance");
|
||||
}
|
||||
|
||||
return new()
|
||||
{
|
||||
Enabled = Enabled,
|
||||
Name = Name,
|
||||
Url = new Uri(Url),
|
||||
ExternalUrl = ExternalUrl is not null ? new Uri(ExternalUrl) : null,
|
||||
ApiKey = ApiKey,
|
||||
ArrConfigId = configId,
|
||||
Version = Version,
|
||||
};
|
||||
}
|
||||
|
||||
public void ApplyTo(ArrInstance instance)
|
||||
{
|
||||
@@ -40,7 +50,7 @@ public sealed record ArrInstanceRequest
|
||||
instance.Name = Name;
|
||||
instance.Url = new Uri(Url);
|
||||
instance.ExternalUrl = ExternalUrl is not null ? new Uri(ExternalUrl) : null;
|
||||
instance.ApiKey = ApiKey;
|
||||
instance.ApiKey = ApiKey.IsPlaceholder() ? instance.ApiKey : ApiKey;
|
||||
instance.Version = Version;
|
||||
}
|
||||
}
|
||||
+23
-9
@@ -1,7 +1,9 @@
|
||||
using System;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Arr.Contracts.Requests;
|
||||
|
||||
@@ -12,17 +14,29 @@ public sealed record TestArrInstanceRequest
|
||||
|
||||
[Required]
|
||||
public required string ApiKey { get; init; }
|
||||
|
||||
|
||||
[Required]
|
||||
public required float Version { get; init; }
|
||||
|
||||
public ArrInstance ToTestInstance() => new()
|
||||
public Guid? InstanceId { get; init; }
|
||||
|
||||
public ArrInstance ToTestInstance(string? resolvedApiKey = null)
|
||||
{
|
||||
Enabled = true,
|
||||
Name = "Test Instance",
|
||||
Url = new Uri(Url),
|
||||
ApiKey = ApiKey,
|
||||
ArrConfigId = Guid.Empty,
|
||||
Version = Version,
|
||||
};
|
||||
var apiKey = resolvedApiKey ?? ApiKey;
|
||||
|
||||
if (apiKey.IsPlaceholder())
|
||||
{
|
||||
throw new ValidationException("API key cannot be a placeholder value");
|
||||
}
|
||||
|
||||
return new()
|
||||
{
|
||||
Enabled = true,
|
||||
Name = "Test Instance",
|
||||
Url = new Uri(Url),
|
||||
ApiKey = apiKey,
|
||||
ArrConfigId = Guid.Empty,
|
||||
Version = Version,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,9 @@ using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.Arr.Dtos;
|
||||
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
using Mapster;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
@@ -11,6 +13,7 @@ namespace Cleanuparr.Api.Features.Arr.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/configuration")]
|
||||
[Authorize]
|
||||
public sealed class ArrConfigController : ControllerBase
|
||||
{
|
||||
private readonly ILogger<ArrConfigController> _logger;
|
||||
@@ -282,7 +285,23 @@ public sealed class ArrConfigController : ControllerBase
|
||||
{
|
||||
try
|
||||
{
|
||||
var testInstance = request.ToTestInstance();
|
||||
string? resolvedApiKey = null;
|
||||
|
||||
if (request.ApiKey.IsPlaceholder() && request.InstanceId.HasValue)
|
||||
{
|
||||
var existingInstance = await _dataContext.ArrInstances
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(i => i.Id == request.InstanceId.Value);
|
||||
|
||||
if (existingInstance is null)
|
||||
{
|
||||
return NotFound($"Instance with ID {request.InstanceId.Value} not found");
|
||||
}
|
||||
|
||||
resolvedApiKey = existingInstance.ApiKey;
|
||||
}
|
||||
|
||||
var testInstance = request.ToTestInstance(resolvedApiKey);
|
||||
var client = _arrClientFactory.GetClient(type, request.Version);
|
||||
await client.HealthCheckAsync(testInstance);
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Auth.Contracts.Requests;
|
||||
|
||||
public sealed record OidcExchangeRequest
|
||||
{
|
||||
[Required]
|
||||
public required string Code { get; init; }
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
using Cleanuparr.Infrastructure.Features.Auth;
|
||||
using Cleanuparr.Persistence.Models.Auth;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Auth.Contracts.Requests;
|
||||
|
||||
public sealed record UpdateOidcConfigRequest
|
||||
{
|
||||
public bool Enabled { get; init; }
|
||||
|
||||
public string IssuerUrl { get; init; } = string.Empty;
|
||||
|
||||
public string ClientId { get; init; } = string.Empty;
|
||||
|
||||
public string ClientSecret { get; init; } = string.Empty;
|
||||
|
||||
public string Scopes { get; init; } = "openid profile email";
|
||||
|
||||
public string ProviderName { get; init; } = "OIDC";
|
||||
|
||||
public string RedirectUrl { get; init; } = string.Empty;
|
||||
|
||||
public bool ExclusiveMode { get; init; }
|
||||
|
||||
public void ApplyTo(OidcConfig existingConfig)
|
||||
{
|
||||
var previousIssuerUrl = existingConfig.IssuerUrl;
|
||||
|
||||
existingConfig.Enabled = Enabled;
|
||||
existingConfig.IssuerUrl = IssuerUrl;
|
||||
existingConfig.ClientId = ClientId;
|
||||
existingConfig.Scopes = Scopes;
|
||||
existingConfig.ProviderName = ProviderName;
|
||||
existingConfig.RedirectUrl = RedirectUrl;
|
||||
existingConfig.ExclusiveMode = ExclusiveMode;
|
||||
|
||||
if (!ClientSecret.IsPlaceholder())
|
||||
{
|
||||
existingConfig.ClientSecret = ClientSecret;
|
||||
}
|
||||
|
||||
// AuthorizedSubject is intentionally NOT mapped here — it is set only via the OIDC link callback
|
||||
|
||||
if (previousIssuerUrl != IssuerUrl)
|
||||
{
|
||||
OidcAuthService.ClearDiscoveryCache();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,4 +5,7 @@ public sealed record AuthStatusResponse
|
||||
public required bool SetupCompleted { get; init; }
|
||||
public bool PlexLinked { get; init; }
|
||||
public bool AuthBypassActive { get; init; }
|
||||
public bool OidcEnabled { get; init; }
|
||||
public string OidcProviderName { get; init; } = string.Empty;
|
||||
public bool OidcExclusiveMode { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace Cleanuparr.Api.Features.Auth.Contracts.Responses;
|
||||
|
||||
public sealed record OidcStartResponse
|
||||
{
|
||||
public required string AuthorizationUrl { get; init; }
|
||||
}
|
||||
@@ -1,25 +1,30 @@
|
||||
using System.Security.Claims;
|
||||
using System.Security.Cryptography;
|
||||
using Cleanuparr.Api.Extensions;
|
||||
using Cleanuparr.Api.Features.Auth.Contracts.Requests;
|
||||
using Cleanuparr.Api.Features.Auth.Contracts.Responses;
|
||||
using Cleanuparr.Api.Filters;
|
||||
using Cleanuparr.Infrastructure.Features.Auth;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Auth;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Auth.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/account")]
|
||||
[Authorize]
|
||||
[NoCache]
|
||||
public sealed class AccountController : ControllerBase
|
||||
{
|
||||
private readonly UsersContext _usersContext;
|
||||
private readonly IPasswordService _passwordService;
|
||||
private readonly ITotpService _totpService;
|
||||
private readonly IPlexAuthService _plexAuthService;
|
||||
private readonly IOidcAuthService _oidcAuthService;
|
||||
private readonly ILogger<AccountController> _logger;
|
||||
|
||||
public AccountController(
|
||||
@@ -27,12 +32,14 @@ public sealed class AccountController : ControllerBase
|
||||
IPasswordService passwordService,
|
||||
ITotpService totpService,
|
||||
IPlexAuthService plexAuthService,
|
||||
IOidcAuthService oidcAuthService,
|
||||
ILogger<AccountController> logger)
|
||||
{
|
||||
_usersContext = usersContext;
|
||||
_passwordService = passwordService;
|
||||
_totpService = totpService;
|
||||
_plexAuthService = plexAuthService;
|
||||
_oidcAuthService = oidcAuthService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -40,7 +47,10 @@ public sealed class AccountController : ControllerBase
|
||||
public async Task<IActionResult> GetAccountInfo()
|
||||
{
|
||||
var user = await GetCurrentUser();
|
||||
if (user is null) return Unauthorized();
|
||||
if (user is null)
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
return Ok(new AccountInfoResponse
|
||||
{
|
||||
@@ -55,234 +65,230 @@ public sealed class AccountController : ControllerBase
|
||||
[HttpPut("password")]
|
||||
public async Task<IActionResult> ChangePassword([FromBody] ChangePasswordRequest request)
|
||||
{
|
||||
await UsersContext.Lock.WaitAsync();
|
||||
try
|
||||
if (await IsOidcExclusiveModeActive())
|
||||
{
|
||||
var user = await GetCurrentUser();
|
||||
if (user is null) return Unauthorized();
|
||||
|
||||
if (!_passwordService.VerifyPassword(request.CurrentPassword, user.PasswordHash))
|
||||
{
|
||||
return BadRequest(new { error = "Current password is incorrect" });
|
||||
}
|
||||
|
||||
user.PasswordHash = _passwordService.HashPassword(request.NewPassword);
|
||||
user.UpdatedAt = DateTime.UtcNow;
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Password changed for user {Username}", user.Username);
|
||||
|
||||
return Ok(new { message = "Password changed" });
|
||||
return StatusCode(403, new { error = "Password changes are disabled while OIDC exclusive mode is active." });
|
||||
}
|
||||
finally
|
||||
|
||||
var user = await GetCurrentUser();
|
||||
if (user is null)
|
||||
{
|
||||
UsersContext.Lock.Release();
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
if (!_passwordService.VerifyPassword(request.CurrentPassword, user.PasswordHash))
|
||||
{
|
||||
return BadRequest(new { error = "Current password is incorrect" });
|
||||
}
|
||||
|
||||
DateTime now = DateTime.UtcNow;
|
||||
|
||||
user.PasswordHash = _passwordService.HashPassword(request.NewPassword);
|
||||
user.UpdatedAt = now;
|
||||
|
||||
// Revoke all existing refresh tokens so old sessions can't be reused
|
||||
var activeTokens = await _usersContext.RefreshTokens
|
||||
.Where(r => r.UserId == user.Id && r.RevokedAt == null)
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var token in activeTokens)
|
||||
{
|
||||
token.RevokedAt = now;
|
||||
}
|
||||
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Password changed for user {Username}", user.Username);
|
||||
|
||||
return Ok(new { message = "Password changed" });
|
||||
}
|
||||
|
||||
[HttpPost("2fa/regenerate")]
|
||||
public async Task<IActionResult> Regenerate2fa([FromBody] Regenerate2faRequest request)
|
||||
{
|
||||
await UsersContext.Lock.WaitAsync();
|
||||
try
|
||||
var user = await GetCurrentUser(includeRecoveryCodes: true);
|
||||
if (user is null)
|
||||
{
|
||||
var user = await GetCurrentUser(includeRecoveryCodes: true);
|
||||
if (user is null) return Unauthorized();
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
// Verify current credentials
|
||||
if (!_passwordService.VerifyPassword(request.Password, user.PasswordHash))
|
||||
// Verify current credentials
|
||||
if (!_passwordService.VerifyPassword(request.Password, user.PasswordHash))
|
||||
{
|
||||
return BadRequest(new { error = "Incorrect password" });
|
||||
}
|
||||
|
||||
if (!_totpService.ValidateCode(user.TotpSecret, request.TotpCode))
|
||||
{
|
||||
return BadRequest(new { error = "Invalid 2FA code" });
|
||||
}
|
||||
|
||||
// Generate new TOTP
|
||||
var secret = _totpService.GenerateSecret();
|
||||
var qrUri = _totpService.GetQrCodeUri(secret, user.Username);
|
||||
var recoveryCodes = _totpService.GenerateRecoveryCodes();
|
||||
|
||||
user.TotpSecret = secret;
|
||||
user.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
// Replace recovery codes
|
||||
_usersContext.RecoveryCodes.RemoveRange(user.RecoveryCodes);
|
||||
|
||||
foreach (var code in recoveryCodes)
|
||||
{
|
||||
_usersContext.RecoveryCodes.Add(new RecoveryCode
|
||||
{
|
||||
return BadRequest(new { error = "Incorrect password" });
|
||||
}
|
||||
|
||||
if (!_totpService.ValidateCode(user.TotpSecret, request.TotpCode))
|
||||
{
|
||||
return BadRequest(new { error = "Invalid 2FA code" });
|
||||
}
|
||||
|
||||
// Generate new TOTP
|
||||
var secret = _totpService.GenerateSecret();
|
||||
var qrUri = _totpService.GetQrCodeUri(secret, user.Username);
|
||||
var recoveryCodes = _totpService.GenerateRecoveryCodes();
|
||||
|
||||
user.TotpSecret = secret;
|
||||
user.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
// Replace recovery codes
|
||||
_usersContext.RecoveryCodes.RemoveRange(user.RecoveryCodes);
|
||||
|
||||
foreach (var code in recoveryCodes)
|
||||
{
|
||||
_usersContext.RecoveryCodes.Add(new RecoveryCode
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = user.Id,
|
||||
CodeHash = _totpService.HashRecoveryCode(code),
|
||||
IsUsed = false
|
||||
});
|
||||
}
|
||||
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("2FA regenerated for user {Username}", user.Username);
|
||||
|
||||
return Ok(new TotpSetupResponse
|
||||
{
|
||||
Secret = secret,
|
||||
QrCodeUri = qrUri,
|
||||
RecoveryCodes = recoveryCodes
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = user.Id,
|
||||
CodeHash = _totpService.HashRecoveryCode(code),
|
||||
IsUsed = false
|
||||
});
|
||||
}
|
||||
finally
|
||||
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("2FA regenerated for user {Username}", user.Username);
|
||||
|
||||
return Ok(new TotpSetupResponse
|
||||
{
|
||||
UsersContext.Lock.Release();
|
||||
}
|
||||
Secret = secret,
|
||||
QrCodeUri = qrUri,
|
||||
RecoveryCodes = recoveryCodes
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("2fa/enable")]
|
||||
public async Task<IActionResult> Enable2fa([FromBody] Enable2faRequest request)
|
||||
{
|
||||
await UsersContext.Lock.WaitAsync();
|
||||
try
|
||||
var user = await GetCurrentUser(includeRecoveryCodes: true);
|
||||
if (user is null)
|
||||
{
|
||||
var user = await GetCurrentUser(includeRecoveryCodes: true);
|
||||
if (user is null) return Unauthorized();
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
if (user.TotpEnabled)
|
||||
if (user.TotpEnabled)
|
||||
{
|
||||
return Conflict(new { error = "2FA is already enabled" });
|
||||
}
|
||||
|
||||
if (!_passwordService.VerifyPassword(request.Password, user.PasswordHash))
|
||||
{
|
||||
return BadRequest(new { error = "Incorrect password" });
|
||||
}
|
||||
|
||||
// Generate new TOTP
|
||||
var secret = _totpService.GenerateSecret();
|
||||
var qrUri = _totpService.GetQrCodeUri(secret, user.Username);
|
||||
var recoveryCodes = _totpService.GenerateRecoveryCodes();
|
||||
|
||||
user.TotpSecret = secret;
|
||||
user.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
// Replace any existing recovery codes
|
||||
_usersContext.RecoveryCodes.RemoveRange(user.RecoveryCodes);
|
||||
|
||||
foreach (var code in recoveryCodes)
|
||||
{
|
||||
_usersContext.RecoveryCodes.Add(new RecoveryCode
|
||||
{
|
||||
return Conflict(new { error = "2FA is already enabled" });
|
||||
}
|
||||
|
||||
if (!_passwordService.VerifyPassword(request.Password, user.PasswordHash))
|
||||
{
|
||||
return BadRequest(new { error = "Incorrect password" });
|
||||
}
|
||||
|
||||
// Generate new TOTP
|
||||
var secret = _totpService.GenerateSecret();
|
||||
var qrUri = _totpService.GetQrCodeUri(secret, user.Username);
|
||||
var recoveryCodes = _totpService.GenerateRecoveryCodes();
|
||||
|
||||
user.TotpSecret = secret;
|
||||
user.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
// Replace any existing recovery codes
|
||||
_usersContext.RecoveryCodes.RemoveRange(user.RecoveryCodes);
|
||||
|
||||
foreach (var code in recoveryCodes)
|
||||
{
|
||||
_usersContext.RecoveryCodes.Add(new RecoveryCode
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = user.Id,
|
||||
CodeHash = _totpService.HashRecoveryCode(code),
|
||||
IsUsed = false
|
||||
});
|
||||
}
|
||||
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("2FA setup generated for user {Username}", user.Username);
|
||||
|
||||
return Ok(new TotpSetupResponse
|
||||
{
|
||||
Secret = secret,
|
||||
QrCodeUri = qrUri,
|
||||
RecoveryCodes = recoveryCodes
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = user.Id,
|
||||
CodeHash = _totpService.HashRecoveryCode(code),
|
||||
IsUsed = false
|
||||
});
|
||||
}
|
||||
finally
|
||||
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("2FA setup generated for user {Username}", user.Username);
|
||||
|
||||
return Ok(new TotpSetupResponse
|
||||
{
|
||||
UsersContext.Lock.Release();
|
||||
}
|
||||
Secret = secret,
|
||||
QrCodeUri = qrUri,
|
||||
RecoveryCodes = recoveryCodes
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("2fa/enable/verify")]
|
||||
public async Task<IActionResult> VerifyEnable2fa([FromBody] VerifyTotpRequest request)
|
||||
{
|
||||
await UsersContext.Lock.WaitAsync();
|
||||
try
|
||||
var user = await GetCurrentUser();
|
||||
if (user is null)
|
||||
{
|
||||
var user = await GetCurrentUser();
|
||||
if (user is null) return Unauthorized();
|
||||
|
||||
if (user.TotpEnabled)
|
||||
{
|
||||
return Conflict(new { error = "2FA is already enabled" });
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(user.TotpSecret))
|
||||
{
|
||||
return BadRequest(new { error = "Generate 2FA setup first" });
|
||||
}
|
||||
|
||||
if (!_totpService.ValidateCode(user.TotpSecret, request.Code))
|
||||
{
|
||||
return BadRequest(new { error = "Invalid verification code" });
|
||||
}
|
||||
|
||||
user.TotpEnabled = true;
|
||||
user.UpdatedAt = DateTime.UtcNow;
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("2FA enabled for user {Username}", user.Username);
|
||||
|
||||
return Ok(new { message = "2FA enabled" });
|
||||
return Unauthorized();
|
||||
}
|
||||
finally
|
||||
|
||||
if (user.TotpEnabled)
|
||||
{
|
||||
UsersContext.Lock.Release();
|
||||
return Conflict(new { error = "2FA is already enabled" });
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(user.TotpSecret))
|
||||
{
|
||||
return BadRequest(new { error = "Generate 2FA setup first" });
|
||||
}
|
||||
|
||||
if (!_totpService.ValidateCode(user.TotpSecret, request.Code))
|
||||
{
|
||||
return BadRequest(new { error = "Invalid verification code" });
|
||||
}
|
||||
|
||||
user.TotpEnabled = true;
|
||||
user.UpdatedAt = DateTime.UtcNow;
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("2FA enabled for user {Username}", user.Username);
|
||||
|
||||
return Ok(new { message = "2FA enabled" });
|
||||
}
|
||||
|
||||
[HttpPost("2fa/disable")]
|
||||
public async Task<IActionResult> Disable2fa([FromBody] Disable2faRequest request)
|
||||
{
|
||||
await UsersContext.Lock.WaitAsync();
|
||||
try
|
||||
var user = await GetCurrentUser(includeRecoveryCodes: true);
|
||||
if (user is null)
|
||||
{
|
||||
var user = await GetCurrentUser(includeRecoveryCodes: true);
|
||||
if (user is null) return Unauthorized();
|
||||
|
||||
if (!user.TotpEnabled)
|
||||
{
|
||||
return BadRequest(new { error = "2FA is not enabled" });
|
||||
}
|
||||
|
||||
if (!_passwordService.VerifyPassword(request.Password, user.PasswordHash))
|
||||
{
|
||||
return BadRequest(new { error = "Incorrect password" });
|
||||
}
|
||||
|
||||
if (!_totpService.ValidateCode(user.TotpSecret, request.TotpCode))
|
||||
{
|
||||
return BadRequest(new { error = "Invalid 2FA code" });
|
||||
}
|
||||
|
||||
user.TotpEnabled = false;
|
||||
user.TotpSecret = string.Empty;
|
||||
user.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
// Remove all recovery codes
|
||||
_usersContext.RecoveryCodes.RemoveRange(user.RecoveryCodes);
|
||||
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("2FA disabled for user {Username}", user.Username);
|
||||
|
||||
return Ok(new { message = "2FA disabled" });
|
||||
return Unauthorized();
|
||||
}
|
||||
finally
|
||||
|
||||
if (!user.TotpEnabled)
|
||||
{
|
||||
UsersContext.Lock.Release();
|
||||
return BadRequest(new { error = "2FA is not enabled" });
|
||||
}
|
||||
|
||||
if (!_passwordService.VerifyPassword(request.Password, user.PasswordHash))
|
||||
{
|
||||
return BadRequest(new { error = "Incorrect password" });
|
||||
}
|
||||
|
||||
if (!_totpService.ValidateCode(user.TotpSecret, request.TotpCode))
|
||||
{
|
||||
return BadRequest(new { error = "Invalid 2FA code" });
|
||||
}
|
||||
|
||||
user.TotpEnabled = false;
|
||||
user.TotpSecret = string.Empty;
|
||||
user.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
// Remove all recovery codes
|
||||
_usersContext.RecoveryCodes.RemoveRange(user.RecoveryCodes);
|
||||
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("2FA disabled for user {Username}", user.Username);
|
||||
|
||||
return Ok(new { message = "2FA disabled" });
|
||||
}
|
||||
|
||||
[HttpGet("api-key")]
|
||||
public async Task<IActionResult> GetApiKey()
|
||||
{
|
||||
var user = await GetCurrentUser();
|
||||
if (user is null) return Unauthorized();
|
||||
if (user is null)
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
return Ok(new { apiKey = user.ApiKey });
|
||||
}
|
||||
@@ -290,33 +296,33 @@ public sealed class AccountController : ControllerBase
|
||||
[HttpPost("api-key/regenerate")]
|
||||
public async Task<IActionResult> RegenerateApiKey()
|
||||
{
|
||||
await UsersContext.Lock.WaitAsync();
|
||||
try
|
||||
var user = await GetCurrentUser();
|
||||
if (user is null)
|
||||
{
|
||||
var user = await GetCurrentUser();
|
||||
if (user is null) return Unauthorized();
|
||||
|
||||
var bytes = new byte[32];
|
||||
using var rng = RandomNumberGenerator.Create();
|
||||
rng.GetBytes(bytes);
|
||||
|
||||
user.ApiKey = Convert.ToHexString(bytes).ToLowerInvariant();
|
||||
user.UpdatedAt = DateTime.UtcNow;
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("API key regenerated for user {Username}", user.Username);
|
||||
|
||||
return Ok(new { apiKey = user.ApiKey });
|
||||
}
|
||||
finally
|
||||
{
|
||||
UsersContext.Lock.Release();
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
var bytes = new byte[32];
|
||||
using var rng = RandomNumberGenerator.Create();
|
||||
rng.GetBytes(bytes);
|
||||
|
||||
user.ApiKey = Convert.ToHexString(bytes).ToLowerInvariant();
|
||||
user.UpdatedAt = DateTime.UtcNow;
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("API key regenerated for user {Username}", user.Username);
|
||||
|
||||
return Ok(new { apiKey = user.ApiKey });
|
||||
}
|
||||
|
||||
[HttpPost("plex/link")]
|
||||
public async Task<IActionResult> StartPlexLink()
|
||||
{
|
||||
if (await IsOidcExclusiveModeActive())
|
||||
{
|
||||
return StatusCode(403, new { error = "Plex account management is disabled while OIDC exclusive mode is active." });
|
||||
}
|
||||
|
||||
var pin = await _plexAuthService.RequestPin();
|
||||
|
||||
return Ok(new { pinId = pin.PinId, authUrl = pin.AuthUrl });
|
||||
@@ -325,6 +331,11 @@ public sealed class AccountController : ControllerBase
|
||||
[HttpPost("plex/link/verify")]
|
||||
public async Task<IActionResult> VerifyPlexLink([FromBody] PlexPinRequest request)
|
||||
{
|
||||
if (await IsOidcExclusiveModeActive())
|
||||
{
|
||||
return StatusCode(403, new { error = "Plex account management is disabled while OIDC exclusive mode is active." });
|
||||
}
|
||||
|
||||
var pinResult = await _plexAuthService.CheckPin(request.PinId);
|
||||
|
||||
if (!pinResult.Completed || pinResult.AuthToken is null)
|
||||
@@ -334,23 +345,194 @@ public sealed class AccountController : ControllerBase
|
||||
|
||||
var plexAccount = await _plexAuthService.GetAccount(pinResult.AuthToken);
|
||||
|
||||
var user = await GetCurrentUser();
|
||||
if (user is null)
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
user.PlexAccountId = plexAccount.AccountId;
|
||||
user.PlexUsername = plexAccount.Username;
|
||||
user.PlexEmail = plexAccount.Email;
|
||||
user.PlexAuthToken = pinResult.AuthToken;
|
||||
user.UpdatedAt = DateTime.UtcNow;
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Plex account linked for user {Username}: {PlexUsername}",
|
||||
user.Username, plexAccount.Username);
|
||||
|
||||
return Ok(new { completed = true, plexUsername = plexAccount.Username });
|
||||
}
|
||||
|
||||
[HttpDelete("plex/link")]
|
||||
public async Task<IActionResult> UnlinkPlex()
|
||||
{
|
||||
if (await IsOidcExclusiveModeActive())
|
||||
{
|
||||
return StatusCode(403, new { error = "Plex account management is disabled while OIDC exclusive mode is active." });
|
||||
}
|
||||
|
||||
var user = await GetCurrentUser();
|
||||
if (user is null)
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
user.PlexAccountId = null;
|
||||
user.PlexUsername = null;
|
||||
user.PlexEmail = null;
|
||||
user.PlexAuthToken = null;
|
||||
user.UpdatedAt = DateTime.UtcNow;
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Plex account unlinked for user {Username}", user.Username);
|
||||
|
||||
return Ok(new { message = "Plex account unlinked" });
|
||||
}
|
||||
|
||||
[HttpGet("oidc")]
|
||||
public async Task<IActionResult> GetOidcConfig()
|
||||
{
|
||||
var user = await GetCurrentUser();
|
||||
if (user is null)
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
return Ok(user.Oidc);
|
||||
}
|
||||
|
||||
[HttpPut("oidc")]
|
||||
public async Task<IActionResult> UpdateOidcConfig([FromBody] UpdateOidcConfigRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
var user = await GetCurrentUser();
|
||||
if (user is null)
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
request.ApplyTo(user.Oidc);
|
||||
user.Oidc.Validate();
|
||||
user.UpdatedAt = DateTime.UtcNow;
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
return Ok(new { message = "OIDC configuration updated" });
|
||||
}
|
||||
catch (ValidationException ex)
|
||||
{
|
||||
return BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("oidc/link")]
|
||||
public async Task<IActionResult> StartOidcLink()
|
||||
{
|
||||
var user = await GetCurrentUser();
|
||||
if (user is null)
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
if (user.Oidc is not { Enabled: true })
|
||||
{
|
||||
return BadRequest(new { error = "OIDC is not enabled" });
|
||||
}
|
||||
|
||||
var redirectUri = GetOidcLinkCallbackUrl(user.Oidc.RedirectUrl);
|
||||
_logger.LogDebug("OIDC link start: using redirect URI {RedirectUri}", redirectUri);
|
||||
|
||||
try
|
||||
{
|
||||
var result = await _oidcAuthService.StartAuthorization(redirectUri, user.Id.ToString());
|
||||
return Ok(new OidcStartResponse { AuthorizationUrl = result.AuthorizationUrl });
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to start OIDC link authorization");
|
||||
return StatusCode(429, new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// This endpoint must be [AllowAnonymous] because the IdP redirects the user's browser here
|
||||
/// without a Bearer token. Security is ensured by validating that the OIDC flow was initiated
|
||||
/// by an authenticated user (InitiatorUserId stored in the flow state during StartOidcLink).
|
||||
/// </remarks>
|
||||
[AllowAnonymous]
|
||||
[HttpGet("oidc/link/callback")]
|
||||
public async Task<IActionResult> OidcLinkCallback(
|
||||
[FromQuery] string? code,
|
||||
[FromQuery] string? state,
|
||||
[FromQuery] string? error)
|
||||
{
|
||||
var basePath = HttpContext.Request.GetSafeBasePath();
|
||||
|
||||
if (!string.IsNullOrEmpty(error) || string.IsNullOrEmpty(code) || string.IsNullOrEmpty(state))
|
||||
{
|
||||
return Redirect($"{basePath}/settings/account?oidc_link_error=failed");
|
||||
}
|
||||
|
||||
// Fetch any user to get the configured redirect URL for the OIDC callback
|
||||
var oidcConfig = (await _usersContext.Users.AsNoTracking().FirstOrDefaultAsync())?.Oidc;
|
||||
var redirectUri = GetOidcLinkCallbackUrl(oidcConfig?.RedirectUrl);
|
||||
_logger.LogDebug("OIDC link callback: using redirect URI {RedirectUri}", redirectUri);
|
||||
var result = await _oidcAuthService.HandleCallback(code, state, redirectUri);
|
||||
|
||||
if (!result.Success || string.IsNullOrEmpty(result.Subject))
|
||||
{
|
||||
_logger.LogWarning("OIDC link callback failed: {Error}", result.Error);
|
||||
return Redirect($"{basePath}/settings/account?oidc_link_error=failed");
|
||||
}
|
||||
|
||||
// Verify the flow was initiated by an authenticated user
|
||||
if (string.IsNullOrEmpty(result.InitiatorUserId) ||
|
||||
!Guid.TryParse(result.InitiatorUserId, out var initiatorId))
|
||||
{
|
||||
_logger.LogWarning("OIDC link callback missing initiator user ID");
|
||||
return Redirect($"{basePath}/settings/account?oidc_link_error=failed");
|
||||
}
|
||||
|
||||
// Save the authorized subject to the user's OIDC config
|
||||
var user = await _usersContext.Users.FirstOrDefaultAsync(u => u.Id == initiatorId);
|
||||
|
||||
if (user is null)
|
||||
{
|
||||
_logger.LogWarning("OIDC link callback initiator user not found: {UserId}", result.InitiatorUserId);
|
||||
return Redirect($"{basePath}/settings/account?oidc_link_error=failed");
|
||||
}
|
||||
|
||||
user.Oidc.AuthorizedSubject = result.Subject;
|
||||
user.UpdatedAt = DateTime.UtcNow;
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("OIDC account linked with subject: {Subject} by user: {Username}",
|
||||
result.Subject, user.Username);
|
||||
|
||||
return Redirect($"{basePath}/settings/account?oidc_link=success");
|
||||
}
|
||||
|
||||
[HttpDelete("oidc/link")]
|
||||
public async Task<IActionResult> UnlinkOidc()
|
||||
{
|
||||
await UsersContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var user = await GetCurrentUser();
|
||||
if (user is null) return Unauthorized();
|
||||
if (user is null)
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
user.PlexAccountId = plexAccount.AccountId;
|
||||
user.PlexUsername = plexAccount.Username;
|
||||
user.PlexEmail = plexAccount.Email;
|
||||
user.PlexAuthToken = pinResult.AuthToken;
|
||||
user.Oidc.AuthorizedSubject = string.Empty;
|
||||
user.Oidc.ExclusiveMode = false;
|
||||
user.UpdatedAt = DateTime.UtcNow;
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Plex account linked for user {Username}: {PlexUsername}",
|
||||
user.Username, plexAccount.Username);
|
||||
_logger.LogInformation("OIDC account unlinked for user {Username}", user.Username);
|
||||
|
||||
return Ok(new { completed = true, plexUsername = plexAccount.Username });
|
||||
return Ok(new { message = "OIDC account unlinked" });
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -358,30 +540,24 @@ public sealed class AccountController : ControllerBase
|
||||
}
|
||||
}
|
||||
|
||||
[HttpDelete("plex/link")]
|
||||
public async Task<IActionResult> UnlinkPlex()
|
||||
private string GetOidcLinkCallbackUrl(string? redirectUrl = null)
|
||||
{
|
||||
await UsersContext.Lock.WaitAsync();
|
||||
try
|
||||
var baseUrl = string.IsNullOrEmpty(redirectUrl)
|
||||
? HttpContext.GetExternalBaseUrl()
|
||||
: redirectUrl.TrimEnd('/');
|
||||
return $"{baseUrl}/api/account/oidc/link/callback";
|
||||
}
|
||||
|
||||
private async Task<bool> IsOidcExclusiveModeActive()
|
||||
{
|
||||
var user = await _usersContext.Users.AsNoTracking().FirstOrDefaultAsync();
|
||||
if (user is not { SetupCompleted: true })
|
||||
{
|
||||
var user = await GetCurrentUser();
|
||||
if (user is null) return Unauthorized();
|
||||
|
||||
user.PlexAccountId = null;
|
||||
user.PlexUsername = null;
|
||||
user.PlexEmail = null;
|
||||
user.PlexAuthToken = null;
|
||||
user.UpdatedAt = DateTime.UtcNow;
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Plex account unlinked for user {Username}", user.Username);
|
||||
|
||||
return Ok(new { message = "Plex account unlinked" });
|
||||
}
|
||||
finally
|
||||
{
|
||||
UsersContext.Lock.Release();
|
||||
return false;
|
||||
}
|
||||
|
||||
var oidc = user.Oidc;
|
||||
return oidc is { Enabled: true, ExclusiveMode: true };
|
||||
}
|
||||
|
||||
private async Task<User?> GetCurrentUser(bool includeRecoveryCodes = false)
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
using System.Security.Cryptography;
|
||||
using Cleanuparr.Api.Auth;
|
||||
using Cleanuparr.Api.Extensions;
|
||||
using Cleanuparr.Api.Features.Auth.Contracts.Requests;
|
||||
using Cleanuparr.Api.Features.Auth.Contracts.Responses;
|
||||
using Cleanuparr.Api.Filters;
|
||||
using Cleanuparr.Infrastructure.Features.Auth;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Auth;
|
||||
@@ -14,28 +16,35 @@ namespace Cleanuparr.Api.Features.Auth.Controllers;
|
||||
[ApiController]
|
||||
[Route("api/auth")]
|
||||
[AllowAnonymous]
|
||||
[NoCache]
|
||||
public sealed class AuthController : ControllerBase
|
||||
{
|
||||
private readonly UsersContext _usersContext;
|
||||
private readonly DataContext _dataContext;
|
||||
private readonly IJwtService _jwtService;
|
||||
private readonly IPasswordService _passwordService;
|
||||
private readonly ITotpService _totpService;
|
||||
private readonly IPlexAuthService _plexAuthService;
|
||||
private readonly IOidcAuthService _oidcAuthService;
|
||||
private readonly ILogger<AuthController> _logger;
|
||||
|
||||
public AuthController(
|
||||
UsersContext usersContext,
|
||||
DataContext dataContext,
|
||||
IJwtService jwtService,
|
||||
IPasswordService passwordService,
|
||||
ITotpService totpService,
|
||||
IPlexAuthService plexAuthService,
|
||||
IOidcAuthService oidcAuthService,
|
||||
ILogger<AuthController> logger)
|
||||
{
|
||||
_usersContext = usersContext;
|
||||
_dataContext = dataContext;
|
||||
_jwtService = jwtService;
|
||||
_passwordService = passwordService;
|
||||
_totpService = totpService;
|
||||
_plexAuthService = plexAuthService;
|
||||
_oidcAuthService = oidcAuthService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -45,8 +54,7 @@ public sealed class AuthController : ControllerBase
|
||||
var user = await _usersContext.Users.AsNoTracking().FirstOrDefaultAsync();
|
||||
|
||||
var authBypass = false;
|
||||
await using var dataContext = DataContext.CreateStaticInstance();
|
||||
var generalConfig = await dataContext.GeneralConfigs.AsNoTracking().FirstOrDefaultAsync();
|
||||
var generalConfig = await _dataContext.GeneralConfigs.AsNoTracking().FirstOrDefaultAsync();
|
||||
if (generalConfig is { Auth.DisableAuthForLocalAddresses: true })
|
||||
{
|
||||
var clientIp = TrustedNetworkAuthenticationHandler.ResolveClientIp(
|
||||
@@ -58,11 +66,21 @@ public sealed class AuthController : ControllerBase
|
||||
}
|
||||
}
|
||||
|
||||
var oidcConfig = user?.Oidc;
|
||||
var oidcEnabled = oidcConfig is { Enabled: true } &&
|
||||
!string.IsNullOrEmpty(oidcConfig.IssuerUrl) &&
|
||||
!string.IsNullOrEmpty(oidcConfig.ClientId);
|
||||
|
||||
var oidcExclusiveMode = oidcEnabled && oidcConfig!.ExclusiveMode;
|
||||
|
||||
return Ok(new AuthStatusResponse
|
||||
{
|
||||
SetupCompleted = user is { SetupCompleted: true },
|
||||
PlexLinked = user?.PlexAccountId is not null,
|
||||
AuthBypassActive = authBypass
|
||||
AuthBypassActive = authBypass,
|
||||
OidcEnabled = oidcEnabled,
|
||||
OidcProviderName = oidcEnabled ? oidcConfig!.ProviderName : string.Empty,
|
||||
OidcExclusiveMode = oidcExclusiveMode
|
||||
});
|
||||
}
|
||||
|
||||
@@ -119,9 +137,9 @@ public sealed class AuthController : ControllerBase
|
||||
return BadRequest(new { error = "Create an account first" });
|
||||
}
|
||||
|
||||
if (user.SetupCompleted && user.TotpEnabled)
|
||||
if (user.SetupCompleted)
|
||||
{
|
||||
return Conflict(new { error = "2FA is already configured" });
|
||||
return Conflict(new { error = "Setup already completed. Use account settings to manage 2FA." });
|
||||
}
|
||||
|
||||
// Generate new TOTP secret
|
||||
@@ -176,6 +194,11 @@ public sealed class AuthController : ControllerBase
|
||||
return BadRequest(new { error = "Create an account first" });
|
||||
}
|
||||
|
||||
if (user.SetupCompleted)
|
||||
{
|
||||
return Conflict(new { error = "Setup already completed. Use account settings to manage 2FA." });
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(user.TotpSecret))
|
||||
{
|
||||
return BadRequest(new { error = "Generate 2FA setup first" });
|
||||
@@ -212,6 +235,11 @@ public sealed class AuthController : ControllerBase
|
||||
return BadRequest(new { error = "Create an account first" });
|
||||
}
|
||||
|
||||
if (user.SetupCompleted)
|
||||
{
|
||||
return Conflict(new { error = "Setup already completed" });
|
||||
}
|
||||
|
||||
user.SetupCompleted = true;
|
||||
user.UpdatedAt = DateTime.UtcNow;
|
||||
await _usersContext.SaveChangesAsync();
|
||||
@@ -229,8 +257,18 @@ public sealed class AuthController : ControllerBase
|
||||
[HttpPost("login")]
|
||||
public async Task<IActionResult> Login([FromBody] LoginRequest request)
|
||||
{
|
||||
if (await IsOidcExclusiveModeActive())
|
||||
{
|
||||
return StatusCode(403, new { error = "Login with credentials is disabled. Use OIDC to sign in." });
|
||||
}
|
||||
|
||||
var user = await _usersContext.Users.AsNoTracking().FirstOrDefaultAsync();
|
||||
|
||||
// Always verify the submitted password to prevent timing-based username enumeration
|
||||
var userHasPassword = user?.PasswordHash is not null;
|
||||
var passwordHash = user?.PasswordHash ?? _passwordService.DummyHash;
|
||||
var passwordValid = _passwordService.VerifyPassword(request.Password, passwordHash) && userHasPassword;
|
||||
|
||||
if (user is null || !user.SetupCompleted)
|
||||
{
|
||||
return Unauthorized(new { error = "Invalid credentials" });
|
||||
@@ -239,12 +277,11 @@ public sealed class AuthController : ControllerBase
|
||||
// Check lockout
|
||||
if (user.LockoutEnd.HasValue && user.LockoutEnd.Value > DateTime.UtcNow)
|
||||
{
|
||||
var remaining = (int)(user.LockoutEnd.Value - DateTime.UtcNow).TotalSeconds;
|
||||
var remaining = (int)Math.Ceiling((user.LockoutEnd.Value - DateTime.UtcNow).TotalSeconds);
|
||||
return StatusCode(429, new { error = "Account is locked", retryAfterSeconds = remaining });
|
||||
}
|
||||
|
||||
if (!_passwordService.VerifyPassword(request.Password, user.PasswordHash) ||
|
||||
!string.Equals(user.Username, request.Username, StringComparison.OrdinalIgnoreCase))
|
||||
if (!passwordValid || !string.Equals(user.Username, request.Username, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var retryAfterSeconds = await IncrementFailedAttempts(user.Id);
|
||||
return Unauthorized(new { error = "Invalid credentials", retryAfterSeconds });
|
||||
@@ -282,6 +319,11 @@ public sealed class AuthController : ControllerBase
|
||||
[HttpPost("login/2fa")]
|
||||
public async Task<IActionResult> VerifyTwoFactor([FromBody] TwoFactorRequest request)
|
||||
{
|
||||
if (await IsOidcExclusiveModeActive())
|
||||
{
|
||||
return StatusCode(403, new { error = "Login with credentials is disabled. Use OIDC to sign in." });
|
||||
}
|
||||
|
||||
var userId = _jwtService.ValidateLoginToken(request.LoginToken);
|
||||
if (userId is null)
|
||||
{
|
||||
@@ -382,6 +424,11 @@ public sealed class AuthController : ControllerBase
|
||||
return BadRequest(new { error = "Create an account first" });
|
||||
}
|
||||
|
||||
if (user.SetupCompleted)
|
||||
{
|
||||
return Conflict(new { error = "Setup already completed. Use account settings to manage Plex." });
|
||||
}
|
||||
|
||||
var pin = await _plexAuthService.RequestPin();
|
||||
|
||||
return Ok(new PlexPinStatusResponse
|
||||
@@ -412,6 +459,11 @@ public sealed class AuthController : ControllerBase
|
||||
return BadRequest(new { error = "Create an account first" });
|
||||
}
|
||||
|
||||
if (user.SetupCompleted)
|
||||
{
|
||||
return Conflict(new { error = "Setup already completed. Use account settings to manage Plex." });
|
||||
}
|
||||
|
||||
user.PlexAccountId = plexAccount.AccountId;
|
||||
user.PlexUsername = plexAccount.Username;
|
||||
user.PlexEmail = plexAccount.Email;
|
||||
@@ -433,6 +485,11 @@ public sealed class AuthController : ControllerBase
|
||||
[HttpPost("login/plex/pin")]
|
||||
public async Task<IActionResult> RequestPlexPin()
|
||||
{
|
||||
if (await IsOidcExclusiveModeActive())
|
||||
{
|
||||
return StatusCode(403, new { error = "Plex login is disabled. Use OIDC to sign in." });
|
||||
}
|
||||
|
||||
var user = await _usersContext.Users.AsNoTracking().FirstOrDefaultAsync();
|
||||
if (user is null || !user.SetupCompleted || user.PlexAccountId is null)
|
||||
{
|
||||
@@ -451,6 +508,11 @@ public sealed class AuthController : ControllerBase
|
||||
[HttpPost("login/plex/verify")]
|
||||
public async Task<IActionResult> VerifyPlexLogin([FromBody] PlexPinRequest request)
|
||||
{
|
||||
if (await IsOidcExclusiveModeActive())
|
||||
{
|
||||
return StatusCode(403, new { error = "Plex login is disabled. Use OIDC to sign in." });
|
||||
}
|
||||
|
||||
var user = await _usersContext.Users.FirstOrDefaultAsync();
|
||||
if (user is null || !user.SetupCompleted || user.PlexAccountId is null)
|
||||
{
|
||||
@@ -472,7 +534,10 @@ public sealed class AuthController : ControllerBase
|
||||
return Unauthorized(new { error = "Plex account does not match the linked account" });
|
||||
}
|
||||
|
||||
// Plex login bypasses 2FA
|
||||
// Plex OAuth acts as a trusted identity provider — the user explicitly linked their
|
||||
// Plex account during setup or via account settings (both require authentication).
|
||||
// Since Plex login verifies the exact same Plex account ID that was linked,
|
||||
// 2FA is not required for Plex login.
|
||||
_logger.LogInformation("User {Username} logged in via Plex", user.Username);
|
||||
|
||||
var tokenResponse = await GenerateTokenResponse(user);
|
||||
@@ -484,6 +549,119 @@ public sealed class AuthController : ControllerBase
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("oidc/start")]
|
||||
public async Task<IActionResult> StartOidc()
|
||||
{
|
||||
var user = await _usersContext.Users.AsNoTracking().FirstOrDefaultAsync();
|
||||
var oidcConfig = user?.Oidc;
|
||||
|
||||
if (oidcConfig is not { Enabled: true } ||
|
||||
string.IsNullOrEmpty(oidcConfig.IssuerUrl) ||
|
||||
string.IsNullOrEmpty(oidcConfig.ClientId))
|
||||
{
|
||||
return BadRequest(new { error = "OIDC is not enabled or not configured" });
|
||||
}
|
||||
|
||||
var redirectUri = GetOidcCallbackUrl(oidcConfig.RedirectUrl);
|
||||
_logger.LogDebug("OIDC login start: using redirect URI {RedirectUri}", redirectUri);
|
||||
|
||||
try
|
||||
{
|
||||
var result = await _oidcAuthService.StartAuthorization(redirectUri);
|
||||
return Ok(new OidcStartResponse { AuthorizationUrl = result.AuthorizationUrl });
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to start OIDC authorization");
|
||||
return StatusCode(429, new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("oidc/callback")]
|
||||
public async Task<IActionResult> OidcCallback(
|
||||
[FromQuery] string? code,
|
||||
[FromQuery] string? state,
|
||||
[FromQuery] string? error)
|
||||
{
|
||||
var basePath = HttpContext.Request.GetSafeBasePath();
|
||||
|
||||
// Handle IdP error responses
|
||||
if (!string.IsNullOrEmpty(error))
|
||||
{
|
||||
_logger.LogWarning("OIDC callback received error: {Error}", error);
|
||||
return Redirect($"{basePath}/auth/login?oidc_error=provider_error");
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(code) || string.IsNullOrEmpty(state))
|
||||
{
|
||||
return Redirect($"{basePath}/auth/login?oidc_error=invalid_request");
|
||||
}
|
||||
|
||||
// Load the user early so we can use the configured redirect URL
|
||||
var user = await _usersContext.Users.FirstOrDefaultAsync(u => u.SetupCompleted);
|
||||
if (user is null)
|
||||
{
|
||||
return Redirect($"{basePath}/auth/login?oidc_error=no_account");
|
||||
}
|
||||
|
||||
var redirectUri = GetOidcCallbackUrl(user.Oidc.RedirectUrl);
|
||||
_logger.LogDebug("OIDC login callback: using redirect URI {RedirectUri}", redirectUri);
|
||||
var result = await _oidcAuthService.HandleCallback(code, state, redirectUri);
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
_logger.LogWarning("OIDC callback failed: {Error}", result.Error);
|
||||
return Redirect($"{basePath}/auth/login?oidc_error=authentication_failed");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(user.Oidc.AuthorizedSubject) &&
|
||||
result.Subject != user.Oidc.AuthorizedSubject)
|
||||
{
|
||||
_logger.LogWarning("OIDC subject mismatch. Expected: {Expected}, Got: {Got}",
|
||||
user.Oidc.AuthorizedSubject, result.Subject);
|
||||
return Redirect($"{basePath}/auth/login?oidc_error=unauthorized");
|
||||
}
|
||||
|
||||
var tokenResponse = await GenerateTokenResponse(user);
|
||||
|
||||
// Store tokens with a one-time code (never put tokens in the URL)
|
||||
var oneTimeCode = _oidcAuthService.StoreOneTimeCode(
|
||||
tokenResponse.AccessToken,
|
||||
tokenResponse.RefreshToken,
|
||||
tokenResponse.ExpiresIn);
|
||||
|
||||
_logger.LogInformation("User {Username} authenticated via OIDC (subject: {Subject})",
|
||||
user.Username, result.Subject);
|
||||
|
||||
return Redirect($"{basePath}/auth/oidc/callback?code={Uri.EscapeDataString(oneTimeCode)}");
|
||||
}
|
||||
|
||||
[HttpPost("oidc/exchange")]
|
||||
public IActionResult ExchangeOidcCode([FromBody] OidcExchangeRequest request)
|
||||
{
|
||||
var result = _oidcAuthService.ExchangeOneTimeCode(request.Code);
|
||||
|
||||
if (result is null)
|
||||
{
|
||||
return NotFound(new { error = "Invalid or expired code" });
|
||||
}
|
||||
|
||||
return Ok(new TokenResponse
|
||||
{
|
||||
AccessToken = result.AccessToken,
|
||||
RefreshToken = result.RefreshToken,
|
||||
ExpiresIn = result.ExpiresIn
|
||||
});
|
||||
}
|
||||
|
||||
private string GetOidcCallbackUrl(string? redirectUrl = null)
|
||||
{
|
||||
var baseUrl = string.IsNullOrEmpty(redirectUrl)
|
||||
? HttpContext.GetExternalBaseUrl()
|
||||
: redirectUrl.TrimEnd('/');
|
||||
return $"{baseUrl}/api/auth/oidc/callback";
|
||||
}
|
||||
|
||||
private async Task<TokenResponse> GenerateTokenResponse(User user)
|
||||
{
|
||||
var accessToken = _jwtService.GenerateAccessToken(user);
|
||||
@@ -504,7 +682,7 @@ public sealed class AuthController : ControllerBase
|
||||
{
|
||||
AccessToken = accessToken,
|
||||
RefreshToken = refreshToken,
|
||||
ExpiresIn = 60 // seconds
|
||||
ExpiresIn = 3600 // seconds
|
||||
};
|
||||
}
|
||||
|
||||
@@ -585,4 +763,16 @@ public sealed class AuthController : ControllerBase
|
||||
var hash = SHA256.HashData(bytes);
|
||||
return Convert.ToBase64String(hash);
|
||||
}
|
||||
|
||||
private async Task<bool> IsOidcExclusiveModeActive()
|
||||
{
|
||||
var user = await _usersContext.Users.AsNoTracking().FirstOrDefaultAsync();
|
||||
if (user is not { SetupCompleted: true })
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var oidc = user.Oidc;
|
||||
return oidc is { Enabled: true, ExclusiveMode: true };
|
||||
}
|
||||
}
|
||||
+2
@@ -6,6 +6,7 @@ using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Services.Interfaces;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration.BlacklistSync;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -14,6 +15,7 @@ namespace Cleanuparr.Api.Features.BlacklistSync.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/configuration")]
|
||||
[Authorize]
|
||||
public sealed class BlacklistSyncConfigController : ControllerBase
|
||||
{
|
||||
private readonly ILogger<BlacklistSyncConfigController> _logger;
|
||||
|
||||
+2
@@ -9,6 +9,7 @@ using Cleanuparr.Infrastructure.Utilities;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -17,6 +18,7 @@ namespace Cleanuparr.Api.Features.DownloadCleaner.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/configuration")]
|
||||
[Authorize]
|
||||
public sealed class DownloadCleanerConfigController : ControllerBase
|
||||
{
|
||||
private readonly ILogger<DownloadCleanerConfigController> _logger;
|
||||
|
||||
+6
@@ -3,6 +3,7 @@ using System;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Domain.Exceptions;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
|
||||
namespace Cleanuparr.Api.Features.DownloadClient.Contracts.Requests;
|
||||
|
||||
@@ -47,6 +48,11 @@ public sealed record CreateDownloadClientRequest
|
||||
{
|
||||
throw new ValidationException("External URL is not a valid URL");
|
||||
}
|
||||
|
||||
if (Password.IsPlaceholder())
|
||||
{
|
||||
throw new ValidationException("Password cannot be a placeholder value");
|
||||
}
|
||||
}
|
||||
|
||||
public DownloadClientConfig ToEntity() => new()
|
||||
|
||||
+24
-11
@@ -3,6 +3,7 @@ using System;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Domain.Exceptions;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
|
||||
namespace Cleanuparr.Api.Features.DownloadClient.Contracts.Requests;
|
||||
|
||||
@@ -20,6 +21,8 @@ public sealed record TestDownloadClientRequest
|
||||
|
||||
public string? UrlBase { get; init; }
|
||||
|
||||
public Guid? ClientId { get; init; }
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(Host))
|
||||
@@ -33,16 +36,26 @@ public sealed record TestDownloadClientRequest
|
||||
}
|
||||
}
|
||||
|
||||
public DownloadClientConfig ToTestConfig() => new()
|
||||
public DownloadClientConfig ToTestConfig(string? resolvedPassword = null)
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Enabled = true,
|
||||
Name = "Test Client",
|
||||
TypeName = TypeName,
|
||||
Type = Type,
|
||||
Host = new Uri(Host!, UriKind.RelativeOrAbsolute),
|
||||
Username = Username,
|
||||
Password = Password,
|
||||
UrlBase = UrlBase,
|
||||
};
|
||||
var password = resolvedPassword ?? Password;
|
||||
|
||||
if (password.IsPlaceholder())
|
||||
{
|
||||
throw new ValidationException("Password cannot be a placeholder value");
|
||||
}
|
||||
|
||||
return new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Enabled = true,
|
||||
Name = "Test Client",
|
||||
TypeName = TypeName,
|
||||
Type = Type,
|
||||
Host = new Uri(Host!, UriKind.RelativeOrAbsolute),
|
||||
Username = Username,
|
||||
Password = password,
|
||||
UrlBase = UrlBase,
|
||||
};
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -3,6 +3,7 @@ using System;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Domain.Exceptions;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
|
||||
namespace Cleanuparr.Api.Features.DownloadClient.Contracts.Requests;
|
||||
|
||||
@@ -57,7 +58,7 @@ public sealed record UpdateDownloadClientRequest
|
||||
Type = Type,
|
||||
Host = new Uri(Host!, UriKind.RelativeOrAbsolute),
|
||||
Username = Username,
|
||||
Password = Password,
|
||||
Password = Password.IsPlaceholder() ? existing.Password : Password,
|
||||
UrlBase = UrlBase,
|
||||
ExternalUrl = !string.IsNullOrWhiteSpace(ExternalUrl) ? new Uri(ExternalUrl, UriKind.RelativeOrAbsolute) : null,
|
||||
};
|
||||
|
||||
+20
-1
@@ -5,6 +5,8 @@ using Cleanuparr.Api.Features.DownloadClient.Contracts.Requests;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient;
|
||||
using Cleanuparr.Infrastructure.Http.DynamicHttpClientSystem;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
@@ -12,6 +14,7 @@ namespace Cleanuparr.Api.Features.DownloadClient.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/configuration")]
|
||||
[Authorize]
|
||||
public sealed class DownloadClientController : ControllerBase
|
||||
{
|
||||
private readonly ILogger<DownloadClientController> _logger;
|
||||
@@ -156,7 +159,23 @@ public sealed class DownloadClientController : ControllerBase
|
||||
{
|
||||
request.Validate();
|
||||
|
||||
var testConfig = request.ToTestConfig();
|
||||
string? resolvedPassword = null;
|
||||
|
||||
if (request.Password.IsPlaceholder() && request.ClientId.HasValue)
|
||||
{
|
||||
var existingClient = await _dataContext.DownloadClients
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(c => c.Id == request.ClientId.Value);
|
||||
|
||||
if (existingClient is null)
|
||||
{
|
||||
return NotFound($"Download client with ID {request.ClientId.Value} not found");
|
||||
}
|
||||
|
||||
resolvedPassword = existingClient.Password;
|
||||
}
|
||||
|
||||
var testConfig = request.ToTestConfig(resolvedPassword);
|
||||
using var downloadService = _downloadServiceFactory.GetDownloadService(testConfig);
|
||||
var healthResult = await downloadService.HealthCheckAsync();
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ using System.Threading.Tasks;
|
||||
|
||||
using Cleanuparr.Api.Features.General.Contracts.Requests;
|
||||
using Cleanuparr.Persistence;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -12,6 +13,7 @@ namespace Cleanuparr.Api.Features.General.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/configuration")]
|
||||
[Authorize]
|
||||
public sealed class GeneralConfigController : ControllerBase
|
||||
{
|
||||
private readonly ILogger<GeneralConfigController> _logger;
|
||||
|
||||
+2
-2
@@ -16,7 +16,7 @@ public sealed record UpdateMalwareBlockerConfigRequest
|
||||
|
||||
public bool DeletePrivate { get; init; }
|
||||
|
||||
public bool DeleteKnownMalware { get; init; }
|
||||
public bool ProcessNoContentId { get; init; }
|
||||
|
||||
public BlocklistSettings Sonarr { get; init; } = new();
|
||||
|
||||
@@ -37,7 +37,7 @@ public sealed record UpdateMalwareBlockerConfigRequest
|
||||
config.UseAdvancedScheduling = UseAdvancedScheduling;
|
||||
config.IgnorePrivate = IgnorePrivate;
|
||||
config.DeletePrivate = DeletePrivate;
|
||||
config.DeleteKnownMalware = DeleteKnownMalware;
|
||||
config.ProcessNoContentId = ProcessNoContentId;
|
||||
config.Sonarr = Sonarr;
|
||||
config.Radarr = Radarr;
|
||||
config.Lidarr = Lidarr;
|
||||
|
||||
+2
@@ -8,6 +8,7 @@ using Cleanuparr.Infrastructure.Utilities;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -16,6 +17,7 @@ namespace Cleanuparr.Api.Features.MalwareBlocker.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/configuration")]
|
||||
[Authorize]
|
||||
public sealed class MalwareBlockerConfigController : ControllerBase
|
||||
{
|
||||
private readonly ILogger<MalwareBlockerConfigController> _logger;
|
||||
|
||||
+2
@@ -15,4 +15,6 @@ public record TestAppriseProviderRequest
|
||||
|
||||
// CLI mode fields
|
||||
public string? ServiceUrls { get; init; }
|
||||
|
||||
public Guid? ProviderId { get; init; }
|
||||
}
|
||||
+2
@@ -7,4 +7,6 @@ public record TestDiscordProviderRequest
|
||||
public string Username { get; init; } = string.Empty;
|
||||
|
||||
public string AvatarUrl { get; init; } = string.Empty;
|
||||
|
||||
public Guid? ProviderId { get; init; }
|
||||
}
|
||||
+2
@@ -7,4 +7,6 @@ public record TestGotifyProviderRequest
|
||||
public string ApplicationToken { get; init; } = string.Empty;
|
||||
|
||||
public int Priority { get; init; } = 5;
|
||||
|
||||
public Guid? ProviderId { get; init; }
|
||||
}
|
||||
+3
-1
@@ -3,6 +3,8 @@ namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
|
||||
public record TestNotifiarrProviderRequest
|
||||
{
|
||||
public string ApiKey { get; init; } = string.Empty;
|
||||
|
||||
|
||||
public string ChannelId { get; init; } = string.Empty;
|
||||
|
||||
public Guid? ProviderId { get; init; }
|
||||
}
|
||||
+2
@@ -19,4 +19,6 @@ public record TestNtfyProviderRequest
|
||||
public NtfyPriority Priority { get; init; } = NtfyPriority.Default;
|
||||
|
||||
public List<string> Tags { get; init; } = [];
|
||||
|
||||
public Guid? ProviderId { get; init; }
|
||||
}
|
||||
+2
@@ -19,4 +19,6 @@ public record TestPushoverProviderRequest
|
||||
public int? Expire { get; init; }
|
||||
|
||||
public List<string> Tags { get; init; } = [];
|
||||
|
||||
public Guid? ProviderId { get; init; }
|
||||
}
|
||||
+2
@@ -9,4 +9,6 @@ public sealed record TestTelegramProviderRequest
|
||||
public string? TopicId { get; init; }
|
||||
|
||||
public bool SendSilently { get; init; }
|
||||
|
||||
public Guid? ProviderId { get; init; }
|
||||
}
|
||||
+253
-20
@@ -11,6 +11,8 @@ using Cleanuparr.Infrastructure.Features.Notifications.Telegram;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Gotify;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Notification;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
@@ -18,6 +20,7 @@ namespace Cleanuparr.Api.Features.Notifications.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/configuration/notification_providers")]
|
||||
[Authorize]
|
||||
public sealed class NotificationProvidersController : ControllerBase
|
||||
{
|
||||
private readonly ILogger<NotificationProvidersController> _logger;
|
||||
@@ -127,6 +130,11 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
return BadRequest("A provider with this name already exists");
|
||||
}
|
||||
|
||||
if (newProvider.ApiKey.IsPlaceholder())
|
||||
{
|
||||
return BadRequest("API key cannot be a placeholder value");
|
||||
}
|
||||
|
||||
var notifiarrConfig = new NotifiarrConfig
|
||||
{
|
||||
ApiKey = newProvider.ApiKey,
|
||||
@@ -184,6 +192,16 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
return BadRequest("A provider with this name already exists");
|
||||
}
|
||||
|
||||
if (newProvider.Key.IsPlaceholder())
|
||||
{
|
||||
return BadRequest("Key cannot be a placeholder value");
|
||||
}
|
||||
|
||||
if (newProvider.ServiceUrls.IsPlaceholder())
|
||||
{
|
||||
return BadRequest("Service URLs cannot be a placeholder value");
|
||||
}
|
||||
|
||||
var appriseConfig = new AppriseConfig
|
||||
{
|
||||
Mode = newProvider.Mode,
|
||||
@@ -248,6 +266,16 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
return BadRequest("A provider with this name already exists");
|
||||
}
|
||||
|
||||
if (newProvider.Password.IsPlaceholder())
|
||||
{
|
||||
return BadRequest("Password cannot be a placeholder value");
|
||||
}
|
||||
|
||||
if (newProvider.AccessToken.IsPlaceholder())
|
||||
{
|
||||
return BadRequest("Access token cannot be a placeholder value");
|
||||
}
|
||||
|
||||
var ntfyConfig = new NtfyConfig
|
||||
{
|
||||
ServerUrl = newProvider.ServerUrl,
|
||||
@@ -315,6 +343,11 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
return BadRequest("A provider with this name already exists");
|
||||
}
|
||||
|
||||
if (newProvider.BotToken.IsPlaceholder())
|
||||
{
|
||||
return BadRequest("Bot token cannot be a placeholder value");
|
||||
}
|
||||
|
||||
var telegramConfig = new TelegramConfig
|
||||
{
|
||||
BotToken = newProvider.BotToken,
|
||||
@@ -392,7 +425,9 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
|
||||
var notifiarrConfig = new NotifiarrConfig
|
||||
{
|
||||
ApiKey = updatedProvider.ApiKey,
|
||||
ApiKey = updatedProvider.ApiKey.IsPlaceholder()
|
||||
? existingProvider.NotifiarrConfiguration!.ApiKey
|
||||
: updatedProvider.ApiKey,
|
||||
ChannelId = updatedProvider.ChannelId
|
||||
};
|
||||
|
||||
@@ -473,9 +508,13 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
{
|
||||
Mode = updatedProvider.Mode,
|
||||
Url = updatedProvider.Url,
|
||||
Key = updatedProvider.Key,
|
||||
Key = updatedProvider.Key.IsPlaceholder()
|
||||
? existingProvider.AppriseConfiguration!.Key
|
||||
: updatedProvider.Key,
|
||||
Tags = updatedProvider.Tags,
|
||||
ServiceUrls = updatedProvider.ServiceUrls
|
||||
ServiceUrls = updatedProvider.ServiceUrls.IsPlaceholder()
|
||||
? existingProvider.AppriseConfiguration!.ServiceUrls
|
||||
: updatedProvider.ServiceUrls
|
||||
};
|
||||
|
||||
if (existingProvider.AppriseConfiguration != null)
|
||||
@@ -557,8 +596,12 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
Topics = updatedProvider.Topics,
|
||||
AuthenticationType = updatedProvider.AuthenticationType,
|
||||
Username = updatedProvider.Username,
|
||||
Password = updatedProvider.Password,
|
||||
AccessToken = updatedProvider.AccessToken,
|
||||
Password = updatedProvider.Password.IsPlaceholder()
|
||||
? existingProvider.NtfyConfiguration!.Password
|
||||
: updatedProvider.Password,
|
||||
AccessToken = updatedProvider.AccessToken.IsPlaceholder()
|
||||
? existingProvider.NtfyConfiguration!.AccessToken
|
||||
: updatedProvider.AccessToken,
|
||||
Priority = updatedProvider.Priority,
|
||||
Tags = updatedProvider.Tags
|
||||
};
|
||||
@@ -638,7 +681,9 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
|
||||
var telegramConfig = new TelegramConfig
|
||||
{
|
||||
BotToken = updatedProvider.BotToken,
|
||||
BotToken = updatedProvider.BotToken.IsPlaceholder()
|
||||
? existingProvider.TelegramConfiguration!.BotToken
|
||||
: updatedProvider.BotToken,
|
||||
ChatId = updatedProvider.ChatId,
|
||||
TopicId = updatedProvider.TopicId,
|
||||
SendSilently = updatedProvider.SendSilently
|
||||
@@ -735,9 +780,24 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
{
|
||||
try
|
||||
{
|
||||
var apiKey = testRequest.ApiKey;
|
||||
|
||||
if (apiKey.IsPlaceholder())
|
||||
{
|
||||
var existing = await GetExistingProviderConfig<NotifiarrConfig>(
|
||||
testRequest.ProviderId, NotificationProviderType.Notifiarr, p => p.NotifiarrConfiguration);
|
||||
|
||||
if (existing is null)
|
||||
{
|
||||
return BadRequest(new { Message = "API key cannot be a placeholder value" });
|
||||
}
|
||||
|
||||
apiKey = existing.ApiKey;
|
||||
}
|
||||
|
||||
var notifiarrConfig = new NotifiarrConfig
|
||||
{
|
||||
ApiKey = testRequest.ApiKey,
|
||||
ApiKey = apiKey,
|
||||
ChannelId = testRequest.ChannelId
|
||||
};
|
||||
notifiarrConfig.Validate();
|
||||
@@ -775,13 +835,37 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
{
|
||||
try
|
||||
{
|
||||
var key = testRequest.Key;
|
||||
var serviceUrls = testRequest.ServiceUrls;
|
||||
|
||||
if (key.IsPlaceholder() || serviceUrls.IsPlaceholder())
|
||||
{
|
||||
var existing = await GetExistingProviderConfig<AppriseConfig>(
|
||||
testRequest.ProviderId, NotificationProviderType.Apprise, p => p.AppriseConfiguration);
|
||||
|
||||
if (existing is null)
|
||||
{
|
||||
return BadRequest(new { Message = "Sensitive fields cannot be placeholder values" });
|
||||
}
|
||||
|
||||
if (key.IsPlaceholder())
|
||||
{
|
||||
key = existing.Key;
|
||||
}
|
||||
|
||||
if (serviceUrls.IsPlaceholder())
|
||||
{
|
||||
serviceUrls = existing.ServiceUrls;
|
||||
}
|
||||
}
|
||||
|
||||
var appriseConfig = new AppriseConfig
|
||||
{
|
||||
Mode = testRequest.Mode,
|
||||
Url = testRequest.Url,
|
||||
Key = testRequest.Key,
|
||||
Key = key,
|
||||
Tags = testRequest.Tags,
|
||||
ServiceUrls = testRequest.ServiceUrls
|
||||
ServiceUrls = serviceUrls
|
||||
};
|
||||
appriseConfig.Validate();
|
||||
|
||||
@@ -822,14 +906,38 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
{
|
||||
try
|
||||
{
|
||||
var password = testRequest.Password;
|
||||
var accessToken = testRequest.AccessToken;
|
||||
|
||||
if (password.IsPlaceholder() || accessToken.IsPlaceholder())
|
||||
{
|
||||
var existing = await GetExistingProviderConfig<NtfyConfig>(
|
||||
testRequest.ProviderId, NotificationProviderType.Ntfy, p => p.NtfyConfiguration);
|
||||
|
||||
if (existing is null)
|
||||
{
|
||||
return BadRequest(new { Message = "Sensitive fields cannot be placeholder values" });
|
||||
}
|
||||
|
||||
if (password.IsPlaceholder())
|
||||
{
|
||||
password = existing.Password;
|
||||
}
|
||||
|
||||
if (accessToken.IsPlaceholder())
|
||||
{
|
||||
accessToken = existing.AccessToken;
|
||||
}
|
||||
}
|
||||
|
||||
var ntfyConfig = new NtfyConfig
|
||||
{
|
||||
ServerUrl = testRequest.ServerUrl,
|
||||
Topics = testRequest.Topics,
|
||||
AuthenticationType = testRequest.AuthenticationType,
|
||||
Username = testRequest.Username,
|
||||
Password = testRequest.Password,
|
||||
AccessToken = testRequest.AccessToken,
|
||||
Password = password,
|
||||
AccessToken = accessToken,
|
||||
Priority = testRequest.Priority,
|
||||
Tags = testRequest.Tags
|
||||
};
|
||||
@@ -868,9 +976,24 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
{
|
||||
try
|
||||
{
|
||||
var botToken = testRequest.BotToken;
|
||||
|
||||
if (botToken.IsPlaceholder())
|
||||
{
|
||||
var existing = await GetExistingProviderConfig<TelegramConfig>(
|
||||
testRequest.ProviderId, NotificationProviderType.Telegram, p => p.TelegramConfiguration);
|
||||
|
||||
if (existing is null)
|
||||
{
|
||||
return BadRequest(new { Message = "Bot token cannot be a placeholder value" });
|
||||
}
|
||||
|
||||
botToken = existing.BotToken;
|
||||
}
|
||||
|
||||
var telegramConfig = new TelegramConfig
|
||||
{
|
||||
BotToken = testRequest.BotToken,
|
||||
BotToken = botToken,
|
||||
ChatId = testRequest.ChatId,
|
||||
TopicId = testRequest.TopicId,
|
||||
SendSilently = testRequest.SendSilently
|
||||
@@ -958,6 +1081,11 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
return BadRequest("A provider with this name already exists");
|
||||
}
|
||||
|
||||
if (newProvider.WebhookUrl.IsPlaceholder())
|
||||
{
|
||||
return BadRequest("Webhook URL cannot be a placeholder value");
|
||||
}
|
||||
|
||||
var discordConfig = new DiscordConfig
|
||||
{
|
||||
WebhookUrl = newProvider.WebhookUrl,
|
||||
@@ -1034,7 +1162,9 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
|
||||
var discordConfig = new DiscordConfig
|
||||
{
|
||||
WebhookUrl = updatedProvider.WebhookUrl,
|
||||
WebhookUrl = updatedProvider.WebhookUrl.IsPlaceholder()
|
||||
? existingProvider.DiscordConfiguration!.WebhookUrl
|
||||
: updatedProvider.WebhookUrl,
|
||||
Username = updatedProvider.Username,
|
||||
AvatarUrl = updatedProvider.AvatarUrl
|
||||
};
|
||||
@@ -1088,9 +1218,24 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
{
|
||||
try
|
||||
{
|
||||
var webhookUrl = testRequest.WebhookUrl;
|
||||
|
||||
if (webhookUrl.IsPlaceholder())
|
||||
{
|
||||
var existing = await GetExistingProviderConfig<DiscordConfig>(
|
||||
testRequest.ProviderId, NotificationProviderType.Discord, p => p.DiscordConfiguration);
|
||||
|
||||
if (existing is null)
|
||||
{
|
||||
return BadRequest(new { Message = "Webhook URL cannot be a placeholder value" });
|
||||
}
|
||||
|
||||
webhookUrl = existing.WebhookUrl;
|
||||
}
|
||||
|
||||
var discordConfig = new DiscordConfig
|
||||
{
|
||||
WebhookUrl = testRequest.WebhookUrl,
|
||||
WebhookUrl = webhookUrl,
|
||||
Username = testRequest.Username,
|
||||
AvatarUrl = testRequest.AvatarUrl
|
||||
};
|
||||
@@ -1146,6 +1291,16 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
return BadRequest("A provider with this name already exists");
|
||||
}
|
||||
|
||||
if (newProvider.ApiToken.IsPlaceholder())
|
||||
{
|
||||
return BadRequest("API token cannot be a placeholder value");
|
||||
}
|
||||
|
||||
if (newProvider.UserKey.IsPlaceholder())
|
||||
{
|
||||
return BadRequest("User key cannot be a placeholder value");
|
||||
}
|
||||
|
||||
var pushoverConfig = new PushoverConfig
|
||||
{
|
||||
ApiToken = newProvider.ApiToken,
|
||||
@@ -1227,8 +1382,12 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
|
||||
var pushoverConfig = new PushoverConfig
|
||||
{
|
||||
ApiToken = updatedProvider.ApiToken,
|
||||
UserKey = updatedProvider.UserKey,
|
||||
ApiToken = updatedProvider.ApiToken.IsPlaceholder()
|
||||
? existingProvider.PushoverConfiguration!.ApiToken
|
||||
: updatedProvider.ApiToken,
|
||||
UserKey = updatedProvider.UserKey.IsPlaceholder()
|
||||
? existingProvider.PushoverConfiguration!.UserKey
|
||||
: updatedProvider.UserKey,
|
||||
Devices = updatedProvider.Devices,
|
||||
Priority = updatedProvider.Priority,
|
||||
Sound = updatedProvider.Sound,
|
||||
@@ -1286,10 +1445,34 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
{
|
||||
try
|
||||
{
|
||||
var apiToken = testRequest.ApiToken;
|
||||
var userKey = testRequest.UserKey;
|
||||
|
||||
if (apiToken.IsPlaceholder() || userKey.IsPlaceholder())
|
||||
{
|
||||
var existing = await GetExistingProviderConfig<PushoverConfig>(
|
||||
testRequest.ProviderId, NotificationProviderType.Pushover, p => p.PushoverConfiguration);
|
||||
|
||||
if (existing is null)
|
||||
{
|
||||
return BadRequest(new { Message = "Sensitive fields cannot be placeholder values" });
|
||||
}
|
||||
|
||||
if (apiToken.IsPlaceholder())
|
||||
{
|
||||
apiToken = existing.ApiToken;
|
||||
}
|
||||
|
||||
if (userKey.IsPlaceholder())
|
||||
{
|
||||
userKey = existing.UserKey;
|
||||
}
|
||||
}
|
||||
|
||||
var pushoverConfig = new PushoverConfig
|
||||
{
|
||||
ApiToken = testRequest.ApiToken,
|
||||
UserKey = testRequest.UserKey,
|
||||
ApiToken = apiToken,
|
||||
UserKey = userKey,
|
||||
Devices = testRequest.Devices,
|
||||
Priority = testRequest.Priority,
|
||||
Sound = testRequest.Sound,
|
||||
@@ -1344,6 +1527,11 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
return BadRequest("A provider with this name already exists");
|
||||
}
|
||||
|
||||
if (newProvider.ApplicationToken.IsPlaceholder())
|
||||
{
|
||||
return BadRequest("Application token cannot be a placeholder value");
|
||||
}
|
||||
|
||||
var gotifyConfig = new GotifyConfig
|
||||
{
|
||||
ServerUrl = newProvider.ServerUrl,
|
||||
@@ -1421,7 +1609,9 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
var gotifyConfig = new GotifyConfig
|
||||
{
|
||||
ServerUrl = updatedProvider.ServerUrl,
|
||||
ApplicationToken = updatedProvider.ApplicationToken,
|
||||
ApplicationToken = updatedProvider.ApplicationToken.IsPlaceholder()
|
||||
? existingProvider.GotifyConfiguration!.ApplicationToken
|
||||
: updatedProvider.ApplicationToken,
|
||||
Priority = updatedProvider.Priority
|
||||
};
|
||||
|
||||
@@ -1474,10 +1664,23 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
{
|
||||
try
|
||||
{
|
||||
var applicationToken = testRequest.ApplicationToken;
|
||||
|
||||
if (applicationToken.IsPlaceholder())
|
||||
{
|
||||
var existing = await GetExistingProviderConfig<GotifyConfig>(
|
||||
testRequest.ProviderId, NotificationProviderType.Gotify, p => p.GotifyConfiguration);
|
||||
|
||||
if (existing is null)
|
||||
return BadRequest(new { Message = "Application token cannot be a placeholder value" });
|
||||
|
||||
applicationToken = existing.ApplicationToken;
|
||||
}
|
||||
|
||||
var gotifyConfig = new GotifyConfig
|
||||
{
|
||||
ServerUrl = testRequest.ServerUrl,
|
||||
ApplicationToken = testRequest.ApplicationToken,
|
||||
ApplicationToken = applicationToken,
|
||||
Priority = testRequest.Priority
|
||||
};
|
||||
gotifyConfig.Validate();
|
||||
@@ -1514,4 +1717,34 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
return BadRequest(new { Message = $"Test failed: {ex.Message}" });
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<T?> GetExistingProviderConfig<T>(
|
||||
Guid? providerId,
|
||||
NotificationProviderType expectedType,
|
||||
Func<NotificationConfig, T?> configSelector) where T : class
|
||||
{
|
||||
if (!providerId.HasValue)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
IQueryable<NotificationConfig> query = _dataContext.NotificationConfigs.AsNoTracking();
|
||||
|
||||
query = expectedType switch
|
||||
{
|
||||
NotificationProviderType.Notifiarr => query.Include(p => p.NotifiarrConfiguration),
|
||||
NotificationProviderType.Apprise => query.Include(p => p.AppriseConfiguration),
|
||||
NotificationProviderType.Ntfy => query.Include(p => p.NtfyConfiguration),
|
||||
NotificationProviderType.Pushover => query.Include(p => p.PushoverConfiguration),
|
||||
NotificationProviderType.Telegram => query.Include(p => p.TelegramConfiguration),
|
||||
NotificationProviderType.Discord => query.Include(p => p.DiscordConfiguration),
|
||||
NotificationProviderType.Gotify => query.Include(p => p.GotifyConfiguration),
|
||||
_ => query
|
||||
};
|
||||
|
||||
var provider = await query
|
||||
.FirstOrDefaultAsync(p => p.Id == providerId.Value && p.Type == expectedType);
|
||||
|
||||
return provider is null ? null : configSelector(provider);
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -13,6 +13,8 @@ public sealed record UpdateQueueCleanerConfigRequest
|
||||
public FailedImportConfig FailedImport { get; init; } = new();
|
||||
|
||||
public ushort DownloadingMetadataMaxStrikes { get; init; }
|
||||
|
||||
|
||||
public bool ProcessNoContentId { get; init; }
|
||||
|
||||
public List<string> IgnoredDownloads { get; set; } = [];
|
||||
}
|
||||
+3
@@ -7,6 +7,7 @@ using Cleanuparr.Infrastructure.Utilities;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -15,6 +16,7 @@ namespace Cleanuparr.Api.Features.QueueCleaner.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/configuration")]
|
||||
[Authorize]
|
||||
public sealed class QueueCleanerConfigController : ControllerBase
|
||||
{
|
||||
private readonly ILogger<QueueCleanerConfigController> _logger;
|
||||
@@ -67,6 +69,7 @@ public sealed class QueueCleanerConfigController : ControllerBase
|
||||
oldConfig.UseAdvancedScheduling = newConfigDto.UseAdvancedScheduling;
|
||||
oldConfig.FailedImport = newConfigDto.FailedImport;
|
||||
oldConfig.DownloadingMetadataMaxStrikes = newConfigDto.DownloadingMetadataMaxStrikes;
|
||||
oldConfig.ProcessNoContentId = newConfigDto.ProcessNoContentId;
|
||||
oldConfig.IgnoredDownloads = newConfigDto.IgnoredDownloads;
|
||||
|
||||
oldConfig.Validate();
|
||||
|
||||
@@ -3,6 +3,7 @@ using Cleanuparr.Domain.Exceptions;
|
||||
using Cleanuparr.Infrastructure.Services.Interfaces;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -11,6 +12,7 @@ namespace Cleanuparr.Api.Features.QueueCleaner.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/queue-rules")]
|
||||
[Authorize]
|
||||
public class QueueRulesController : ControllerBase
|
||||
{
|
||||
private readonly ILogger<QueueRulesController> _logger;
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
using Microsoft.AspNetCore.Mvc.Filters;
|
||||
|
||||
namespace Cleanuparr.Api.Filters;
|
||||
|
||||
/// <summary>
|
||||
/// Prevents caching of sensitive responses by setting appropriate HTTP headers.
|
||||
/// Applies Cache-Control: no-cache, no-store, Pragma: no-cache, and a past Expires date
|
||||
/// for maximum compatibility with HTTP/1.0 and HTTP/1.1 clients and intermediaries.
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
|
||||
public sealed class NoCacheAttribute : ActionFilterAttribute
|
||||
{
|
||||
public static void Apply(IHeaderDictionary headers)
|
||||
{
|
||||
headers.CacheControl = "no-cache, no-store";
|
||||
headers.Pragma = "no-cache";
|
||||
headers.Expires = "Thu, 01 Jan 1970 00:00:00 GMT";
|
||||
}
|
||||
|
||||
public override void OnResultExecuting(ResultExecutingContext context)
|
||||
{
|
||||
Apply(context.HttpContext.Response.Headers);
|
||||
base.OnResultExecuting(context);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,4 @@
|
||||
using System.Reflection;
|
||||
using Cleanuparr.Infrastructure.Health;
|
||||
using Cleanuparr.Infrastructure.Logging;
|
||||
using Cleanuparr.Infrastructure.Services;
|
||||
using Cleanuparr.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization.Metadata;
|
||||
|
||||
using Cleanuparr.Shared.Attributes;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
|
||||
namespace Cleanuparr.Api.Json;
|
||||
|
||||
/// <summary>
|
||||
/// JSON type info resolver that masks properties decorated with <see cref="SensitiveDataAttribute"/>
|
||||
/// by replacing their serialized values with the appropriate placeholder during serialization.
|
||||
/// </summary>
|
||||
public sealed class SensitiveDataResolver : IJsonTypeInfoResolver
|
||||
{
|
||||
private readonly IJsonTypeInfoResolver _innerResolver;
|
||||
|
||||
public SensitiveDataResolver(IJsonTypeInfoResolver innerResolver)
|
||||
{
|
||||
_innerResolver = innerResolver;
|
||||
}
|
||||
|
||||
public JsonTypeInfo? GetTypeInfo(Type type, JsonSerializerOptions options)
|
||||
{
|
||||
var typeInfo = _innerResolver.GetTypeInfo(type, options);
|
||||
|
||||
if (typeInfo?.Kind != JsonTypeInfoKind.Object)
|
||||
return typeInfo;
|
||||
|
||||
foreach (var property in typeInfo.Properties)
|
||||
{
|
||||
if (property.AttributeProvider is not PropertyInfo propertyInfo)
|
||||
continue;
|
||||
|
||||
var sensitiveAttr = propertyInfo.GetCustomAttribute<SensitiveDataAttribute>();
|
||||
if (sensitiveAttr is null)
|
||||
continue;
|
||||
|
||||
ApplyMasking(property, sensitiveAttr.Type);
|
||||
}
|
||||
|
||||
return typeInfo;
|
||||
}
|
||||
|
||||
private static void ApplyMasking(JsonPropertyInfo property, SensitiveDataType maskType)
|
||||
{
|
||||
var originalGet = property.Get;
|
||||
if (originalGet is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
property.Get = maskType switch
|
||||
{
|
||||
SensitiveDataType.Full => obj =>
|
||||
{
|
||||
var value = originalGet(obj);
|
||||
return value is string ? SensitiveDataHelper.Placeholder : value;
|
||||
},
|
||||
|
||||
SensitiveDataType.AppriseUrl => obj =>
|
||||
{
|
||||
var value = originalGet(obj);
|
||||
return value is string s ? SensitiveDataHelper.MaskAppriseUrls(s) : value;
|
||||
},
|
||||
|
||||
_ => originalGet,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -15,52 +15,75 @@ public class SetupGuardMiddleware
|
||||
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
// Fast path: setup already completed
|
||||
string path = context.Request.Path.Value?.ToLowerInvariant() ?? "";
|
||||
|
||||
// Always allow health checks and non-API paths (static files, SPA, etc.)
|
||||
if (path.StartsWith("/health") || !path.StartsWith("/api/"))
|
||||
{
|
||||
await _next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
// Setup-only paths (/api/auth/setup/*) require setup to NOT be complete
|
||||
if (IsSetupOnlyPath(path))
|
||||
{
|
||||
if (await IsSetupCompleted())
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status403Forbidden;
|
||||
context.Response.ContentType = "application/json";
|
||||
await context.Response.WriteAsJsonAsync(new { error = "Setup already completed" });
|
||||
return;
|
||||
}
|
||||
|
||||
await _next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
// Non-setup auth paths (login, refresh, logout, status) are always allowed
|
||||
if (path.StartsWith("/api/auth/") || path == "/api/auth")
|
||||
{
|
||||
await _next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
// All other API paths require setup to be complete
|
||||
if (!await IsSetupCompleted())
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status403Forbidden;
|
||||
context.Response.ContentType = "application/json";
|
||||
await context.Response.WriteAsJsonAsync(new { error = "Setup required" });
|
||||
return;
|
||||
}
|
||||
|
||||
await _next(context);
|
||||
}
|
||||
|
||||
public void ResetSetupState()
|
||||
{
|
||||
_setupCompleted = false;
|
||||
}
|
||||
|
||||
private async Task<bool> IsSetupCompleted()
|
||||
{
|
||||
if (_setupCompleted)
|
||||
{
|
||||
await _next(context);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
var path = context.Request.Path.Value?.ToLowerInvariant() ?? "";
|
||||
|
||||
// Always allow these paths regardless of setup state
|
||||
if (IsAllowedPath(path))
|
||||
{
|
||||
await _next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check database for setup completion
|
||||
await using var usersContext = UsersContext.CreateStaticInstance();
|
||||
var user = await usersContext.Users.AsNoTracking().FirstOrDefaultAsync();
|
||||
|
||||
if (user is { SetupCompleted: true })
|
||||
{
|
||||
_setupCompleted = true;
|
||||
await _next(context);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Setup not complete - block non-auth requests
|
||||
context.Response.StatusCode = StatusCodes.Status403Forbidden;
|
||||
context.Response.ContentType = "application/json";
|
||||
await context.Response.WriteAsJsonAsync(new { error = "Setup required" });
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resets the cached setup state. Call this if the user database is reset.
|
||||
/// </summary>
|
||||
public void ResetSetupState()
|
||||
private static bool IsSetupOnlyPath(string path)
|
||||
{
|
||||
_setupCompleted = false;
|
||||
}
|
||||
|
||||
private static bool IsAllowedPath(string path)
|
||||
{
|
||||
return path.StartsWith("/api/auth/")
|
||||
|| path == "/api/auth"
|
||||
|| path.StartsWith("/health")
|
||||
|| !path.StartsWith("/api/");
|
||||
return path.StartsWith("/api/auth/setup/") || path == "/api/auth/setup";
|
||||
}
|
||||
}
|
||||
@@ -181,4 +181,4 @@ await app.RunAsync();
|
||||
await Log.CloseAndFlushAsync();
|
||||
|
||||
// Make Program class accessible for testing
|
||||
public partial class Program { }
|
||||
public partial class Program { }
|
||||
@@ -11,5 +11,4 @@ public enum DeleteReason
|
||||
AllFilesSkipped,
|
||||
AllFilesSkippedByQBit,
|
||||
AllFilesBlocked,
|
||||
MalwareFileFound,
|
||||
}
|
||||
@@ -1,10 +1,8 @@
|
||||
using Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
using Cleanuparr.Infrastructure.Features.Arr;
|
||||
using Cleanuparr.Infrastructure.Features.ItemStriker;
|
||||
using Cleanuparr.Infrastructure.Interceptors;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.Arr;
|
||||
|
||||
@@ -35,144 +33,4 @@ public class WhisparrV2ClientTests
|
||||
_dryRunInterceptorMock.Object
|
||||
);
|
||||
}
|
||||
|
||||
#region IsRecordValid Tests
|
||||
|
||||
[Fact]
|
||||
public void IsRecordValid_WhenEpisodeIdIsZero_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var record = new QueueRecord
|
||||
{
|
||||
Id = 1,
|
||||
Title = "Test Episode",
|
||||
DownloadId = "abc123",
|
||||
Protocol = "torrent",
|
||||
EpisodeId = 0,
|
||||
SeriesId = 1
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _client.IsRecordValid(record);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
_loggerMock.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Debug,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("episode id and/or series id missing")),
|
||||
It.IsAny<Exception?>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()
|
||||
),
|
||||
Times.Once
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsRecordValid_WhenSeriesIdIsZero_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var record = new QueueRecord
|
||||
{
|
||||
Id = 1,
|
||||
Title = "Test Episode",
|
||||
DownloadId = "abc123",
|
||||
Protocol = "torrent",
|
||||
EpisodeId = 1,
|
||||
SeriesId = 0
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _client.IsRecordValid(record);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsRecordValid_WhenBothIdsAreZero_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var record = new QueueRecord
|
||||
{
|
||||
Id = 1,
|
||||
Title = "Test Episode",
|
||||
DownloadId = "abc123",
|
||||
Protocol = "torrent",
|
||||
EpisodeId = 0,
|
||||
SeriesId = 0
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _client.IsRecordValid(record);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsRecordValid_WhenBothIdsAreSet_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var record = new QueueRecord
|
||||
{
|
||||
Id = 1,
|
||||
Title = "Test Episode",
|
||||
DownloadId = "abc123",
|
||||
Protocol = "torrent",
|
||||
EpisodeId = 42,
|
||||
SeriesId = 10
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _client.IsRecordValid(record);
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsRecordValid_WhenDownloadIdIsNull_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var record = new QueueRecord
|
||||
{
|
||||
Id = 1,
|
||||
Title = "Test Episode",
|
||||
DownloadId = null!,
|
||||
Protocol = "torrent",
|
||||
EpisodeId = 42,
|
||||
SeriesId = 10
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _client.IsRecordValid(record);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsRecordValid_WhenDownloadIdIsEmpty_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var record = new QueueRecord
|
||||
{
|
||||
Id = 1,
|
||||
Title = "Test Episode",
|
||||
DownloadId = "",
|
||||
Protocol = "torrent",
|
||||
EpisodeId = 42,
|
||||
SeriesId = 10
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _client.IsRecordValid(record);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -1,10 +1,8 @@
|
||||
using Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
using Cleanuparr.Infrastructure.Features.Arr;
|
||||
using Cleanuparr.Infrastructure.Features.ItemStriker;
|
||||
using Cleanuparr.Infrastructure.Interceptors;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.Arr;
|
||||
|
||||
@@ -35,98 +33,4 @@ public class WhisparrV3ClientTests
|
||||
_dryRunInterceptorMock.Object
|
||||
);
|
||||
}
|
||||
|
||||
#region IsRecordValid Tests
|
||||
|
||||
[Fact]
|
||||
public void IsRecordValid_WhenMovieIdIsZero_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var record = new QueueRecord
|
||||
{
|
||||
Id = 1,
|
||||
Title = "Test Movie",
|
||||
DownloadId = "abc123",
|
||||
Protocol = "torrent",
|
||||
MovieId = 0
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _client.IsRecordValid(record);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
_loggerMock.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Debug,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("movie id missing")),
|
||||
It.IsAny<Exception?>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()
|
||||
),
|
||||
Times.Once
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsRecordValid_WhenMovieIdIsSet_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var record = new QueueRecord
|
||||
{
|
||||
Id = 1,
|
||||
Title = "Test Movie",
|
||||
DownloadId = "abc123",
|
||||
Protocol = "torrent",
|
||||
MovieId = 42
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _client.IsRecordValid(record);
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsRecordValid_WhenDownloadIdIsNull_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var record = new QueueRecord
|
||||
{
|
||||
Id = 1,
|
||||
Title = "Test Movie",
|
||||
DownloadId = null!,
|
||||
Protocol = "torrent",
|
||||
MovieId = 42
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _client.IsRecordValid(record);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsRecordValid_WhenDownloadIdIsEmpty_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var record = new QueueRecord
|
||||
{
|
||||
Id = 1,
|
||||
Title = "Test Movie",
|
||||
DownloadId = "",
|
||||
Protocol = "torrent",
|
||||
MovieId = 42
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _client.IsRecordValid(record);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
+52
-2
@@ -275,6 +275,55 @@ public class QueueItemRemoverTests : IDisposable
|
||||
|
||||
#endregion
|
||||
|
||||
#region RemoveQueueItemAsync - SkipSearch Tests
|
||||
|
||||
[Fact]
|
||||
public async Task RemoveQueueItemAsync_WhenSkipSearch_DoesNotPublishHuntRequest()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateRemoveRequest(skipSearch: true);
|
||||
|
||||
_arrClientMock
|
||||
.Setup(c => c.DeleteQueueItemAsync(
|
||||
It.IsAny<ArrInstance>(),
|
||||
It.IsAny<QueueRecord>(),
|
||||
It.IsAny<bool>(),
|
||||
It.IsAny<DeleteReason>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _queueItemRemover.RemoveQueueItemAsync(request);
|
||||
|
||||
// Assert
|
||||
_busMock.Verify(b => b.Publish(
|
||||
It.IsAny<DownloadHuntRequest<SearchItem>>(),
|
||||
It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RemoveQueueItemAsync_WhenSkipSearch_AndHashIsNotRecurring_DoesNotModifyRecurringHashes()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateRemoveRequest(skipSearch: true);
|
||||
var hash = request.Record.DownloadId.ToLowerInvariant();
|
||||
|
||||
_arrClientMock
|
||||
.Setup(c => c.DeleteQueueItemAsync(
|
||||
It.IsAny<ArrInstance>(),
|
||||
It.IsAny<QueueRecord>(),
|
||||
It.IsAny<bool>(),
|
||||
It.IsAny<DeleteReason>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _queueItemRemover.RemoveQueueItemAsync(request);
|
||||
|
||||
// Assert - hash was never in recurring, should still not be there
|
||||
Assert.False(Striker.RecurringHashes.ContainsKey(hash));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region RemoveQueueItemAsync - HTTP Error Tests
|
||||
|
||||
[Fact]
|
||||
@@ -377,7 +426,6 @@ public class QueueItemRemoverTests : IDisposable
|
||||
[InlineData(DeleteReason.SlowSpeed)]
|
||||
[InlineData(DeleteReason.SlowTime)]
|
||||
[InlineData(DeleteReason.DownloadingMetadata)]
|
||||
[InlineData(DeleteReason.MalwareFileFound)]
|
||||
public async Task RemoveQueueItemAsync_PassesCorrectDeleteReason(DeleteReason deleteReason)
|
||||
{
|
||||
// Arrange
|
||||
@@ -436,7 +484,8 @@ public class QueueItemRemoverTests : IDisposable
|
||||
private static QueueItemRemoveRequest<SearchItem> CreateRemoveRequest(
|
||||
InstanceType instanceType = InstanceType.Sonarr,
|
||||
bool removeFromClient = true,
|
||||
DeleteReason deleteReason = DeleteReason.Stalled)
|
||||
DeleteReason deleteReason = DeleteReason.Stalled,
|
||||
bool skipSearch = false)
|
||||
{
|
||||
return new QueueItemRemoveRequest<SearchItem>
|
||||
{
|
||||
@@ -446,6 +495,7 @@ public class QueueItemRemoverTests : IDisposable
|
||||
Record = CreateQueueRecord(),
|
||||
RemoveFromClient = removeFromClient,
|
||||
DeleteReason = deleteReason,
|
||||
SkipSearch = skipSearch,
|
||||
JobRunId = Guid.NewGuid()
|
||||
};
|
||||
}
|
||||
|
||||
+192
-19
@@ -159,24 +159,21 @@ public class MalwareBlockerTests : IDisposable
|
||||
_fixture.ArrClientFactory.Verify(x => x.GetClient(InstanceType.Sonarr, It.IsAny<float>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteInternalAsync_WhenDeleteKnownMalwareEnabled_ProcessesAllArrs()
|
||||
[Theory]
|
||||
[InlineData(InstanceType.Radarr)]
|
||||
[InlineData(InstanceType.Lidarr)]
|
||||
[InlineData(InstanceType.Readarr)]
|
||||
[InlineData(InstanceType.Whisparr)]
|
||||
public async Task ExecuteInternalAsync_WhenArrTypeEnabled_ProcessesCorrectInstances(InstanceType instanceType)
|
||||
{
|
||||
// Arrange
|
||||
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
|
||||
|
||||
var contentBlockerConfig = _fixture.DataContext.ContentBlockerConfigs.First();
|
||||
contentBlockerConfig.DeleteKnownMalware = true;
|
||||
// Need at least one blocklist enabled for processing to occur
|
||||
contentBlockerConfig.Sonarr = new BlocklistSettings { Enabled = true };
|
||||
_fixture.DataContext.SaveChanges();
|
||||
|
||||
TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
|
||||
TestDataContextFactory.AddRadarrInstance(_fixture.DataContext);
|
||||
EnableBlocklist(instanceType);
|
||||
AddArrInstance(instanceType);
|
||||
|
||||
var mockArrClient = new Mock<IArrClient>();
|
||||
_fixture.ArrClientFactory
|
||||
.Setup(x => x.GetClient(It.IsAny<InstanceType>(), It.IsAny<float>()))
|
||||
.Setup(x => x.GetClient(instanceType, It.IsAny<float>()))
|
||||
.Returns(mockArrClient.Object);
|
||||
|
||||
_fixture.ArrQueueIterator
|
||||
@@ -192,9 +189,8 @@ public class MalwareBlockerTests : IDisposable
|
||||
// Act
|
||||
await sut.ExecuteAsync();
|
||||
|
||||
// Assert - Sonarr and Radarr processed because DeleteKnownMalware is true
|
||||
_fixture.ArrClientFactory.Verify(x => x.GetClient(InstanceType.Sonarr, It.IsAny<float>()), Times.Once);
|
||||
_fixture.ArrClientFactory.Verify(x => x.GetClient(InstanceType.Radarr, It.IsAny<float>()), Times.Once);
|
||||
// Assert
|
||||
_fixture.ArrClientFactory.Verify(x => x.GetClient(instanceType, It.IsAny<float>()), Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -215,6 +211,7 @@ public class MalwareBlockerTests : IDisposable
|
||||
|
||||
var mockArrClient = new Mock<IArrClient>();
|
||||
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
|
||||
mockArrClient.Setup(x => x.HasContentId(It.IsAny<QueueRecord>())).Returns(true);
|
||||
|
||||
_fixture.ArrClientFactory
|
||||
.Setup(x => x.GetClient(InstanceType.Sonarr, It.IsAny<float>()))
|
||||
@@ -225,7 +222,9 @@ public class MalwareBlockerTests : IDisposable
|
||||
Id = 1,
|
||||
DownloadId = "ignored-download-id",
|
||||
Title = "Ignored Download",
|
||||
Protocol = "torrent"
|
||||
Protocol = "torrent",
|
||||
SeriesId = 1,
|
||||
EpisodeId = 1
|
||||
};
|
||||
|
||||
_fixture.ArrQueueIterator
|
||||
@@ -267,6 +266,7 @@ public class MalwareBlockerTests : IDisposable
|
||||
|
||||
var mockArrClient = new Mock<IArrClient>();
|
||||
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
|
||||
mockArrClient.Setup(x => x.HasContentId(It.IsAny<QueueRecord>())).Returns(true);
|
||||
|
||||
_fixture.ArrClientFactory
|
||||
.Setup(x => x.GetClient(InstanceType.Sonarr, It.IsAny<float>()))
|
||||
@@ -277,7 +277,9 @@ public class MalwareBlockerTests : IDisposable
|
||||
Id = 1,
|
||||
DownloadId = "torrent-download-id",
|
||||
Title = "Torrent Download",
|
||||
Protocol = "torrent"
|
||||
Protocol = "torrent",
|
||||
SeriesId = 1,
|
||||
EpisodeId = 1
|
||||
};
|
||||
|
||||
_fixture.ArrQueueIterator
|
||||
@@ -325,6 +327,7 @@ public class MalwareBlockerTests : IDisposable
|
||||
|
||||
var mockArrClient = new Mock<IArrClient>();
|
||||
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
|
||||
mockArrClient.Setup(x => x.HasContentId(It.IsAny<QueueRecord>())).Returns(true);
|
||||
|
||||
_fixture.ArrClientFactory
|
||||
.Setup(x => x.GetClient(InstanceType.Sonarr, It.IsAny<float>()))
|
||||
@@ -401,6 +404,7 @@ public class MalwareBlockerTests : IDisposable
|
||||
|
||||
var mockArrClient = new Mock<IArrClient>();
|
||||
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
|
||||
mockArrClient.Setup(x => x.HasContentId(It.IsAny<QueueRecord>())).Returns(true);
|
||||
|
||||
_fixture.ArrClientFactory
|
||||
.Setup(x => x.GetClient(InstanceType.Sonarr, It.IsAny<float>()))
|
||||
@@ -472,6 +476,7 @@ public class MalwareBlockerTests : IDisposable
|
||||
|
||||
var mockArrClient = new Mock<IArrClient>();
|
||||
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
|
||||
mockArrClient.Setup(x => x.HasContentId(It.IsAny<QueueRecord>())).Returns(true);
|
||||
|
||||
_fixture.ArrClientFactory
|
||||
.Setup(x => x.GetClient(InstanceType.Sonarr, It.IsAny<float>()))
|
||||
@@ -482,7 +487,9 @@ public class MalwareBlockerTests : IDisposable
|
||||
Id = 1,
|
||||
DownloadId = "missing-download-id",
|
||||
Title = "Missing Download",
|
||||
Protocol = "torrent"
|
||||
Protocol = "torrent",
|
||||
SeriesId = 1,
|
||||
EpisodeId = 1
|
||||
};
|
||||
|
||||
_fixture.ArrQueueIterator
|
||||
@@ -526,6 +533,142 @@ public class MalwareBlockerTests : IDisposable
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessInstanceAsync_SkipsItem_WhenMissingContentId_AndProcessNoContentIdIsFalse()
|
||||
{
|
||||
// Arrange
|
||||
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
|
||||
EnableSonarrBlocklist();
|
||||
TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
|
||||
|
||||
var mockArrClient = new Mock<IArrClient>();
|
||||
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
|
||||
mockArrClient.Setup(x => x.HasContentId(It.IsAny<QueueRecord>())).Returns(false);
|
||||
|
||||
_fixture.ArrClientFactory
|
||||
.Setup(x => x.GetClient(InstanceType.Sonarr, It.IsAny<float>()))
|
||||
.Returns(mockArrClient.Object);
|
||||
|
||||
var queueRecord = new QueueRecord
|
||||
{
|
||||
Id = 1,
|
||||
DownloadId = "no-content-id-download",
|
||||
Title = "No Content ID Download",
|
||||
Protocol = "torrent"
|
||||
};
|
||||
|
||||
_fixture.ArrQueueIterator
|
||||
.Setup(x => x.Iterate(
|
||||
It.IsAny<IArrClient>(),
|
||||
It.IsAny<ArrInstance>(),
|
||||
It.IsAny<Func<IReadOnlyList<QueueRecord>, Task>>()
|
||||
))
|
||||
.Returns(async (IArrClient client, ArrInstance instance, Func<IReadOnlyList<QueueRecord>, Task> callback) =>
|
||||
{
|
||||
await callback([queueRecord]);
|
||||
});
|
||||
|
||||
var sut = CreateSut();
|
||||
|
||||
// Act
|
||||
await sut.ExecuteAsync();
|
||||
|
||||
// Assert
|
||||
_logger.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Information,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("skip | item is missing the content id")),
|
||||
It.IsAny<Exception?>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()
|
||||
),
|
||||
Times.Once
|
||||
);
|
||||
|
||||
_fixture.MessageBus.Verify(
|
||||
x => x.Publish(
|
||||
It.IsAny<QueueItemRemoveRequest<SeriesSearchItem>>(),
|
||||
It.IsAny<CancellationToken>()
|
||||
),
|
||||
Times.Never
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessInstanceAsync_WhenMissingContentId_AndProcessNoContentIdIsTrue_PublishesRemoveRequestWithSkipSearch()
|
||||
{
|
||||
// Arrange
|
||||
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
|
||||
EnableSonarrBlocklist();
|
||||
TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
|
||||
|
||||
var contentBlockerConfig = _fixture.DataContext.ContentBlockerConfigs.First();
|
||||
contentBlockerConfig.ProcessNoContentId = true;
|
||||
_fixture.DataContext.SaveChanges();
|
||||
|
||||
var mockArrClient = new Mock<IArrClient>();
|
||||
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
|
||||
mockArrClient.Setup(x => x.HasContentId(It.IsAny<QueueRecord>())).Returns(false);
|
||||
|
||||
_fixture.ArrClientFactory
|
||||
.Setup(x => x.GetClient(InstanceType.Sonarr, It.IsAny<float>()))
|
||||
.Returns(mockArrClient.Object);
|
||||
|
||||
var queueRecord = new QueueRecord
|
||||
{
|
||||
Id = 1,
|
||||
DownloadId = "no-content-id-download",
|
||||
Title = "No Content ID Download",
|
||||
Protocol = "torrent"
|
||||
};
|
||||
|
||||
_fixture.ArrQueueIterator
|
||||
.Setup(x => x.Iterate(
|
||||
It.IsAny<IArrClient>(),
|
||||
It.IsAny<ArrInstance>(),
|
||||
It.IsAny<Func<IReadOnlyList<QueueRecord>, Task>>()
|
||||
))
|
||||
.Returns(async (IArrClient client, ArrInstance instance, Func<IReadOnlyList<QueueRecord>, Task> callback) =>
|
||||
{
|
||||
await callback([queueRecord]);
|
||||
});
|
||||
|
||||
var mockDownloadService = _fixture.CreateMockDownloadService();
|
||||
mockDownloadService
|
||||
.Setup(x => x.BlockUnwantedFilesAsync(
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<List<string>>()
|
||||
))
|
||||
.ReturnsAsync(new BlockFilesResult
|
||||
{
|
||||
Found = true,
|
||||
ShouldRemove = true,
|
||||
IsPrivate = false,
|
||||
DeleteReason = DeleteReason.AllFilesBlocked
|
||||
});
|
||||
|
||||
_fixture.DownloadServiceFactory
|
||||
.Setup(x => x.GetDownloadService(It.IsAny<DownloadClientConfig>()))
|
||||
.Returns(mockDownloadService.Object);
|
||||
|
||||
var sut = CreateSut();
|
||||
|
||||
// Act
|
||||
await sut.ExecuteAsync();
|
||||
|
||||
// Assert - SkipSearch must be true because the item has no content ID
|
||||
_fixture.MessageBus.Verify(
|
||||
x => x.Publish(
|
||||
It.Is<QueueItemRemoveRequest<SeriesSearchItem>>(r =>
|
||||
r.SkipSearch == true &&
|
||||
r.DeleteReason == DeleteReason.AllFilesBlocked
|
||||
),
|
||||
It.IsAny<CancellationToken>()
|
||||
),
|
||||
Times.Once
|
||||
);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Error Handling Tests
|
||||
@@ -540,6 +683,7 @@ public class MalwareBlockerTests : IDisposable
|
||||
|
||||
var mockArrClient = new Mock<IArrClient>();
|
||||
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
|
||||
mockArrClient.Setup(x => x.HasContentId(It.IsAny<QueueRecord>())).Returns(true);
|
||||
|
||||
_fixture.ArrClientFactory
|
||||
.Setup(x => x.GetClient(InstanceType.Sonarr, It.IsAny<float>()))
|
||||
@@ -550,7 +694,9 @@ public class MalwareBlockerTests : IDisposable
|
||||
Id = 1,
|
||||
DownloadId = "error-download-id",
|
||||
Title = "Error Download",
|
||||
Protocol = "torrent"
|
||||
Protocol = "torrent",
|
||||
SeriesId = 1,
|
||||
EpisodeId = 1
|
||||
};
|
||||
|
||||
_fixture.ArrQueueIterator
|
||||
@@ -605,5 +751,32 @@ public class MalwareBlockerTests : IDisposable
|
||||
_fixture.DataContext.SaveChanges();
|
||||
}
|
||||
|
||||
private void EnableBlocklist(InstanceType instanceType)
|
||||
{
|
||||
var config = _fixture.DataContext.ContentBlockerConfigs.First();
|
||||
var settings = new BlocklistSettings { Enabled = true };
|
||||
switch (instanceType)
|
||||
{
|
||||
case InstanceType.Radarr: config.Radarr = settings; break;
|
||||
case InstanceType.Lidarr: config.Lidarr = settings; break;
|
||||
case InstanceType.Readarr: config.Readarr = settings; break;
|
||||
case InstanceType.Whisparr: config.Whisparr = settings; break;
|
||||
default: throw new ArgumentOutOfRangeException(nameof(instanceType));
|
||||
}
|
||||
_fixture.DataContext.SaveChanges();
|
||||
}
|
||||
|
||||
private void AddArrInstance(InstanceType instanceType)
|
||||
{
|
||||
switch (instanceType)
|
||||
{
|
||||
case InstanceType.Radarr: TestDataContextFactory.AddRadarrInstance(_fixture.DataContext); break;
|
||||
case InstanceType.Lidarr: TestDataContextFactory.AddLidarrInstance(_fixture.DataContext); break;
|
||||
case InstanceType.Readarr: TestDataContextFactory.AddReadarrInstance(_fixture.DataContext); break;
|
||||
case InstanceType.Whisparr: TestDataContextFactory.AddWhisparrInstance(_fixture.DataContext); break;
|
||||
default: throw new ArgumentOutOfRangeException(nameof(instanceType));
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -220,6 +220,7 @@ public class QueueCleanerTests : IDisposable
|
||||
|
||||
var mockArrClient = new Mock<IArrClient>();
|
||||
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
|
||||
mockArrClient.Setup(x => x.HasContentId(It.IsAny<QueueRecord>())).Returns(true);
|
||||
|
||||
_fixture.ArrClientFactory
|
||||
.Setup(x => x.GetClient(InstanceType.Sonarr, It.IsAny<float>()))
|
||||
@@ -230,7 +231,9 @@ public class QueueCleanerTests : IDisposable
|
||||
Id = 1,
|
||||
DownloadId = "ignored-download-id",
|
||||
Title = "Ignored Download",
|
||||
Protocol = "torrent"
|
||||
Protocol = "torrent",
|
||||
SeriesId = 1,
|
||||
EpisodeId = 1
|
||||
};
|
||||
|
||||
_fixture.ArrQueueIterator
|
||||
@@ -275,6 +278,7 @@ public class QueueCleanerTests : IDisposable
|
||||
|
||||
var mockArrClient = new Mock<IArrClient>();
|
||||
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
|
||||
mockArrClient.Setup(x => x.HasContentId(It.IsAny<QueueRecord>())).Returns(true);
|
||||
|
||||
_fixture.ArrClientFactory
|
||||
.Setup(x => x.GetClient(InstanceType.Sonarr, It.IsAny<float>()))
|
||||
@@ -285,7 +289,9 @@ public class QueueCleanerTests : IDisposable
|
||||
Id = 1,
|
||||
DownloadId = "cached-download-id",
|
||||
Title = "Cached Download",
|
||||
Protocol = "torrent"
|
||||
Protocol = "torrent",
|
||||
SeriesId = 1,
|
||||
EpisodeId = 1
|
||||
};
|
||||
|
||||
_fixture.ArrQueueIterator
|
||||
@@ -326,6 +332,7 @@ public class QueueCleanerTests : IDisposable
|
||||
|
||||
var mockArrClient = new Mock<IArrClient>();
|
||||
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
|
||||
mockArrClient.Setup(x => x.HasContentId(It.IsAny<QueueRecord>())).Returns(true);
|
||||
mockArrClient.Setup(x => x.ShouldRemoveFromQueue(
|
||||
It.IsAny<InstanceType>(),
|
||||
It.IsAny<QueueRecord>(),
|
||||
@@ -342,7 +349,9 @@ public class QueueCleanerTests : IDisposable
|
||||
Id = 1,
|
||||
DownloadId = "torrent-download-id",
|
||||
Title = "Torrent Download",
|
||||
Protocol = "torrent"
|
||||
Protocol = "torrent",
|
||||
SeriesId = 1,
|
||||
EpisodeId = 1
|
||||
};
|
||||
|
||||
_fixture.ArrQueueIterator
|
||||
@@ -389,6 +398,7 @@ public class QueueCleanerTests : IDisposable
|
||||
|
||||
var mockArrClient = new Mock<IArrClient>();
|
||||
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
|
||||
mockArrClient.Setup(x => x.HasContentId(It.IsAny<QueueRecord>())).Returns(true);
|
||||
|
||||
_fixture.ArrClientFactory
|
||||
.Setup(x => x.GetClient(InstanceType.Sonarr, It.IsAny<float>()))
|
||||
@@ -458,6 +468,7 @@ public class QueueCleanerTests : IDisposable
|
||||
|
||||
var mockArrClient = new Mock<IArrClient>();
|
||||
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
|
||||
mockArrClient.Setup(x => x.HasContentId(It.IsAny<QueueRecord>())).Returns(true);
|
||||
mockArrClient.Setup(x => x.ShouldRemoveFromQueue(
|
||||
It.IsAny<InstanceType>(),
|
||||
It.IsAny<QueueRecord>(),
|
||||
@@ -474,7 +485,9 @@ public class QueueCleanerTests : IDisposable
|
||||
Id = 1,
|
||||
DownloadId = "missing-download-id",
|
||||
Title = "Missing Download",
|
||||
Protocol = "torrent"
|
||||
Protocol = "torrent",
|
||||
SeriesId = 1,
|
||||
EpisodeId = 1
|
||||
};
|
||||
|
||||
_fixture.ArrQueueIterator
|
||||
@@ -527,6 +540,7 @@ public class QueueCleanerTests : IDisposable
|
||||
|
||||
var mockArrClient = new Mock<IArrClient>();
|
||||
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
|
||||
mockArrClient.Setup(x => x.HasContentId(It.IsAny<QueueRecord>())).Returns(true);
|
||||
mockArrClient.Setup(x => x.ShouldRemoveFromQueue(
|
||||
It.IsAny<InstanceType>(),
|
||||
It.IsAny<QueueRecord>(),
|
||||
@@ -543,7 +557,9 @@ public class QueueCleanerTests : IDisposable
|
||||
Id = 1,
|
||||
DownloadId = "download-id",
|
||||
Title = "Test Download",
|
||||
Protocol = "torrent"
|
||||
Protocol = "torrent",
|
||||
SeriesId = 1,
|
||||
EpisodeId = 1
|
||||
};
|
||||
|
||||
_fixture.ArrQueueIterator
|
||||
@@ -595,6 +611,7 @@ public class QueueCleanerTests : IDisposable
|
||||
|
||||
var mockArrClient = new Mock<IArrClient>();
|
||||
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
|
||||
mockArrClient.Setup(x => x.HasContentId(It.IsAny<QueueRecord>())).Returns(true);
|
||||
mockArrClient.Setup(x => x.ShouldRemoveFromQueue(
|
||||
It.IsAny<InstanceType>(),
|
||||
It.IsAny<QueueRecord>(),
|
||||
@@ -656,6 +673,147 @@ public class QueueCleanerTests : IDisposable
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessInstanceAsync_SkipsItem_WhenMissingContentId_AndProcessNoContentIdIsFalse()
|
||||
{
|
||||
// Arrange
|
||||
TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
|
||||
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
|
||||
|
||||
var mockArrClient = new Mock<IArrClient>();
|
||||
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
|
||||
mockArrClient.Setup(x => x.HasContentId(It.IsAny<QueueRecord>())).Returns(false);
|
||||
|
||||
_fixture.ArrClientFactory
|
||||
.Setup(x => x.GetClient(InstanceType.Sonarr, It.IsAny<float>()))
|
||||
.Returns(mockArrClient.Object);
|
||||
|
||||
var queueRecord = new QueueRecord
|
||||
{
|
||||
Id = 1,
|
||||
DownloadId = "no-content-id-download",
|
||||
Title = "No Content ID Download",
|
||||
Protocol = "torrent"
|
||||
};
|
||||
|
||||
_fixture.ArrQueueIterator
|
||||
.Setup(x => x.Iterate(
|
||||
It.IsAny<IArrClient>(),
|
||||
It.IsAny<ArrInstance>(),
|
||||
It.IsAny<Func<IReadOnlyList<QueueRecord>, Task>>()
|
||||
))
|
||||
.Returns(async (IArrClient client, ArrInstance instance, Func<IReadOnlyList<QueueRecord>, Task> callback) =>
|
||||
{
|
||||
await callback([queueRecord]);
|
||||
});
|
||||
|
||||
var sut = CreateSut();
|
||||
|
||||
// Act
|
||||
await sut.ExecuteAsync();
|
||||
|
||||
// Assert
|
||||
_logger.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Information,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("skip | item is missing the content id")),
|
||||
It.IsAny<Exception?>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()
|
||||
),
|
||||
Times.Once
|
||||
);
|
||||
|
||||
_fixture.MessageBus.Verify(
|
||||
x => x.Publish(
|
||||
It.IsAny<QueueItemRemoveRequest<SeriesSearchItem>>(),
|
||||
It.IsAny<CancellationToken>()
|
||||
),
|
||||
Times.Never
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessInstanceAsync_WhenMissingContentId_AndProcessNoContentIdIsTrue_PublishesRemoveRequestWithSkipSearch()
|
||||
{
|
||||
// Arrange
|
||||
TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
|
||||
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
|
||||
|
||||
var queueCleanerConfig = _fixture.DataContext.QueueCleanerConfigs.First();
|
||||
queueCleanerConfig.ProcessNoContentId = true;
|
||||
_fixture.DataContext.SaveChanges();
|
||||
|
||||
var mockArrClient = new Mock<IArrClient>();
|
||||
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
|
||||
mockArrClient.Setup(x => x.HasContentId(It.IsAny<QueueRecord>())).Returns(false);
|
||||
mockArrClient.Setup(x => x.ShouldRemoveFromQueue(
|
||||
It.IsAny<InstanceType>(),
|
||||
It.IsAny<QueueRecord>(),
|
||||
It.IsAny<bool>(),
|
||||
It.IsAny<short>()
|
||||
)).ReturnsAsync(false);
|
||||
|
||||
_fixture.ArrClientFactory
|
||||
.Setup(x => x.GetClient(InstanceType.Sonarr, It.IsAny<float>()))
|
||||
.Returns(mockArrClient.Object);
|
||||
|
||||
var queueRecord = new QueueRecord
|
||||
{
|
||||
Id = 1,
|
||||
DownloadId = "no-content-id-download",
|
||||
Title = "No Content ID Download",
|
||||
Protocol = "torrent"
|
||||
};
|
||||
|
||||
_fixture.ArrQueueIterator
|
||||
.Setup(x => x.Iterate(
|
||||
It.IsAny<IArrClient>(),
|
||||
It.IsAny<ArrInstance>(),
|
||||
It.IsAny<Func<IReadOnlyList<QueueRecord>, Task>>()
|
||||
))
|
||||
.Returns(async (IArrClient client, ArrInstance instance, Func<IReadOnlyList<QueueRecord>, Task> callback) =>
|
||||
{
|
||||
await callback([queueRecord]);
|
||||
});
|
||||
|
||||
var mockDownloadService = _fixture.CreateMockDownloadService();
|
||||
mockDownloadService
|
||||
.Setup(x => x.ShouldRemoveFromArrQueueAsync(
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<List<string>>()
|
||||
))
|
||||
.ReturnsAsync(new DownloadCheckResult
|
||||
{
|
||||
Found = true,
|
||||
ShouldRemove = true,
|
||||
IsPrivate = false,
|
||||
DeleteFromClient = true,
|
||||
DeleteReason = DeleteReason.Stalled
|
||||
});
|
||||
|
||||
_fixture.DownloadServiceFactory
|
||||
.Setup(x => x.GetDownloadService(It.IsAny<DownloadClientConfig>()))
|
||||
.Returns(mockDownloadService.Object);
|
||||
|
||||
var sut = CreateSut();
|
||||
|
||||
// Act
|
||||
await sut.ExecuteAsync();
|
||||
|
||||
// Assert - SkipSearch must be true because the item has no content ID
|
||||
_fixture.MessageBus.Verify(
|
||||
x => x.Publish(
|
||||
It.Is<QueueItemRemoveRequest<SeriesSearchItem>>(r =>
|
||||
r.SkipSearch == true &&
|
||||
r.DeleteReason == DeleteReason.Stalled
|
||||
),
|
||||
It.IsAny<CancellationToken>()
|
||||
),
|
||||
Times.Once
|
||||
);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Error Handling Tests
|
||||
@@ -669,6 +827,7 @@ public class QueueCleanerTests : IDisposable
|
||||
|
||||
var mockArrClient = new Mock<IArrClient>();
|
||||
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
|
||||
mockArrClient.Setup(x => x.HasContentId(It.IsAny<QueueRecord>())).Returns(true);
|
||||
mockArrClient.Setup(x => x.ShouldRemoveFromQueue(
|
||||
It.IsAny<InstanceType>(),
|
||||
It.IsAny<QueueRecord>(),
|
||||
@@ -685,7 +844,9 @@ public class QueueCleanerTests : IDisposable
|
||||
Id = 1,
|
||||
DownloadId = "error-download-id",
|
||||
Title = "Error Download",
|
||||
Protocol = "torrent"
|
||||
Protocol = "torrent",
|
||||
SeriesId = 1,
|
||||
EpisodeId = 1
|
||||
};
|
||||
|
||||
_fixture.ArrQueueIterator
|
||||
@@ -744,6 +905,7 @@ public class QueueCleanerTests : IDisposable
|
||||
|
||||
var mockArrClient = new Mock<IArrClient>();
|
||||
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
|
||||
mockArrClient.Setup(x => x.HasContentId(It.IsAny<QueueRecord>())).Returns(true);
|
||||
|
||||
_fixture.ArrClientFactory
|
||||
.Setup(x => x.GetClient(InstanceType.Radarr, It.IsAny<float>()))
|
||||
@@ -833,6 +995,7 @@ public class QueueCleanerTests : IDisposable
|
||||
|
||||
var mockArrClient = new Mock<IArrClient>();
|
||||
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
|
||||
mockArrClient.Setup(x => x.HasContentId(It.IsAny<QueueRecord>())).Returns(true);
|
||||
|
||||
_fixture.ArrClientFactory
|
||||
.Setup(x => x.GetClient(InstanceType.Radarr, It.IsAny<float>()))
|
||||
@@ -905,6 +1068,7 @@ public class QueueCleanerTests : IDisposable
|
||||
|
||||
var mockArrClient = new Mock<IArrClient>();
|
||||
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
|
||||
mockArrClient.Setup(x => x.HasContentId(It.IsAny<QueueRecord>())).Returns(true);
|
||||
|
||||
_fixture.ArrClientFactory
|
||||
.Setup(x => x.GetClient(InstanceType.Lidarr, It.IsAny<float>()))
|
||||
@@ -977,6 +1141,7 @@ public class QueueCleanerTests : IDisposable
|
||||
|
||||
var mockArrClient = new Mock<IArrClient>();
|
||||
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
|
||||
mockArrClient.Setup(x => x.HasContentId(It.IsAny<QueueRecord>())).Returns(true);
|
||||
|
||||
_fixture.ArrClientFactory
|
||||
.Setup(x => x.GetClient(InstanceType.Readarr, It.IsAny<float>()))
|
||||
@@ -1049,6 +1214,7 @@ public class QueueCleanerTests : IDisposable
|
||||
|
||||
var mockArrClient = new Mock<IArrClient>();
|
||||
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
|
||||
mockArrClient.Setup(x => x.HasContentId(It.IsAny<QueueRecord>())).Returns(true);
|
||||
|
||||
_fixture.ArrClientFactory
|
||||
.Setup(x => x.GetClient(InstanceType.Whisparr, 2f))
|
||||
@@ -1124,6 +1290,7 @@ public class QueueCleanerTests : IDisposable
|
||||
|
||||
var mockArrClient = new Mock<IArrClient>();
|
||||
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
|
||||
mockArrClient.Setup(x => x.HasContentId(It.IsAny<QueueRecord>())).Returns(true);
|
||||
|
||||
_fixture.ArrClientFactory
|
||||
.Setup(x => x.GetClient(InstanceType.Whisparr, 3f))
|
||||
@@ -1196,6 +1363,7 @@ public class QueueCleanerTests : IDisposable
|
||||
|
||||
var mockArrClient = new Mock<IArrClient>();
|
||||
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
|
||||
mockArrClient.Setup(x => x.HasContentId(It.IsAny<QueueRecord>())).Returns(true);
|
||||
|
||||
_fixture.ArrClientFactory
|
||||
.Setup(x => x.GetClient(InstanceType.Whisparr, 2f))
|
||||
|
||||
-1
@@ -75,7 +75,6 @@ public static class TestDataContextFactory
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
IgnoredDownloads = [],
|
||||
DeleteKnownMalware = false,
|
||||
DeletePrivate = false,
|
||||
Sonarr = new BlocklistSettings { Enabled = false },
|
||||
Radarr = new BlocklistSettings { Enabled = false },
|
||||
|
||||
-28
@@ -118,34 +118,6 @@ public class BlocklistProviderTests : IDisposable
|
||||
result.Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMalwarePatterns_NotInCache_ReturnsEmptyBag()
|
||||
{
|
||||
// Act
|
||||
var result = _provider.GetMalwarePatterns();
|
||||
|
||||
// Assert
|
||||
result.ShouldNotBeNull();
|
||||
result.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMalwarePatterns_InCache_ReturnsCachedPatterns()
|
||||
{
|
||||
// Arrange
|
||||
var patterns = new ConcurrentBag<string> { "known_malware.exe", "trojan*", "virus.dll" };
|
||||
_cache.Set(CacheKeys.KnownMalwarePatterns(), patterns);
|
||||
|
||||
// Act
|
||||
var result = _provider.GetMalwarePatterns();
|
||||
|
||||
// Assert
|
||||
result.Count.ShouldBe(3);
|
||||
result.ShouldContain("known_malware.exe");
|
||||
result.ShouldContain("trojan*");
|
||||
result.ShouldContain("virus.dll");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(InstanceType.Sonarr)]
|
||||
[InlineData(InstanceType.Radarr)]
|
||||
|
||||
+2
-2
@@ -271,12 +271,12 @@ public class NotificationPublisherTests
|
||||
.Returns(providerMock.Object);
|
||||
|
||||
// Act
|
||||
await _publisher.NotifyQueueItemDeleted(false, DeleteReason.MalwareFileFound);
|
||||
await _publisher.NotifyQueueItemDeleted(false, DeleteReason.AllFilesBlocked);
|
||||
|
||||
// Assert
|
||||
providerMock.Verify(p => p.SendNotificationAsync(It.Is<NotificationContext>(
|
||||
c => c.Data["Removed from client?"] == "False" &&
|
||||
c.Data["Reason"] == "MalwareFileFound")), Times.Once);
|
||||
c.Data["Reason"] == "AllFilesBlocked")), Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
<PackageReference Include="Mapster" Version="7.4.0" />
|
||||
<PackageReference Include="MassTransit.Abstractions" Version="8.5.7" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.SignalR" Version="1.2.0" />
|
||||
<PackageReference Include="Microsoft.IdentityModel.Protocols.OpenIdConnect" Version="8.7.0" />
|
||||
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.7.0" />
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.7.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.1" />
|
||||
|
||||
@@ -232,7 +232,7 @@ public class EventPublisher : IEventPublisher
|
||||
public async Task PublishSearchNotTriggered(string hash, string itemName)
|
||||
{
|
||||
await PublishManualAsync(
|
||||
"Replacement search was not triggered after removal because the item keeps coming back\nPlease trigger a manual search if needed",
|
||||
"Replacement search was not triggered after removal\nPlease trigger a manual search if needed",
|
||||
EventSeverity.Warning,
|
||||
data: new { itemName, hash }
|
||||
);
|
||||
|
||||
@@ -158,7 +158,7 @@ public abstract class ArrClient : IArrClient
|
||||
|
||||
public abstract Task SearchItemsAsync(ArrInstance arrInstance, HashSet<SearchItem>? items);
|
||||
|
||||
public virtual bool IsRecordValid(QueueRecord record)
|
||||
public bool IsRecordValid(QueueRecord record)
|
||||
{
|
||||
if (string.IsNullOrEmpty(record.DownloadId))
|
||||
{
|
||||
@@ -169,6 +169,8 @@ public abstract class ArrClient : IArrClient
|
||||
return true;
|
||||
}
|
||||
|
||||
public abstract bool HasContentId(QueueRecord record);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task HealthCheckAsync(ArrInstance arrInstance)
|
||||
{
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Cleanuparr.Shared.Attributes;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Features.Arr.Dtos;
|
||||
|
||||
@@ -23,6 +24,7 @@ public record ArrInstanceDto
|
||||
public required string Url { get; init; }
|
||||
|
||||
[Required]
|
||||
[SensitiveData]
|
||||
public required string ApiKey { get; init; }
|
||||
|
||||
public string? ExternalUrl { get; init; }
|
||||
|
||||
@@ -17,6 +17,13 @@ public interface IArrClient
|
||||
|
||||
bool IsRecordValid(QueueRecord record);
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether the record has an id (movie id, tv show id etc.)
|
||||
/// </summary>
|
||||
/// <param name="record">The record to check</param>
|
||||
/// <returns>True if the record has an id, false otherwise</returns>
|
||||
bool HasContentId(QueueRecord record);
|
||||
|
||||
/// <summary>
|
||||
/// Tests the connection to an Arr instance
|
||||
/// </summary>
|
||||
|
||||
@@ -87,16 +87,7 @@ public class LidarrClient : ArrClient, ILidarrClient
|
||||
}
|
||||
}
|
||||
|
||||
public override bool IsRecordValid(QueueRecord record)
|
||||
{
|
||||
if (record.ArtistId is 0 || record.AlbumId is 0)
|
||||
{
|
||||
_logger.LogDebug("skip | artist id and/or album id missing | {title}", record.Title);
|
||||
return false;
|
||||
}
|
||||
|
||||
return base.IsRecordValid(record);
|
||||
}
|
||||
public override bool HasContentId(QueueRecord record) => record.ArtistId is not 0 && record.AlbumId is not 0;
|
||||
|
||||
private static string GetSearchLog(
|
||||
Uri instanceUrl,
|
||||
|
||||
@@ -92,16 +92,7 @@ public class RadarrClient : ArrClient, IRadarrClient
|
||||
}
|
||||
}
|
||||
|
||||
public override bool IsRecordValid(QueueRecord record)
|
||||
{
|
||||
if (record.MovieId is 0)
|
||||
{
|
||||
_logger.LogDebug("skip | movie id missing | {title}", record.Title);
|
||||
return false;
|
||||
}
|
||||
|
||||
return base.IsRecordValid(record);
|
||||
}
|
||||
public override bool HasContentId(QueueRecord record) => record.MovieId is not 0;
|
||||
|
||||
private static string GetSearchLog(Uri instanceUrl, RadarrCommand command, bool success, string? logContext)
|
||||
{
|
||||
|
||||
@@ -92,16 +92,7 @@ public class ReadarrClient : ArrClient, IReadarrClient
|
||||
}
|
||||
}
|
||||
|
||||
public override bool IsRecordValid(QueueRecord record)
|
||||
{
|
||||
if (record.AuthorId is 0 || record.BookId is 0)
|
||||
{
|
||||
_logger.LogDebug("skip | author id and/or book id missing | {title}", record.Title);
|
||||
return false;
|
||||
}
|
||||
|
||||
return base.IsRecordValid(record);
|
||||
}
|
||||
public override bool HasContentId(QueueRecord record) => record.AuthorId is not 0 && record.BookId is not 0;
|
||||
|
||||
private static string GetSearchLog(Uri instanceUrl, ReadarrCommand command, bool success, string? logContext)
|
||||
{
|
||||
|
||||
@@ -90,16 +90,7 @@ public class SonarrClient : ArrClient, ISonarrClient
|
||||
}
|
||||
}
|
||||
|
||||
public override bool IsRecordValid(QueueRecord record)
|
||||
{
|
||||
if (record.EpisodeId is 0 || record.SeriesId is 0)
|
||||
{
|
||||
_logger.LogDebug("skip | episode id and/or series id missing | {title}", record.Title);
|
||||
return false;
|
||||
}
|
||||
|
||||
return base.IsRecordValid(record);
|
||||
}
|
||||
public override bool HasContentId(QueueRecord record) => record.EpisodeId is not 0 && record.SeriesId is not 0;
|
||||
|
||||
private static string GetSearchLog(
|
||||
SeriesSearchType searchType,
|
||||
|
||||
@@ -90,16 +90,7 @@ public class WhisparrV2Client : ArrClient, IWhisparrV2Client
|
||||
}
|
||||
}
|
||||
|
||||
public override bool IsRecordValid(QueueRecord record)
|
||||
{
|
||||
if (record.EpisodeId is 0 || record.SeriesId is 0)
|
||||
{
|
||||
_logger.LogDebug("skip | episode id and/or series id missing | {title}", record.Title);
|
||||
return false;
|
||||
}
|
||||
|
||||
return base.IsRecordValid(record);
|
||||
}
|
||||
public override bool HasContentId(QueueRecord record) => record.EpisodeId is not 0 && record.SeriesId is not 0;
|
||||
|
||||
private static string GetSearchLog(
|
||||
SeriesSearchType searchType,
|
||||
|
||||
@@ -93,16 +93,7 @@ public class WhisparrV3Client : ArrClient, IWhisparrV3Client
|
||||
}
|
||||
}
|
||||
|
||||
public override bool IsRecordValid(QueueRecord record)
|
||||
{
|
||||
if (record.MovieId is 0)
|
||||
{
|
||||
_logger.LogDebug("skip | movie id missing | {title}", record.Title);
|
||||
return false;
|
||||
}
|
||||
|
||||
return base.IsRecordValid(record);
|
||||
}
|
||||
public override bool HasContentId(QueueRecord record) => record.MovieId is not 0;
|
||||
|
||||
private static string GetSearchLog(Uri instanceUrl, WhisparrV3Command command, bool success, string? logContext)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
namespace Cleanuparr.Infrastructure.Features.Auth;
|
||||
|
||||
public sealed record OidcAuthorizationResult
|
||||
{
|
||||
public required string AuthorizationUrl { get; init; }
|
||||
public required string State { get; init; }
|
||||
}
|
||||
|
||||
public sealed record OidcCallbackResult
|
||||
{
|
||||
public required bool Success { get; init; }
|
||||
public string? Subject { get; init; }
|
||||
public string? PreferredUsername { get; init; }
|
||||
public string? Email { get; init; }
|
||||
public string? Error { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The user ID of the authenticated user who initiated this OIDC flow.
|
||||
/// Set when the flow is started from an authenticated context (e.g., account linking).
|
||||
/// Used to verify the callback is completing the correct user's flow.
|
||||
/// </summary>
|
||||
public string? InitiatorUserId { get; init; }
|
||||
}
|
||||
|
||||
public interface IOidcAuthService
|
||||
{
|
||||
/// <summary>
|
||||
/// Generates the OIDC authorization URL and stores state/verifier for the callback.
|
||||
/// </summary>
|
||||
/// <param name="redirectUri">The callback URI for the OIDC provider.</param>
|
||||
/// <param name="initiatorUserId">Optional user ID of the authenticated user initiating the flow (for account linking).</param>
|
||||
Task<OidcAuthorizationResult> StartAuthorization(string redirectUri, string? initiatorUserId = null);
|
||||
|
||||
/// <summary>
|
||||
/// Handles the OIDC callback: validates state, exchanges code for tokens, validates the ID token.
|
||||
/// </summary>
|
||||
Task<OidcCallbackResult> HandleCallback(string code, string state, string redirectUri);
|
||||
|
||||
/// <summary>
|
||||
/// Stores tokens associated with a one-time exchange code.
|
||||
/// Returns the one-time code.
|
||||
/// </summary>
|
||||
string StoreOneTimeCode(string accessToken, string refreshToken, int expiresIn);
|
||||
|
||||
/// <summary>
|
||||
/// Exchanges a one-time code for the stored tokens.
|
||||
/// The code is consumed (can only be used once).
|
||||
/// </summary>
|
||||
OidcTokenExchangeResult? ExchangeOneTimeCode(string code);
|
||||
}
|
||||
|
||||
public sealed record OidcTokenExchangeResult
|
||||
{
|
||||
public required string AccessToken { get; init; }
|
||||
public required string RefreshToken { get; init; }
|
||||
public required int ExpiresIn { get; init; }
|
||||
}
|
||||
@@ -2,6 +2,9 @@ namespace Cleanuparr.Infrastructure.Features.Auth;
|
||||
|
||||
public interface IPasswordService
|
||||
{
|
||||
string DummyHash { get; }
|
||||
|
||||
string HashPassword(string password);
|
||||
|
||||
bool VerifyPassword(string password, string hash);
|
||||
}
|
||||
@@ -11,7 +11,7 @@ public sealed class JwtService : IJwtService
|
||||
{
|
||||
private const string Issuer = "Cleanuparr";
|
||||
private const string Audience = "Cleanuparr";
|
||||
private static readonly TimeSpan AccessTokenLifetime = TimeSpan.FromMinutes(1);
|
||||
private static readonly TimeSpan AccessTokenLifetime = TimeSpan.FromHours(1);
|
||||
private static readonly TimeSpan LoginTokenLifetime = TimeSpan.FromMinutes(5);
|
||||
|
||||
private readonly byte[] _signingKey;
|
||||
|
||||
@@ -0,0 +1,523 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Net.Http.Json;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Auth;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.IdentityModel.Protocols;
|
||||
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Features.Auth;
|
||||
|
||||
public sealed class OidcAuthService : IOidcAuthService
|
||||
{
|
||||
private const int MaxPendingFlows = 100;
|
||||
private const int MaxOneTimeCodes = 100;
|
||||
private static readonly TimeSpan FlowStateExpiry = TimeSpan.FromMinutes(10);
|
||||
private static readonly TimeSpan OneTimeCodeExpiry = TimeSpan.FromSeconds(30);
|
||||
|
||||
private static readonly ConcurrentDictionary<string, OidcFlowState> PendingFlows = new();
|
||||
private static readonly ConcurrentDictionary<string, OidcOneTimeCodeEntry> OneTimeCodes = new();
|
||||
private static readonly ConcurrentDictionary<string, ConfigurationManager<OpenIdConnectConfiguration>> ConfigManagers = new();
|
||||
|
||||
// Reference held to prevent GC collection; the timer fires CleanupExpiredEntries every minute
|
||||
#pragma warning disable IDE0052
|
||||
private static readonly Timer CleanupTimer = new(CleanupExpiredEntries, null, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(1));
|
||||
#pragma warning restore IDE0052
|
||||
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly UsersContext _usersContext;
|
||||
private readonly ILogger<OidcAuthService> _logger;
|
||||
|
||||
public OidcAuthService(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
UsersContext usersContext,
|
||||
ILogger<OidcAuthService> logger)
|
||||
{
|
||||
_httpClient = httpClientFactory.CreateClient("OidcAuth");
|
||||
_usersContext = usersContext;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<OidcAuthorizationResult> StartAuthorization(string redirectUri, string? initiatorUserId = null)
|
||||
{
|
||||
var oidcConfig = await GetOidcConfig();
|
||||
|
||||
if (!oidcConfig.Enabled)
|
||||
{
|
||||
throw new InvalidOperationException("OIDC is not enabled");
|
||||
}
|
||||
|
||||
if (PendingFlows.Count >= MaxPendingFlows)
|
||||
{
|
||||
throw new InvalidOperationException("Too many pending OIDC flows. Please try again later.");
|
||||
}
|
||||
|
||||
var discovery = await GetDiscoveryDocument(oidcConfig.IssuerUrl);
|
||||
|
||||
var state = GenerateRandomString();
|
||||
var nonce = GenerateRandomString();
|
||||
var codeVerifier = GenerateRandomString();
|
||||
var codeChallenge = ComputeCodeChallenge(codeVerifier);
|
||||
|
||||
var flowState = new OidcFlowState
|
||||
{
|
||||
State = state,
|
||||
Nonce = nonce,
|
||||
CodeVerifier = codeVerifier,
|
||||
RedirectUri = redirectUri,
|
||||
InitiatorUserId = initiatorUserId,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
if (!PendingFlows.TryAdd(state, flowState))
|
||||
{
|
||||
throw new InvalidOperationException("Failed to store OIDC flow state");
|
||||
}
|
||||
|
||||
var authUrl = BuildAuthorizationUrl(
|
||||
discovery.AuthorizationEndpoint,
|
||||
oidcConfig.ClientId,
|
||||
redirectUri,
|
||||
oidcConfig.Scopes,
|
||||
state,
|
||||
nonce,
|
||||
codeChallenge);
|
||||
|
||||
_logger.LogDebug("OIDC authorization started with state {State}", state);
|
||||
|
||||
return new OidcAuthorizationResult
|
||||
{
|
||||
AuthorizationUrl = authUrl,
|
||||
State = state
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<OidcCallbackResult> HandleCallback(string code, string state, string redirectUri)
|
||||
{
|
||||
if (!PendingFlows.TryGetValue(state, out var flowState))
|
||||
{
|
||||
_logger.LogWarning("OIDC callback with invalid or expired state: {State}", state);
|
||||
return new OidcCallbackResult
|
||||
{
|
||||
Success = false,
|
||||
Error = "Invalid or expired OIDC state"
|
||||
};
|
||||
}
|
||||
|
||||
if (DateTime.UtcNow - flowState.CreatedAt > FlowStateExpiry)
|
||||
{
|
||||
PendingFlows.TryRemove(state, out _);
|
||||
_logger.LogWarning("OIDC flow state expired for state: {State}", state);
|
||||
return new OidcCallbackResult
|
||||
{
|
||||
Success = false,
|
||||
Error = "OIDC flow has expired"
|
||||
};
|
||||
}
|
||||
|
||||
if (flowState.RedirectUri != redirectUri)
|
||||
{
|
||||
_logger.LogWarning("OIDC callback redirect URI mismatch. Expected: {Expected}, Got: {Got}",
|
||||
flowState.RedirectUri, redirectUri);
|
||||
return new OidcCallbackResult
|
||||
{
|
||||
Success = false,
|
||||
Error = "Redirect URI mismatch"
|
||||
};
|
||||
}
|
||||
|
||||
// Validation passed — consume the state
|
||||
PendingFlows.TryRemove(state, out _);
|
||||
|
||||
var oidcConfig = await GetOidcConfig();
|
||||
var discovery = await GetDiscoveryDocument(oidcConfig.IssuerUrl);
|
||||
|
||||
// Exchange authorization code for tokens
|
||||
var tokenResponse = await ExchangeCodeForTokens(
|
||||
discovery.TokenEndpoint,
|
||||
code,
|
||||
flowState.CodeVerifier,
|
||||
redirectUri,
|
||||
oidcConfig.ClientId,
|
||||
oidcConfig.ClientSecret);
|
||||
|
||||
if (tokenResponse is null)
|
||||
{
|
||||
return new OidcCallbackResult
|
||||
{
|
||||
Success = false,
|
||||
Error = "Failed to exchange authorization code"
|
||||
};
|
||||
}
|
||||
|
||||
// Validate the ID token
|
||||
var validatedToken = await ValidateIdToken(
|
||||
tokenResponse.IdToken,
|
||||
oidcConfig,
|
||||
discovery,
|
||||
flowState.Nonce);
|
||||
|
||||
if (validatedToken is null)
|
||||
{
|
||||
return new OidcCallbackResult
|
||||
{
|
||||
Success = false,
|
||||
Error = "ID token validation failed"
|
||||
};
|
||||
}
|
||||
|
||||
var subject = validatedToken.Claims.FirstOrDefault(c => c.Type == "sub")?.Value;
|
||||
var preferredUsername = validatedToken.Claims.FirstOrDefault(c => c.Type == "preferred_username")?.Value;
|
||||
var email = validatedToken.Claims.FirstOrDefault(c => c.Type == "email")?.Value;
|
||||
|
||||
if (string.IsNullOrEmpty(subject))
|
||||
{
|
||||
return new OidcCallbackResult
|
||||
{
|
||||
Success = false,
|
||||
Error = "ID token missing 'sub' claim"
|
||||
};
|
||||
}
|
||||
|
||||
_logger.LogInformation("OIDC authentication successful for subject: {Subject}", subject);
|
||||
|
||||
return new OidcCallbackResult
|
||||
{
|
||||
Success = true,
|
||||
Subject = subject,
|
||||
PreferredUsername = preferredUsername,
|
||||
Email = email,
|
||||
InitiatorUserId = flowState.InitiatorUserId
|
||||
};
|
||||
}
|
||||
|
||||
public string StoreOneTimeCode(string accessToken, string refreshToken, int expiresIn)
|
||||
{
|
||||
// Clean up if at capacity
|
||||
if (OneTimeCodes.Count >= MaxOneTimeCodes)
|
||||
{
|
||||
CleanupExpiredOneTimeCodes();
|
||||
|
||||
// If still at capacity after cleanup, evict oldest entries
|
||||
while (OneTimeCodes.Count >= MaxOneTimeCodes)
|
||||
{
|
||||
var oldest = OneTimeCodes.OrderBy(x => x.Value.CreatedAt).FirstOrDefault();
|
||||
if (oldest.Key is not null)
|
||||
{
|
||||
OneTimeCodes.TryRemove(oldest.Key, out _);
|
||||
}
|
||||
else
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var entry = new OidcOneTimeCodeEntry
|
||||
{
|
||||
AccessToken = accessToken,
|
||||
RefreshToken = refreshToken,
|
||||
ExpiresIn = expiresIn,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
// Retry with new codes on collision
|
||||
for (var i = 0; i < 3; i++)
|
||||
{
|
||||
var code = GenerateRandomString();
|
||||
if (OneTimeCodes.TryAdd(code, entry))
|
||||
{
|
||||
return code;
|
||||
}
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("Failed to generate a unique one-time code");
|
||||
}
|
||||
|
||||
public OidcTokenExchangeResult? ExchangeOneTimeCode(string code)
|
||||
{
|
||||
if (!OneTimeCodes.TryRemove(code, out var entry))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (DateTime.UtcNow - entry.CreatedAt > OneTimeCodeExpiry)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new OidcTokenExchangeResult
|
||||
{
|
||||
AccessToken = entry.AccessToken,
|
||||
RefreshToken = entry.RefreshToken,
|
||||
ExpiresIn = entry.ExpiresIn
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<OidcConfig> GetOidcConfig()
|
||||
{
|
||||
var user = await _usersContext.Users.AsNoTracking().FirstOrDefaultAsync();
|
||||
return user?.Oidc ?? new OidcConfig();
|
||||
}
|
||||
|
||||
private async Task<OpenIdConnectConfiguration> GetDiscoveryDocument(string issuerUrl)
|
||||
{
|
||||
var metadataAddress = issuerUrl.TrimEnd('/') + "/.well-known/openid-configuration";
|
||||
|
||||
var configManager = ConfigManagers.GetOrAdd(issuerUrl, _ =>
|
||||
{
|
||||
var isLocalhost = Uri.TryCreate(issuerUrl, UriKind.Absolute, out var uri) &&
|
||||
uri.Host is "localhost" or "127.0.0.1" or "::1" or "[::1]";
|
||||
return new ConfigurationManager<OpenIdConnectConfiguration>(
|
||||
metadataAddress,
|
||||
new OpenIdConnectConfigurationRetriever(),
|
||||
new HttpDocumentRetriever(_httpClient) { RequireHttps = !isLocalhost });
|
||||
});
|
||||
|
||||
return await configManager.GetConfigurationAsync();
|
||||
}
|
||||
|
||||
private async Task<OidcTokenResponse?> ExchangeCodeForTokens(
|
||||
string tokenEndpoint,
|
||||
string code,
|
||||
string codeVerifier,
|
||||
string redirectUri,
|
||||
string clientId,
|
||||
string clientSecret)
|
||||
{
|
||||
var parameters = new Dictionary<string, string>
|
||||
{
|
||||
["grant_type"] = "authorization_code",
|
||||
["code"] = code,
|
||||
["redirect_uri"] = redirectUri,
|
||||
["client_id"] = clientId,
|
||||
["code_verifier"] = codeVerifier
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(clientSecret))
|
||||
{
|
||||
parameters["client_secret"] = clientSecret;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, tokenEndpoint)
|
||||
{
|
||||
Content = new FormUrlEncodedContent(parameters)
|
||||
};
|
||||
|
||||
var response = await _httpClient.SendAsync(request);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorBody = await response.Content.ReadAsStringAsync();
|
||||
_logger.LogError("OIDC token exchange failed with status {Status}: {Body}",
|
||||
response.StatusCode, errorBody);
|
||||
return null;
|
||||
}
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<OidcTokenResponse>();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "OIDC token exchange failed");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<JwtSecurityToken?> ValidateIdToken(
|
||||
string idToken,
|
||||
OidcConfig oidcConfig,
|
||||
OpenIdConnectConfiguration discovery,
|
||||
string expectedNonce)
|
||||
{
|
||||
var handler = new JwtSecurityTokenHandler();
|
||||
|
||||
var validationParameters = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = true,
|
||||
ValidIssuers = new[]
|
||||
{
|
||||
oidcConfig.IssuerUrl.TrimEnd('/'),
|
||||
oidcConfig.IssuerUrl.TrimEnd('/') + "/"
|
||||
},
|
||||
ValidateAudience = true,
|
||||
ValidAudience = oidcConfig.ClientId,
|
||||
ValidateLifetime = true,
|
||||
ValidateIssuerSigningKey = true,
|
||||
// Bypass lifetime validation
|
||||
IssuerSigningKeyValidator = (_, _, _) => true,
|
||||
IssuerSigningKeys = discovery.SigningKeys,
|
||||
ClockSkew = TimeSpan.FromMinutes(2)
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
handler.ValidateToken(idToken, validationParameters, out var validatedSecurityToken);
|
||||
var jwtToken = (JwtSecurityToken)validatedSecurityToken;
|
||||
|
||||
return ValidateNonce(jwtToken, expectedNonce) ? jwtToken : null;
|
||||
}
|
||||
catch (SecurityTokenSignatureKeyNotFoundException)
|
||||
{
|
||||
// Try refreshing the configuration (JWKS key rotation)
|
||||
_logger.LogInformation("OIDC signing key not found, refreshing configuration");
|
||||
|
||||
if (ConfigManagers.TryGetValue(oidcConfig.IssuerUrl, out var configManager))
|
||||
{
|
||||
configManager.RequestRefresh();
|
||||
var refreshedConfig = await configManager.GetConfigurationAsync();
|
||||
validationParameters.IssuerSigningKeys = refreshedConfig.SigningKeys;
|
||||
|
||||
try
|
||||
{
|
||||
handler.ValidateToken(idToken, validationParameters, out var retryToken);
|
||||
var jwtRetryToken = (JwtSecurityToken)retryToken;
|
||||
|
||||
return ValidateNonce(jwtRetryToken, expectedNonce) ? jwtRetryToken : null;
|
||||
}
|
||||
catch (Exception retryEx)
|
||||
{
|
||||
_logger.LogError(retryEx, "OIDC ID token validation failed after key refresh");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "OIDC ID token validation failed");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildAuthorizationUrl(
|
||||
string authorizationEndpoint,
|
||||
string clientId,
|
||||
string redirectUri,
|
||||
string scopes,
|
||||
string state,
|
||||
string nonce,
|
||||
string codeChallenge)
|
||||
{
|
||||
var queryParams = new Dictionary<string, string>
|
||||
{
|
||||
["response_type"] = "code",
|
||||
["client_id"] = clientId,
|
||||
["redirect_uri"] = redirectUri,
|
||||
["scope"] = scopes,
|
||||
["state"] = state,
|
||||
["nonce"] = nonce,
|
||||
["code_challenge"] = codeChallenge,
|
||||
["code_challenge_method"] = "S256"
|
||||
};
|
||||
|
||||
var queryString = string.Join("&",
|
||||
queryParams.Select(kvp => $"{Uri.EscapeDataString(kvp.Key)}={Uri.EscapeDataString(kvp.Value)}"));
|
||||
|
||||
return $"{authorizationEndpoint}?{queryString}";
|
||||
}
|
||||
|
||||
private bool ValidateNonce(JwtSecurityToken jwtToken, string expectedNonce)
|
||||
{
|
||||
var tokenNonce = jwtToken.Claims.FirstOrDefault(c => c.Type == "nonce")?.Value;
|
||||
if (tokenNonce == expectedNonce) return true;
|
||||
|
||||
_logger.LogWarning("OIDC ID token nonce mismatch. Expected: {Expected}, Got: {Got}",
|
||||
expectedNonce, tokenNonce);
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string GenerateRandomString()
|
||||
{
|
||||
var bytes = new byte[32];
|
||||
using var rng = RandomNumberGenerator.Create();
|
||||
rng.GetBytes(bytes);
|
||||
return Base64UrlEncode(bytes);
|
||||
}
|
||||
|
||||
private static string ComputeCodeChallenge(string codeVerifier)
|
||||
{
|
||||
var bytes = SHA256.HashData(Encoding.ASCII.GetBytes(codeVerifier));
|
||||
return Base64UrlEncode(bytes);
|
||||
}
|
||||
|
||||
private static string Base64UrlEncode(byte[] bytes)
|
||||
{
|
||||
return Convert.ToBase64String(bytes)
|
||||
.TrimEnd('=')
|
||||
.Replace('+', '-')
|
||||
.Replace('/', '_');
|
||||
}
|
||||
|
||||
private static void CleanupExpiredEntries(object? state)
|
||||
{
|
||||
var flowCutoff = DateTime.UtcNow - FlowStateExpiry;
|
||||
foreach (var kvp in PendingFlows)
|
||||
{
|
||||
if (kvp.Value.CreatedAt < flowCutoff)
|
||||
{
|
||||
PendingFlows.TryRemove(kvp.Key, out _);
|
||||
}
|
||||
}
|
||||
|
||||
CleanupExpiredOneTimeCodes();
|
||||
}
|
||||
|
||||
private static void CleanupExpiredOneTimeCodes()
|
||||
{
|
||||
var codeCutoff = DateTime.UtcNow - OneTimeCodeExpiry;
|
||||
foreach (var kvp in OneTimeCodes)
|
||||
{
|
||||
if (kvp.Value.CreatedAt < codeCutoff)
|
||||
{
|
||||
OneTimeCodes.TryRemove(kvp.Key, out _);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears the cached OIDC discovery configuration. Used when issuer URL changes.
|
||||
/// </summary>
|
||||
public static void ClearDiscoveryCache()
|
||||
{
|
||||
ConfigManagers.Clear();
|
||||
}
|
||||
|
||||
private sealed class OidcFlowState
|
||||
{
|
||||
public required string State { get; init; }
|
||||
public required string Nonce { get; init; }
|
||||
public required string CodeVerifier { get; init; }
|
||||
public required string RedirectUri { get; init; }
|
||||
public string? InitiatorUserId { get; init; }
|
||||
public required DateTime CreatedAt { get; init; }
|
||||
}
|
||||
|
||||
private sealed class OidcOneTimeCodeEntry
|
||||
{
|
||||
public required string AccessToken { get; init; }
|
||||
public required string RefreshToken { get; init; }
|
||||
public required int ExpiresIn { get; init; }
|
||||
public required DateTime CreatedAt { get; init; }
|
||||
}
|
||||
|
||||
private sealed class OidcTokenResponse
|
||||
{
|
||||
[System.Text.Json.Serialization.JsonPropertyName("id_token")]
|
||||
public string IdToken { get; set; } = string.Empty;
|
||||
|
||||
[System.Text.Json.Serialization.JsonPropertyName("access_token")]
|
||||
public string AccessToken { get; set; } = string.Empty;
|
||||
|
||||
[System.Text.Json.Serialization.JsonPropertyName("token_type")]
|
||||
public string TokenType { get; set; } = string.Empty;
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,11 @@ public sealed class PasswordService : IPasswordService
|
||||
{
|
||||
private const int WorkFactor = 12;
|
||||
|
||||
/// <summary>
|
||||
/// Pre-computed BCrypt hash with a work factor of 12 used as a fallback when no user exists
|
||||
/// </summary>
|
||||
public string DummyHash => "$2a$12$tQw4MgGGq7WTFro3Me4mQOekctJ0mIOYmFMn.XEmEbyZhBq0i4qKy";
|
||||
|
||||
public string HashPassword(string password)
|
||||
{
|
||||
return BCrypt.Net.BCrypt.HashPassword(password, WorkFactor);
|
||||
|
||||
-8
@@ -68,7 +68,6 @@ public partial class DelugeService
|
||||
BlocklistType blocklistType = _blocklistProvider.GetBlocklistType(instanceType);
|
||||
ConcurrentBag<string> patterns = _blocklistProvider.GetPatterns(instanceType);
|
||||
ConcurrentBag<Regex> regexes = _blocklistProvider.GetRegexes(instanceType);
|
||||
ConcurrentBag<string> malwarePatterns = _blocklistProvider.GetMalwarePatterns();
|
||||
|
||||
ProcessFiles(contents.Contents, (name, file) =>
|
||||
{
|
||||
@@ -79,13 +78,6 @@ public partial class DelugeService
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (malwareBlockerConfig.DeleteKnownMalware && _filenameEvaluator.IsKnownMalware(name, malwarePatterns))
|
||||
{
|
||||
_logger.LogInformation("malware file found | {file} | {title}", file.Path, download.Name);
|
||||
result.ShouldRemove = true;
|
||||
result.DeleteReason = DeleteReason.MalwareFileFound;
|
||||
}
|
||||
|
||||
if (file.Priority is 0)
|
||||
{
|
||||
|
||||
+1
-10
@@ -73,8 +73,7 @@ public partial class QBitService
|
||||
BlocklistType blocklistType = _blocklistProvider.GetBlocklistType(instanceType);
|
||||
ConcurrentBag<string> patterns = _blocklistProvider.GetPatterns(instanceType);
|
||||
ConcurrentBag<Regex> regexes = _blocklistProvider.GetRegexes(instanceType);
|
||||
ConcurrentBag<string> malwarePatterns = _blocklistProvider.GetMalwarePatterns();
|
||||
|
||||
|
||||
foreach (TorrentContent file in files)
|
||||
{
|
||||
if (!file.Index.HasValue)
|
||||
@@ -84,14 +83,6 @@ public partial class QBitService
|
||||
}
|
||||
|
||||
totalFiles++;
|
||||
|
||||
if (malwareBlockerConfig.DeleteKnownMalware && _filenameEvaluator.IsKnownMalware(file.Name, malwarePatterns))
|
||||
{
|
||||
_logger.LogInformation("malware file found | {file} | {title}", file.Name, download.Name);
|
||||
result.ShouldRemove = true;
|
||||
result.DeleteReason = DeleteReason.MalwareFileFound;
|
||||
return result;
|
||||
}
|
||||
|
||||
if (file.Priority is TorrentContentPriority.Skip)
|
||||
{
|
||||
|
||||
-8
@@ -71,7 +71,6 @@ public partial class RTorrentService
|
||||
BlocklistType blocklistType = _blocklistProvider.GetBlocklistType(instanceType);
|
||||
ConcurrentBag<string> patterns = _blocklistProvider.GetPatterns(instanceType);
|
||||
ConcurrentBag<Regex> regexes = _blocklistProvider.GetRegexes(instanceType);
|
||||
ConcurrentBag<string> malwarePatterns = _blocklistProvider.GetMalwarePatterns();
|
||||
|
||||
List<(int Index, int Priority)> priorityUpdates = [];
|
||||
|
||||
@@ -85,13 +84,6 @@ public partial class RTorrentService
|
||||
continue;
|
||||
}
|
||||
|
||||
if (malwareBlockerConfig.DeleteKnownMalware && _filenameEvaluator.IsKnownMalware(fileName, malwarePatterns))
|
||||
{
|
||||
_logger.LogInformation("malware file found | {file} | {title}", file.Path, download.Name);
|
||||
result.ShouldRemove = true;
|
||||
result.DeleteReason = DeleteReason.MalwareFileFound;
|
||||
}
|
||||
|
||||
if (file.Priority == 0)
|
||||
{
|
||||
_logger.LogTrace("File is already skipped | {file}", file.Path);
|
||||
|
||||
Loaded 100 of 220 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user