mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2026-09-09 03:47:42 -04:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
156dc8bf34 | ||
|
|
bba4e943bc | ||
|
|
eee5af9268 | ||
|
|
d4485748a0 | ||
|
|
68aac966f2 | ||
|
|
1057d25e5f | ||
|
|
2ae82cbeab | ||
|
|
d0f309931d | ||
|
|
cb7e9116f1 | ||
|
|
2dca20aa59 | ||
|
|
3b32311ab6 | ||
|
|
be03a74005 | ||
|
|
8c9b8bd252 | ||
|
|
a0a37a5a8a | ||
|
|
caebcf550d | ||
|
|
47bd8b0e50 | ||
|
|
0ba32e7bad | ||
|
|
6d6d11914d | ||
|
|
41dbbf155b | ||
|
|
8f8f30c37a | ||
|
|
f2b119c724 | ||
|
|
0c02bf57d7 | ||
|
|
d87fdbf974 | ||
|
|
d7d8ff8afd | ||
|
|
237b02b1f1 | ||
|
|
0ad587a7e1 | ||
|
|
7907ae846d | ||
|
|
ea7ea9630d | ||
|
|
a502eb6407 | ||
|
|
323c2e3bda | ||
|
|
934b19efc1 | ||
|
|
dd7d83837b | ||
|
|
88e882f72d | ||
|
|
88f0103527 | ||
|
|
b7c0d211eb | ||
|
|
4b74aa23f3 | ||
|
|
5d18f203ad | ||
|
|
a798eeb129 | ||
|
|
8164d910e7 | ||
|
|
50bbf5bf5d | ||
|
|
557dde83e1 | ||
|
|
a4a4ad0f14 | ||
|
|
f7e3ba4196 | ||
|
|
f541cfa97c | ||
|
|
f8584136b8 | ||
|
|
d819994a71 | ||
|
|
edbd37efec | ||
|
|
145d08e97e | ||
|
|
fc919a52ab | ||
|
|
d524eb5632 | ||
|
|
9edc685e31 | ||
|
|
71f18457d1 | ||
|
|
775fa1e5c6 | ||
|
|
824513f006 | ||
|
|
c42c02d2dc | ||
|
|
1729456121 | ||
|
|
4cece1c892 | ||
|
|
93753f099b | ||
|
|
d5718f9219 | ||
|
|
46ef402b82 | ||
|
|
895dc4517b | ||
|
|
32a4c5da5c | ||
|
|
c4b3adcf87 | ||
|
|
075461391b | ||
|
|
2c1346f95f | ||
|
|
e20fa1339d | ||
|
|
4dc8b5e051 | ||
|
|
dcdc07610a | ||
|
|
6a6a7a79a7 | ||
|
|
282ec1d325 | ||
|
|
a28caf1bc9 | ||
|
|
1fbfcc0803 | ||
|
|
5c5a217430 | ||
|
|
ccdbb6294a | ||
|
|
107b0e7579 | ||
|
|
4884f1f297 | ||
|
|
74ec19ae48 | ||
|
|
6ae7592f6c | ||
|
|
3089b61a7a | ||
|
|
18da58c346 | ||
|
|
4d852911a5 | ||
|
|
71113d8838 | ||
|
|
c4cddf8897 | ||
|
|
d7bb3195de | ||
|
|
569cac9d61 | ||
|
|
96844ac89c | ||
|
|
9642ea263d | ||
|
|
d34ddb841d | ||
|
|
65afcaf3a4 | ||
|
|
d784091ee4 | ||
|
|
81cd7583cd | ||
|
|
3f6c3321e9 | ||
|
|
be3db1616e | ||
|
|
9fd99bd5f5 | ||
|
|
8f1ea44466 | ||
|
|
6ec19a92b2 | ||
|
|
fa1801875e | ||
|
|
c6ef6ad979 | ||
|
|
74f11f5beb | ||
|
|
c0950537ab | ||
|
|
7cc079c61b | ||
|
|
7aa3224f4d | ||
|
|
1cc068c2ab | ||
|
|
28f22f1085 | ||
|
|
084f83efca | ||
|
|
26b76908eb | ||
|
|
ffc8a0a39a | ||
|
|
8ccd93dc97 | ||
|
|
1ca935b62b | ||
|
|
90a4909e57 | ||
|
|
ef8fb2dd0b | ||
|
|
20ad056400 | ||
|
|
ab792f5fad | ||
|
|
48c36fab8f | ||
|
|
3553fce597 | ||
|
|
c1d2790c8d | ||
|
|
18a9c66ce7 | ||
|
|
7ec60c7ea0 | ||
|
|
a96bc36a1e | ||
|
|
b575644d5f | ||
|
|
13bc71c3cd | ||
|
|
c3f3ee880d | ||
|
|
8ab4a55595 | ||
|
|
85de80a463 | ||
|
|
9f48d3565a | ||
|
|
db2e3e71db | ||
|
|
5d400ad854 | ||
|
|
02a07d4fa3 | ||
|
|
41ca55d615 | ||
|
|
8770a8b18e | ||
|
|
24ecd88cd0 | ||
|
|
7ed0f307be | ||
|
|
5b500c533e | ||
|
|
b1ef63ef43 | ||
|
|
f14e345b52 | ||
|
|
89a0d1281f | ||
|
|
ee5e7c0819 | ||
|
|
d875d88191 | ||
|
|
447db6990a | ||
|
|
4e9d20db0a | ||
|
|
53fc5eff3b | ||
|
|
69fa09e23a | ||
|
|
3360b7a849 | ||
|
|
80b46df8e5 | ||
|
|
88aa71c343 | ||
|
|
7b80e038cc | ||
|
|
ef280ec398 | ||
|
|
81f6de03e7 | ||
|
|
17c3a6b02a | ||
|
|
4903b3137b | ||
|
|
868406c95c | ||
|
|
7122b16a7a | ||
|
|
b9fbac4ddc | ||
|
|
33e948d1e7 | ||
|
|
9f551f151e | ||
|
|
9447cb37c0 | ||
|
|
8183b324a0 | ||
|
|
a6a25de19c | ||
|
|
51a2a1b391 | ||
|
|
d7ab81ddcf | ||
|
|
8da07d4e93 | ||
|
|
20b93f6853 | ||
|
|
2ce204c1bc | ||
|
|
d653b7fa3f | ||
|
|
0dcb42efa2 | ||
|
|
820d254553 | ||
|
|
715ef5711b | ||
|
|
ea3244367e | ||
|
|
f26768bcf7 | ||
|
|
87bb92fac0 | ||
|
|
01dc90bfa7 | ||
|
|
c37e6384a5 | ||
|
|
70fc955d37 | ||
|
|
a44f226e8a | ||
|
|
edafde5810 | ||
|
|
54cd037cd2 | ||
|
|
2ebf67d44d | ||
|
|
d2d294b93c | ||
|
|
39eb91ac48 | ||
|
|
53376d94d9 | ||
|
|
f51973bb7b | ||
|
|
33d1756fdd | ||
|
|
63931763c4 | ||
|
|
bdb956ec84 | ||
|
|
41b48d1104 | ||
|
|
57fef26726 | ||
|
|
ea94dc4548 | ||
|
|
13a7232bc5 |
No files matched your search
+1
-1
@@ -1 +1 @@
|
||||
github: Flaminel
|
||||
github: Cleanuparr
|
||||
@@ -21,7 +21,7 @@ runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Get vault secrets
|
||||
uses: hashicorp/vault-action@v2
|
||||
uses: hashicorp/vault-action@v4
|
||||
with:
|
||||
url: ${{ inputs.vault_host }}
|
||||
method: approle
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
changelog:
|
||||
exclude:
|
||||
labels:
|
||||
- duplicate
|
||||
- invalid
|
||||
- wontfix
|
||||
categories:
|
||||
- title: Breaking Changes
|
||||
labels:
|
||||
- breaking
|
||||
- title: Features
|
||||
labels:
|
||||
- enhancement
|
||||
- title: Bug Fixes
|
||||
labels:
|
||||
- bug
|
||||
- title: Documentation
|
||||
labels:
|
||||
- documentation
|
||||
- title: Maintenance
|
||||
labels:
|
||||
- chore
|
||||
- title: Other Changes
|
||||
labels:
|
||||
- "*"
|
||||
@@ -16,10 +16,20 @@ on:
|
||||
type: string
|
||||
required: false
|
||||
default: ''
|
||||
ref:
|
||||
description: 'Commit SHA to build. The image revision metadata uses this value. Defaults to the ref derived from the event.'
|
||||
type: string
|
||||
required: false
|
||||
default: ''
|
||||
pr_number:
|
||||
description: 'PR number to tag the image with (pr-N). Required when called outside a pull_request event.'
|
||||
type: string
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
# Cancel in-progress runs for the same PR
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
group: build-docker-${{ inputs.pr_number || github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
@@ -40,15 +50,25 @@ jobs:
|
||||
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 }}
|
||||
github_sha: ${{ inputs.ref || github.event.pull_request.head.sha || github.sha }}
|
||||
steps:
|
||||
- name: Initialize build info
|
||||
id: build-info
|
||||
timeout-minutes: 1
|
||||
env:
|
||||
GITHUB_HEAD_REF: ${{ github.head_ref }}
|
||||
GITHUB_REF: ${{ github.ref }}
|
||||
INPUT_VERSION: ${{ inputs.app_version }}
|
||||
INPUT_REF: ${{ inputs.ref }}
|
||||
INPUT_PR_NUMBER: ${{ inputs.pr_number }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: |
|
||||
githubHeadRef="${{ github.head_ref }}"
|
||||
githubRef="${{ github.ref }}"
|
||||
inputVersion="${{ inputs.app_version }}"
|
||||
githubHeadRef="$GITHUB_HEAD_REF"
|
||||
githubRef="$GITHUB_REF"
|
||||
inputVersion="$INPUT_VERSION"
|
||||
inputRef="$INPUT_REF"
|
||||
inputPrNumber="$INPUT_PR_NUMBER"
|
||||
latestDockerTag=""
|
||||
versionDockerTag=""
|
||||
majorVersionDockerTag=""
|
||||
@@ -80,14 +100,18 @@ jobs:
|
||||
minorVersionDockerTag="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}"
|
||||
fi
|
||||
else
|
||||
if [[ -z "$githubHeadRef" ]]; then
|
||||
if [[ -n "$inputPrNumber" ]]; then
|
||||
# Pull request built outside a pull_request event (no head_ref available)
|
||||
branch="$inputRef"
|
||||
versionDockerTag="pr-$inputPrNumber"
|
||||
elif [[ -z "$githubHeadRef" ]]; then
|
||||
# Main branch
|
||||
branch=${githubRef##*/}
|
||||
versionDockerTag="$branch"
|
||||
else
|
||||
# Pull request
|
||||
branch=$githubHeadRef
|
||||
versionDockerTag="$branch"
|
||||
branch="$PR_HEAD_SHA"
|
||||
versionDockerTag="pr-$PR_NUMBER"
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -133,7 +157,7 @@ jobs:
|
||||
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
|
||||
|
||||
- name: Get vault secrets
|
||||
uses: hashicorp/vault-action@v2
|
||||
uses: hashicorp/vault-action@v4
|
||||
with:
|
||||
url: ${{ secrets.VAULT_HOST }}
|
||||
method: approle
|
||||
@@ -144,23 +168,24 @@ jobs:
|
||||
secrets/data/github packages_pat | PACKAGES_PAT
|
||||
|
||||
- name: Checkout target repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
timeout-minutes: 1
|
||||
with:
|
||||
repository: ${{ github.repository }}
|
||||
ref: ${{ needs.prepare.outputs.branch }}
|
||||
token: ${{ env.REPO_READONLY_PAT }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
timeout-minutes: 5
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
if: needs.prepare.outputs.push == 'true'
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
@@ -170,14 +195,14 @@ jobs:
|
||||
if: needs.prepare.outputs.push == 'true'
|
||||
id: build-push
|
||||
timeout-minutes: 30
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
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 }}
|
||||
org.opencontainers.image.revision=${{ needs.prepare.outputs.github_sha }}
|
||||
org.opencontainers.image.version=${{ needs.prepare.outputs.version_docker_tag }}
|
||||
build-args: |
|
||||
VERSION=${{ needs.prepare.outputs.version }}
|
||||
PACKAGES_USERNAME=${{ secrets.PACKAGES_USERNAME }}
|
||||
@@ -190,14 +215,14 @@ jobs:
|
||||
- name: Build (no push)
|
||||
if: needs.prepare.outputs.push != 'true'
|
||||
timeout-minutes: 30
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
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 }}
|
||||
org.opencontainers.image.revision=${{ needs.prepare.outputs.github_sha }}
|
||||
org.opencontainers.image.version=${{ needs.prepare.outputs.version_docker_tag }}
|
||||
build-args: |
|
||||
VERSION=${{ needs.prepare.outputs.version }}
|
||||
PACKAGES_USERNAME=${{ secrets.PACKAGES_USERNAME }}
|
||||
@@ -216,7 +241,7 @@ jobs:
|
||||
|
||||
- name: Upload digest
|
||||
if: needs.prepare.outputs.push == 'true'
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: digests-${{ env.PLATFORM_PAIR }}
|
||||
path: ${{ runner.temp }}/digests/*
|
||||
@@ -230,17 +255,17 @@ jobs:
|
||||
if: needs.prepare.outputs.push == 'true'
|
||||
steps:
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
path: ${{ runner.temp }}/digests
|
||||
pattern: digests-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
@@ -261,6 +286,8 @@ jobs:
|
||||
done
|
||||
|
||||
docker buildx imagetools create $tag_args \
|
||||
--annotation "index:org.opencontainers.image.version=${{ needs.prepare.outputs.version_docker_tag }}" \
|
||||
--annotation "index:org.opencontainers.image.revision=${{ needs.prepare.outputs.github_sha }}" \
|
||||
$(printf '${{ env.REGISTRY_IMAGE }}@sha256:%s ' *)
|
||||
|
||||
- name: Inspect image
|
||||
|
||||
@@ -8,6 +8,24 @@ on:
|
||||
type: string
|
||||
required: false
|
||||
default: ''
|
||||
ref:
|
||||
description: 'Git ref to checkout (branch, tag, or SHA). Defaults to github.ref_name.'
|
||||
type: string
|
||||
required: false
|
||||
default: ''
|
||||
platforms:
|
||||
description: 'JSON array of runtime/platform pairs to build. Defaults to all platforms.'
|
||||
type: string
|
||||
required: false
|
||||
default: >-
|
||||
[{"runtime":"win-x64","platform":"win-amd64"},
|
||||
{"runtime":"linux-x64","platform":"linux-amd64"},
|
||||
{"runtime":"linux-arm64","platform":"linux-arm64"},
|
||||
{"runtime":"osx-x64","platform":"osx-amd64"},
|
||||
{"runtime":"osx-arm64","platform":"osx-arm64"}]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
# Build for each platform in parallel using matrix strategy
|
||||
@@ -16,17 +34,7 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: true
|
||||
matrix:
|
||||
include:
|
||||
- runtime: win-x64
|
||||
platform: win-amd64
|
||||
- runtime: linux-x64
|
||||
platform: linux-amd64
|
||||
- runtime: linux-arm64
|
||||
platform: linux-arm64
|
||||
- runtime: osx-x64
|
||||
platform: osx-amd64
|
||||
- runtime: osx-arm64
|
||||
platform: osx-arm64
|
||||
include: ${{ fromJSON(inputs.platforms) }}
|
||||
|
||||
steps:
|
||||
- name: Set variables
|
||||
@@ -55,7 +63,7 @@ jobs:
|
||||
echo "executableName=Cleanuparr.Api" >> $GITHUB_ENV
|
||||
|
||||
- name: Get vault secrets
|
||||
uses: hashicorp/vault-action@v2
|
||||
uses: hashicorp/vault-action@v4
|
||||
with:
|
||||
url: ${{ secrets.VAULT_HOST }}
|
||||
method: approle
|
||||
@@ -66,20 +74,21 @@ jobs:
|
||||
secrets/data/github packages_pat | PACKAGES_PAT
|
||||
|
||||
- name: Checkout target repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
timeout-minutes: 1
|
||||
with:
|
||||
repository: ${{ env.githubRepository }}
|
||||
ref: ${{ github.ref_name }}
|
||||
ref: ${{ inputs.ref || github.ref_name }}
|
||||
token: ${{ env.REPO_READONLY_PAT }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup dotnet
|
||||
uses: actions/setup-dotnet@v4
|
||||
uses: actions/setup-dotnet@v6
|
||||
with:
|
||||
dotnet-version: 10.0.x
|
||||
dotnet-version: 10.0.200
|
||||
|
||||
- name: Cache NuGet packages
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ~/.nuget/packages
|
||||
key: ${{ runner.os }}-nuget-${{ hashFiles('**/packages.lock.json', '**/*.csproj') }}
|
||||
@@ -87,7 +96,7 @@ jobs:
|
||||
${{ runner.os }}-nuget-
|
||||
|
||||
- name: Download frontend artifact
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: frontend-dist
|
||||
path: code/frontend/dist/ui/browser
|
||||
@@ -119,7 +128,7 @@ jobs:
|
||||
zip -r ./${{ env.githubRepositoryName }}-${{ env.appVersion }}-${{ matrix.platform }}.zip ./${{ env.githubRepositoryName }}-${{ env.appVersion }}-${{ matrix.platform }}/
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: executable-${{ matrix.platform }}
|
||||
path: ./artifacts/*.zip
|
||||
|
||||
@@ -2,13 +2,22 @@ name: Build Frontend
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
ref:
|
||||
description: 'Git ref to checkout (branch, tag, or SHA). Defaults to github.ref_name.'
|
||||
type: string
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build-frontend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Get vault secrets
|
||||
uses: hashicorp/vault-action@v2
|
||||
uses: hashicorp/vault-action@v4
|
||||
with:
|
||||
url: ${{ secrets.VAULT_HOST }}
|
||||
method: approle
|
||||
@@ -18,17 +27,18 @@ jobs:
|
||||
secrets/data/github repo_readonly_pat | REPO_READONLY_PAT
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
timeout-minutes: 1
|
||||
with:
|
||||
repository: ${{ github.repository }}
|
||||
ref: ${{ github.ref_name }}
|
||||
ref: ${{ inputs.ref || github.ref_name }}
|
||||
token: ${{ env.REPO_READONLY_PAT }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '24'
|
||||
node-version: '26'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: code/frontend/package-lock.json
|
||||
|
||||
@@ -39,7 +49,7 @@ jobs:
|
||||
npm run build
|
||||
|
||||
- name: Upload frontend artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: frontend-dist
|
||||
path: code/frontend/dist/ui/browser
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name: Build macOS Installers
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
contents: read
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
@@ -59,7 +59,7 @@ jobs:
|
||||
echo "executableName=Cleanuparr.Api" >> $GITHUB_ENV
|
||||
|
||||
- name: Get vault secrets
|
||||
uses: hashicorp/vault-action@v2
|
||||
uses: hashicorp/vault-action@v4
|
||||
with:
|
||||
url: ${{ secrets.VAULT_HOST }}
|
||||
method: approle
|
||||
@@ -70,23 +70,24 @@ jobs:
|
||||
secrets/data/github packages_pat | PACKAGES_PAT
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
repository: ${{ env.githubRepository }}
|
||||
ref: ${{ github.ref_name }}
|
||||
token: ${{ env.REPO_READONLY_PAT }}
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Download frontend artifact
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: frontend-dist
|
||||
path: code/frontend/dist/ui/browser
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
uses: actions/setup-dotnet@v6
|
||||
with:
|
||||
dotnet-version: 10.0.x
|
||||
dotnet-version: 10.0.200
|
||||
|
||||
- name: Restore .NET dependencies
|
||||
run: |
|
||||
@@ -369,7 +370,7 @@ jobs:
|
||||
echo "pkgName=${pkg_name}" >> $GITHUB_ENV
|
||||
|
||||
- name: Upload installer as artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: Cleanuparr-macos-${{ matrix.artifact_suffix }}-installer
|
||||
path: '${{ env.pkgName }}'
|
||||
|
||||
@@ -8,6 +8,14 @@ on:
|
||||
type: string
|
||||
required: false
|
||||
default: ''
|
||||
ref:
|
||||
description: 'Git ref to checkout (branch, tag, or SHA). Defaults to github.ref_name.'
|
||||
type: string
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build-windows-installer:
|
||||
@@ -44,7 +52,7 @@ jobs:
|
||||
echo "executableName=Cleanuparr.Api" >> $env:GITHUB_ENV
|
||||
|
||||
- name: Get vault secrets
|
||||
uses: hashicorp/vault-action@v2
|
||||
uses: hashicorp/vault-action@v4
|
||||
with:
|
||||
url: ${{ secrets.VAULT_HOST }}
|
||||
method: approle
|
||||
@@ -55,22 +63,23 @@ jobs:
|
||||
secrets/data/github packages_pat | PACKAGES_PAT
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
repository: ${{ env.githubRepository }}
|
||||
ref: ${{ github.ref_name }}
|
||||
ref: ${{ inputs.ref || github.ref_name }}
|
||||
token: ${{ env.REPO_READONLY_PAT }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Download frontend artifact
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: frontend-dist
|
||||
path: code/frontend/dist/ui/browser
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
uses: actions/setup-dotnet@v6
|
||||
with:
|
||||
dotnet-version: 10.0.x
|
||||
dotnet-version: 10.0.200
|
||||
|
||||
- name: Restore .NET dependencies
|
||||
run: |
|
||||
@@ -90,14 +99,7 @@ jobs:
|
||||
- name: Setup Inno Setup
|
||||
shell: pwsh
|
||||
run: |
|
||||
# Download and install Inno Setup
|
||||
$url = "https://jrsoftware.org/download.php/is.exe"
|
||||
$output = "innosetup-installer.exe"
|
||||
|
||||
Invoke-WebRequest -Uri $url -OutFile $output
|
||||
Start-Process -FilePath $output -ArgumentList "/VERYSILENT", "/SUPPRESSMSGBOXES", "/NORESTART" -Wait
|
||||
|
||||
# Add Inno Setup to PATH
|
||||
choco install innosetup --no-progress -y
|
||||
$innoPath = "C:\Program Files (x86)\Inno Setup 6"
|
||||
echo "$innoPath" >> $env:GITHUB_PATH
|
||||
|
||||
@@ -138,10 +140,10 @@ jobs:
|
||||
echo "installerName=$installerName" >> $env:GITHUB_ENV
|
||||
|
||||
- name: Upload installer artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: Cleanuparr-windows-installer
|
||||
path: installer/${{ env.installerName }}
|
||||
retention-days: 30
|
||||
|
||||
# Removed individual release step - handled by main release workflow
|
||||
# Removed individual release step - handled by main release workflow
|
||||
@@ -12,6 +12,9 @@ on:
|
||||
- 'whitelist_with_subtitles'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -19,7 +22,13 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Create directory for static files
|
||||
run: |
|
||||
mkdir -p Cloudflare/static
|
||||
|
||||
- name: Copy root static files to Cloudflare static directory
|
||||
run: |
|
||||
@@ -29,8 +38,9 @@ jobs:
|
||||
cp whitelist_with_subtitles Cloudflare/static/
|
||||
|
||||
- name: Deploy to Cloudflare Pages
|
||||
uses: cloudflare/wrangler-action@v3
|
||||
uses: cloudflare/wrangler-action@v4
|
||||
with:
|
||||
apiToken: ${{ secrets.CLOUDFLARE_PAGES_TOKEN }}
|
||||
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
workingDirectory: "Cloudflare"
|
||||
command: pages deploy . --project-name=cleanuparr
|
||||
command: pages deploy . --project-name=cleanuparr
|
||||
@@ -1,20 +1,32 @@
|
||||
name: Deploy to Cloudflare Pages
|
||||
name: Deploy Status Page to Cloudflare Pages
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
workflow_call:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Release version (e.g. v1.2.3)'
|
||||
type: string
|
||||
required: true
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Release version (e.g. v1.2.3)'
|
||||
type: string
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
name: Deploy to Cloudflare Pages
|
||||
name: Deploy Status Page to Cloudflare Pages
|
||||
|
||||
steps:
|
||||
- name: Create status files
|
||||
run: |
|
||||
mkdir -p status
|
||||
echo "{ \"version\": \"${GITHUB_REF_NAME}\" }" > status/status.json
|
||||
echo "{ \"version\": \"${{ inputs.version }}\" }" > status/status.json
|
||||
|
||||
# Cache static files for 10 minutes
|
||||
cat > status/_headers << 'EOF'
|
||||
@@ -23,8 +35,9 @@ jobs:
|
||||
EOF
|
||||
|
||||
- name: Deploy to Cloudflare Pages
|
||||
uses: cloudflare/wrangler-action@v3
|
||||
uses: cloudflare/wrangler-action@v4
|
||||
with:
|
||||
apiToken: ${{ secrets.CLOUDFLARE_PAGES_TOKEN }}
|
||||
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
workingDirectory: "status"
|
||||
command: pages deploy . --project-name=cleanuparr-status
|
||||
command: pages deploy . --project-name=cleanuparr-status
|
||||
@@ -19,10 +19,12 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Dependency Review
|
||||
uses: actions/dependency-review-action@v4
|
||||
uses: actions/dependency-review-action@v5
|
||||
with:
|
||||
# Fail on critical and high severity vulnerabilities
|
||||
fail-on-severity: high
|
||||
@@ -37,7 +39,7 @@ jobs:
|
||||
vulnerability-check: true
|
||||
|
||||
- name: Upload dependency review results
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: dependency-review-results
|
||||
path: dependency-review-*.json
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
name: Docs Preview Cleanup
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [closed]
|
||||
paths:
|
||||
- 'docs/**'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr_number:
|
||||
description: 'PR number whose preview deployments to delete'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
cleanup:
|
||||
runs-on: ubuntu-latest
|
||||
# Skip fork PRs
|
||||
if: github.event_name == 'workflow_dispatch' || github.event.pull_request.head.repo.full_name == github.repository
|
||||
env:
|
||||
CF_API_TOKEN: ${{ secrets.CLOUDFLARE_PAGES_TOKEN }}
|
||||
CF_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
PROJECT: cleanuparr-docs
|
||||
BRANCH: pull-${{ github.event.pull_request.number || inputs.pr_number }}
|
||||
steps:
|
||||
- name: Delete preview deployments for this PR
|
||||
run: |
|
||||
set -euo pipefail
|
||||
api="https://api.cloudflare.com/client/v4"
|
||||
auth="Authorization: Bearer $CF_API_TOKEN"
|
||||
|
||||
if [ -z "${CF_ACCOUNT_ID:-}" ]; then
|
||||
echo "CLOUDFLARE_ACCOUNT_ID repo variable is not set" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Account $CF_ACCOUNT_ID, tearing down branch $BRANCH"
|
||||
|
||||
ids=""
|
||||
page=1
|
||||
while :; do
|
||||
resp=$(curl -fsS -H "$auth" \
|
||||
"$api/accounts/$CF_ACCOUNT_ID/pages/projects/$PROJECT/deployments?page=$page&per_page=25")
|
||||
count=$(echo "$resp" | jq '.result | length')
|
||||
[ "$count" -eq 0 ] && break
|
||||
|
||||
page_ids=$(echo "$resp" | jq -r --arg b "$BRANCH" \
|
||||
'.result[] | select(.deployment_trigger.metadata.branch == $b) | .id')
|
||||
ids="$ids $page_ids"
|
||||
page=$((page + 1))
|
||||
done
|
||||
|
||||
deleted=0
|
||||
for id in $ids; do
|
||||
echo "Deleting deployment $id"
|
||||
curl -fsS -X DELETE -H "$auth" \
|
||||
"$api/accounts/$CF_ACCOUNT_ID/pages/projects/$PROJECT/deployments/$id?force=true" >/dev/null
|
||||
deleted=$((deleted + 1))
|
||||
done
|
||||
|
||||
echo "Deleted $deleted deployment(s) for $BRANCH"
|
||||
@@ -0,0 +1,85 @@
|
||||
name: Docs Preview
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'docs/**'
|
||||
|
||||
concurrency:
|
||||
group: docs-preview-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
preview:
|
||||
runs-on: ubuntu-latest
|
||||
# Skip fork PRs
|
||||
if: github.event.pull_request.head.repo.full_name == github.repository
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 26.x
|
||||
cache: yarn
|
||||
cache-dependency-path: docs/yarn.lock
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: docs
|
||||
run: yarn install --frozen-lockfile
|
||||
|
||||
- name: Build Docusaurus
|
||||
working-directory: docs
|
||||
run: yarn build
|
||||
|
||||
- name: Deploy preview to Cloudflare Pages
|
||||
uses: cloudflare/wrangler-action@v4
|
||||
with:
|
||||
apiToken: ${{ secrets.CLOUDFLARE_PAGES_TOKEN }}
|
||||
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
workingDirectory: docs
|
||||
command: pages deploy build --project-name=cleanuparr-docs --branch=pull-${{ github.event.pull_request.number }}
|
||||
|
||||
- name: Comment preview URL
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const url = `https://pull-${context.issue.number}.cleanuparr-docs.pages.dev`;
|
||||
const marker = '<!-- docs-preview -->';
|
||||
const body = [
|
||||
marker,
|
||||
`Docs preview for \`${context.payload.pull_request.head.ref}\` (\`${context.payload.pull_request.head.sha.substring(0, 7)}\`):`,
|
||||
``,
|
||||
`${url}/docs`
|
||||
].join('\n');
|
||||
|
||||
const comments = await github.paginate(github.rest.issues.listComments, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
per_page: 100
|
||||
});
|
||||
const existing = comments.find(c => c.body.includes(marker));
|
||||
|
||||
if (existing) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: existing.id,
|
||||
body
|
||||
});
|
||||
} else {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body
|
||||
});
|
||||
}
|
||||
+38
-15
@@ -1,9 +1,7 @@
|
||||
name: Deploy Docusaurus to GitHub Pages
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
workflow_call: {}
|
||||
workflow_dispatch: {}
|
||||
|
||||
permissions:
|
||||
@@ -12,22 +10,20 @@ permissions:
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: main
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24.x
|
||||
node-version: 26.x
|
||||
cache: yarn
|
||||
cache-dependency-path: docs/yarn.lock
|
||||
|
||||
@@ -39,12 +35,39 @@ jobs:
|
||||
working-directory: docs
|
||||
run: yarn build
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
- name: Publish to org site
|
||||
uses: peaceiris/actions-gh-pages@v4
|
||||
with:
|
||||
path: docs/build
|
||||
personal_token: ${{ secrets.DOCS_DEPLOY_TOKEN }}
|
||||
external_repository: Cleanuparr/Cleanuparr.github.io
|
||||
publish_branch: gh-pages
|
||||
publish_dir: docs/build
|
||||
|
||||
redirect:
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Build redirect stub
|
||||
run: |
|
||||
mkdir redirect
|
||||
cat > redirect/404.html <<'EOF'
|
||||
<!doctype html>
|
||||
<meta http-equiv="refresh" content="0;url=https://cleanuparr.github.io/docs">
|
||||
<script>
|
||||
var p = location.pathname.replace(/^\/Cleanuparr/, '');
|
||||
location.replace('https://cleanuparr.github.io' + p + location.search + location.hash);
|
||||
</script>
|
||||
EOF
|
||||
cp redirect/404.html redirect/index.html
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@v5
|
||||
with:
|
||||
path: redirect
|
||||
retention-days: 1
|
||||
|
||||
- name: Deploy to GitHub Pages
|
||||
- name: Deploy redirect to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
uses: actions/deploy-pages@v5
|
||||
@@ -0,0 +1,137 @@
|
||||
name: E2E Tests
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
ref:
|
||||
description: 'Git ref to checkout (branch, tag, or SHA). Defaults to the triggering ref.'
|
||||
type: string
|
||||
required: false
|
||||
default: ''
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: E2E Tests-${{ inputs.ref || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
e2e:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
suite:
|
||||
# These folders need only the app, Keycloak, nginx and the wiremocks.
|
||||
- name: core
|
||||
make-target: up-core
|
||||
projects: >-
|
||||
--project=account --project=arr --project=auth
|
||||
--project=blacklist-sync --project=core --project=download-client
|
||||
--project=general --project=notifications --project=oidc
|
||||
--project=queue-cleaner --project=regression --project=seeker
|
||||
--project=signalr
|
||||
# These folders need the torrent clients and the tracker.
|
||||
- name: clients
|
||||
make-target: up-clients
|
||||
projects: '--project=download-cleaner --project=malware-blocker'
|
||||
# This folder needs the real arrs, the fake indexer and qBittorrent.
|
||||
- name: live-arr
|
||||
make-target: up-arr
|
||||
projects: '--project=live-arr'
|
||||
# Same stack, but the app is built from patched sources.
|
||||
# See e2e/patches: the Seeker can be triggered instead of waited for.
|
||||
- name: live-arr-fast
|
||||
make-target: up-arr-fast
|
||||
projects: '--project=live-arr-fast'
|
||||
# This folder needs the real LazyLibrarian, the fake indexer and qBittorrent.
|
||||
- name: live-lazylibrarian
|
||||
make-target: up-lazylibrarian
|
||||
projects: '--project=live-lazylibrarian'
|
||||
|
||||
name: e2e (${{ matrix.suite.name }})
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
timeout-minutes: 1
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Get vault secrets
|
||||
uses: hashicorp/vault-action@v4
|
||||
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: make ${{ matrix.suite.make-target }}
|
||||
env:
|
||||
PACKAGES_USERNAME: ${{ github.repository_owner }}
|
||||
PACKAGES_PAT: ${{ env.PACKAGES_PAT }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 26
|
||||
cache: 'npm'
|
||||
cache-dependency-path: e2e/package-lock.json
|
||||
|
||||
- name: Install E2E dependencies
|
||||
working-directory: e2e
|
||||
run: npm ci
|
||||
|
||||
- name: Resolve Playwright version
|
||||
id: playwright
|
||||
working-directory: e2e
|
||||
run: echo "version=$(node -p "require('@playwright/test/package.json').version")" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Cache Playwright browsers
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: ${{ runner.os }}-playwright-${{ steps.playwright.outputs.version }}
|
||||
|
||||
- name: Report runner disk space
|
||||
run: df -h /
|
||||
|
||||
- name: Install Playwright browsers
|
||||
working-directory: e2e
|
||||
timeout-minutes: 10
|
||||
env:
|
||||
DEBUG: pw:install
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: Run E2E tests
|
||||
working-directory: e2e
|
||||
run: npx playwright test ${{ matrix.suite.projects }}
|
||||
|
||||
- name: Dump service logs
|
||||
if: failure()
|
||||
working-directory: e2e
|
||||
run: docker compose -f docker-compose.e2e.yml logs --no-color --tail 500
|
||||
|
||||
- name: Upload test results
|
||||
uses: actions/upload-artifact@v7
|
||||
if: always()
|
||||
with:
|
||||
name: e2e-test-results-${{ matrix.suite.name }}
|
||||
path: |
|
||||
e2e/playwright-report/
|
||||
e2e/test-results/
|
||||
retention-days: 7
|
||||
|
||||
- name: Stop services
|
||||
if: always()
|
||||
working-directory: e2e
|
||||
run: make down
|
||||
@@ -0,0 +1,74 @@
|
||||
name: PR Approve (Comment Triggered)
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
approve:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event.issue.pull_request != null
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- name: Approve PR
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const comment = context.payload.comment.body.trim();
|
||||
|
||||
const ALLOWED_USERS = ['flaminel'];
|
||||
|
||||
if (comment !== '/approve') {
|
||||
console.log(`Comment "${comment}" is not the approve command, skipping.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const login = context.payload.comment.user.login;
|
||||
if (!ALLOWED_USERS.includes(login.toLowerCase())) {
|
||||
console.log(`User ${login} is not allowed to approve, skipping.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const pr = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: context.issue.number
|
||||
});
|
||||
|
||||
if (pr.data.state !== 'open') {
|
||||
console.log('PR is not open, skipping.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (pr.data.draft) {
|
||||
console.log('PR is a draft, skipping.');
|
||||
return;
|
||||
}
|
||||
|
||||
await github.rest.pulls.createReview({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: pr.data.number,
|
||||
event: 'APPROVE',
|
||||
body: `Approved on behalf of @${login}.`
|
||||
});
|
||||
|
||||
console.log(`${login} approved PR #${pr.data.number} @ ${pr.data.head.sha}.`);
|
||||
|
||||
try {
|
||||
await github.rest.reactions.createForIssueComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: context.payload.comment.id,
|
||||
content: '+1'
|
||||
});
|
||||
} catch (e) {
|
||||
console.log(`Could not add reaction: ${e}`);
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
name: PR Build (Comment Triggered)
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event.issue.pull_request != null
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
statuses: write
|
||||
outputs:
|
||||
build: ${{ steps.parse.outputs.build }}
|
||||
meta: ${{ steps.parse.outputs.meta }}
|
||||
pr_ref: ${{ steps.parse.outputs.pr_ref }}
|
||||
pr_sha: ${{ steps.parse.outputs.pr_sha }}
|
||||
pr_number: ${{ steps.parse.outputs.pr_number }}
|
||||
|
||||
steps:
|
||||
- name: Parse command and check permissions
|
||||
id: parse
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const comment = context.payload.comment.body.trim();
|
||||
|
||||
const ALLOWED_USERS = ['flaminel'];
|
||||
|
||||
// Parse supported commands
|
||||
const commands = {
|
||||
'/build-windows': {
|
||||
target: 'windows',
|
||||
label: 'Windows installer',
|
||||
artifacts: '`Cleanuparr-windows-installer`'
|
||||
},
|
||||
'/build-linux': {
|
||||
target: 'linux',
|
||||
label: 'Linux executables',
|
||||
artifacts: '`executable-linux-amd64` and `executable-linux-arm64`'
|
||||
},
|
||||
'/test': {
|
||||
target: 'test',
|
||||
label: 'Unit tests',
|
||||
artifacts: '`test-results`, `coverage-report` and `frontend-coverage-report`'
|
||||
},
|
||||
'/e2e': {
|
||||
target: 'e2e',
|
||||
label: 'E2E tests',
|
||||
artifacts: '`e2e-test-results-core` and `e2e-test-results-clients`'
|
||||
},
|
||||
'/build-docker': {
|
||||
target: 'docker',
|
||||
label: 'Docker image',
|
||||
artifacts: ''
|
||||
}
|
||||
};
|
||||
|
||||
const command = commands[comment];
|
||||
if (!command) {
|
||||
console.log(`Comment "${comment}" is not a recognized build command, skipping.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const login = context.payload.comment.user.login;
|
||||
if (!ALLOWED_USERS.includes(login.toLowerCase())) {
|
||||
console.log(`User ${login} is not allowed to trigger builds, skipping.`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await github.rest.reactions.createForIssueComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: context.payload.comment.id,
|
||||
content: 'eyes'
|
||||
});
|
||||
} catch (e) {
|
||||
console.log(`Could not add reaction: ${e}`);
|
||||
}
|
||||
|
||||
// Fetch PR details
|
||||
const pr = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: context.issue.number
|
||||
});
|
||||
|
||||
// Verify PR is open
|
||||
if (pr.data.state !== 'open') {
|
||||
console.log('PR is not open, skipping.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`${login} triggered ${comment} on PR #${pr.data.number} @ ${pr.data.head.sha}.`);
|
||||
|
||||
try {
|
||||
await github.rest.repos.createCommitStatus({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
sha: pr.data.head.sha,
|
||||
state: 'pending',
|
||||
context: `pr-build / ${command.target}`,
|
||||
description: `${command.label} running`,
|
||||
target_url: `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`
|
||||
});
|
||||
} catch (e) {
|
||||
console.log(`Could not create commit status: ${e}`);
|
||||
}
|
||||
|
||||
core.setOutput('build', command.target);
|
||||
core.setOutput('meta', JSON.stringify({ label: command.label, artifacts: command.artifacts }));
|
||||
|
||||
// Export PR details for downstream jobs
|
||||
core.setOutput('pr_ref', pr.data.head.ref);
|
||||
core.setOutput('pr_sha', pr.data.head.sha);
|
||||
core.setOutput('pr_number', String(pr.data.number));
|
||||
|
||||
build-frontend:
|
||||
needs: validate
|
||||
if: needs.validate.outputs.build == 'windows' || needs.validate.outputs.build == 'linux'
|
||||
uses: ./.github/workflows/build-frontend.yml
|
||||
with:
|
||||
ref: ${{ needs.validate.outputs.pr_sha }}
|
||||
secrets: inherit
|
||||
|
||||
build-windows:
|
||||
needs: [validate, build-frontend]
|
||||
if: needs.validate.outputs.build == 'windows'
|
||||
concurrency:
|
||||
group: pr-build-windows-${{ needs.validate.outputs.pr_number }}
|
||||
cancel-in-progress: true
|
||||
uses: ./.github/workflows/build-windows-installer.yml
|
||||
with:
|
||||
ref: ${{ needs.validate.outputs.pr_sha }}
|
||||
secrets: inherit
|
||||
|
||||
build-linux:
|
||||
needs: [validate, build-frontend]
|
||||
if: needs.validate.outputs.build == 'linux'
|
||||
concurrency:
|
||||
group: pr-build-linux-${{ needs.validate.outputs.pr_number }}
|
||||
cancel-in-progress: true
|
||||
uses: ./.github/workflows/build-executable.yml
|
||||
with:
|
||||
ref: ${{ needs.validate.outputs.pr_sha }}
|
||||
platforms: '[{"runtime":"linux-x64","platform":"linux-amd64"},{"runtime":"linux-arm64","platform":"linux-arm64"}]'
|
||||
secrets: inherit
|
||||
|
||||
test:
|
||||
needs: validate
|
||||
if: needs.validate.outputs.build == 'test'
|
||||
concurrency:
|
||||
group: pr-test-${{ needs.validate.outputs.pr_number }}
|
||||
cancel-in-progress: true
|
||||
uses: ./.github/workflows/test.yml
|
||||
with:
|
||||
ref: ${{ needs.validate.outputs.pr_sha }}
|
||||
secrets: inherit
|
||||
|
||||
e2e:
|
||||
needs: validate
|
||||
if: needs.validate.outputs.build == 'e2e'
|
||||
concurrency:
|
||||
group: pr-e2e-${{ needs.validate.outputs.pr_number }}
|
||||
cancel-in-progress: true
|
||||
uses: ./.github/workflows/e2e.yml
|
||||
with:
|
||||
ref: ${{ needs.validate.outputs.pr_sha }}
|
||||
secrets: inherit
|
||||
|
||||
build-docker:
|
||||
needs: validate
|
||||
if: needs.validate.outputs.build == 'docker'
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
uses: ./.github/workflows/build-docker.yml
|
||||
with:
|
||||
ref: ${{ needs.validate.outputs.pr_sha }}
|
||||
pr_number: ${{ needs.validate.outputs.pr_number }}
|
||||
push_docker: true
|
||||
secrets: inherit
|
||||
|
||||
post-result:
|
||||
needs: [validate, build-frontend, build-windows, build-linux, test, e2e, build-docker]
|
||||
if: always() && needs.validate.outputs.build != ''
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
statuses: write
|
||||
|
||||
steps:
|
||||
- name: Post result comment
|
||||
uses: actions/github-script@v9
|
||||
env:
|
||||
PR_REF: ${{ needs.validate.outputs.pr_ref }}
|
||||
META: ${{ needs.validate.outputs.meta }}
|
||||
with:
|
||||
script: |
|
||||
const target = '${{ needs.validate.outputs.build }}';
|
||||
|
||||
// needs.* is only readable inside this job, so the result lookup has to live here.
|
||||
const results = {
|
||||
windows: '${{ needs.build-windows.result }}',
|
||||
linux: '${{ needs.build-linux.result }}',
|
||||
test: '${{ needs.test.result }}',
|
||||
e2e: '${{ needs.e2e.result }}',
|
||||
docker: '${{ needs.build-docker.result }}'
|
||||
};
|
||||
|
||||
const frontend = '${{ needs.build-frontend.result }}';
|
||||
|
||||
let result = results[target];
|
||||
if (result === 'skipped' && (frontend === 'failure' || frontend === 'cancelled')) {
|
||||
result = frontend;
|
||||
}
|
||||
|
||||
if (!result || result === 'skipped') {
|
||||
console.log(`Nothing to report for target "${target}" (${result}).`);
|
||||
return;
|
||||
}
|
||||
|
||||
const { label, artifacts } = JSON.parse(process.env.META);
|
||||
const runUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
|
||||
|
||||
const prSha = '${{ needs.validate.outputs.pr_sha }}';
|
||||
const statusContext = `pr-build / ${target}`;
|
||||
let publishStatus = result !== 'cancelled';
|
||||
|
||||
if (publishStatus) {
|
||||
try {
|
||||
const combined = await github.rest.repos.getCombinedStatusForRef({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
ref: prSha
|
||||
});
|
||||
const current = combined.data.statuses.find(s => s.context === statusContext);
|
||||
publishStatus = !current || current.state !== 'pending' || current.target_url === runUrl;
|
||||
} catch (e) {
|
||||
console.log(`Could not read the current commit status: ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (publishStatus) {
|
||||
try {
|
||||
await github.rest.repos.createCommitStatus({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
sha: prSha,
|
||||
state: result === 'success' ? 'success' : 'failure',
|
||||
context: statusContext,
|
||||
description: `${label} ${result}`,
|
||||
target_url: runUrl
|
||||
});
|
||||
} catch (e) {
|
||||
console.log(`Could not update commit status: ${e}`);
|
||||
}
|
||||
} else {
|
||||
console.log(`A newer run owns "${statusContext}", skipping the status update.`);
|
||||
}
|
||||
|
||||
const prRef = process.env.PR_REF;
|
||||
const prNumber = parseInt('${{ needs.validate.outputs.pr_number }}');
|
||||
const shortSha = prSha.substring(0, 7);
|
||||
|
||||
let body;
|
||||
if (result === 'success') {
|
||||
const how = target === 'docker'
|
||||
? [`**Pull:** \`docker pull ghcr.io/cleanuparr/cleanuparr:pr-${prNumber}\``]
|
||||
: [
|
||||
`**Download:** open the [workflow run](${runUrl}), scroll to the **Artifacts** section at the bottom.`,
|
||||
`The ${artifacts} artifact(s) are attached to that run.`
|
||||
];
|
||||
body = [`${label} **succeeded** for \`${prRef}\` (\`${shortSha}\`).`, ``, ...how].join('\n');
|
||||
} else {
|
||||
const verb = result === 'cancelled' ? 'was **cancelled**' : '**failed**';
|
||||
body = [
|
||||
`${label} ${verb} for \`${prRef}\` (\`${shortSha}\`).`,
|
||||
``,
|
||||
`See the [workflow run](${runUrl}) for details.`
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: prNumber,
|
||||
body
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
name: PR Label
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, reopened, synchronize, labeled, unlabeled]
|
||||
|
||||
concurrency:
|
||||
group: PR Label-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
category:
|
||||
name: Require category label
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Fail when no category label is present
|
||||
if: >-
|
||||
!contains(github.event.pull_request.labels.*.name, 'breaking') &&
|
||||
!contains(github.event.pull_request.labels.*.name, 'enhancement') &&
|
||||
!contains(github.event.pull_request.labels.*.name, 'bug') &&
|
||||
!contains(github.event.pull_request.labels.*.name, 'documentation') &&
|
||||
!contains(github.event.pull_request.labels.*.name, 'chore')
|
||||
run: |
|
||||
echo "::error::Add one of these labels so the release notes can be categorised: breaking, enhancement, bug, documentation, chore"
|
||||
exit 1
|
||||
@@ -35,10 +35,16 @@ on:
|
||||
required: false
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
# Validate release
|
||||
validate:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
outputs:
|
||||
app_version: ${{ steps.version.outputs.app_version }}
|
||||
release_version: ${{ steps.version.outputs.release_version }}
|
||||
@@ -46,7 +52,9 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Get version info
|
||||
id: version
|
||||
@@ -89,6 +97,59 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Check the version against PR labels
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
APP_VERSION: ${{ steps.version.outputs.app_version }}
|
||||
run: |
|
||||
previous_tag=$(gh release view --json tagName --jq .tagName 2>/dev/null || echo "")
|
||||
|
||||
if [ -z "$previous_tag" ]; then
|
||||
echo "No previous release found. Skipping the label check."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
published=$(gh release view "$previous_tag" --json publishedAt --jq .publishedAt)
|
||||
|
||||
if ! breaking_list=$(gh api --paginate "repos/${{ github.repository }}/issues?labels=breaking&state=closed&since=$published&per_page=100" \
|
||||
--jq ".[] | select(.pull_request.merged_at > \"$published\") | \" #\(.number) \(.title)\""); then
|
||||
echo "::error::Failed to query pull request labels, cannot verify the version"
|
||||
printf '%s\n' "$breaking_list"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
breaking=$(printf '%s' "$breaking_list" | grep -c . || true)
|
||||
|
||||
previous_version=${previous_tag#v}
|
||||
|
||||
if [ "$previous_version" = "$APP_VERSION" ]; then
|
||||
echo "::error::$APP_VERSION is the same version as the previous release $previous_tag."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$(printf '%s\n%s\n' "$previous_version" "$APP_VERSION" | sort -V | tail -1)" != "$APP_VERSION" ]; then
|
||||
echo "::error::$APP_VERSION is older than the previous release $previous_tag. Releases must move forward."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
previous_minor=${previous_version%.*}
|
||||
new_minor=${APP_VERSION%.*}
|
||||
|
||||
echo "Previous release: $previous_tag (published $published)"
|
||||
echo "Breaking PRs merged since: $breaking"
|
||||
|
||||
if [ "$breaking" -gt 0 ] && [ "$previous_minor" = "$new_minor" ]; then
|
||||
echo "::error::$breaking PR(s) labelled 'breaking' were merged since $previous_tag, so $APP_VERSION cannot be a patch release. Bump the minor, or remove the label if it was applied by mistake. See RELEASING.md."
|
||||
printf '%s\n' "$breaking_list"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$breaking" -eq 0 ] && [ "$previous_minor" != "$new_minor" ]; then
|
||||
echo "::warning::$APP_VERSION bumps the minor but no PR merged since $previous_tag carries the 'breaking' label. Intentional for a policy or milestone release, otherwise check the labels."
|
||||
fi
|
||||
|
||||
echo "✅ Version $APP_VERSION is consistent with the PR labels."
|
||||
|
||||
# Run tests
|
||||
test:
|
||||
needs: validate
|
||||
@@ -152,6 +213,9 @@ jobs:
|
||||
# Build and push Docker image(s)
|
||||
build-docker:
|
||||
needs: [validate, test]
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
if: |
|
||||
always() &&
|
||||
needs.validate.result == 'success' &&
|
||||
@@ -180,7 +244,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Get vault secrets
|
||||
uses: hashicorp/vault-action@v2
|
||||
uses: hashicorp/vault-action@v4
|
||||
with:
|
||||
url: ${{ secrets.VAULT_HOST }}
|
||||
method: approle
|
||||
@@ -190,20 +254,20 @@ jobs:
|
||||
secrets/data/github repo_readonly_pat | REPO_READONLY_PAT
|
||||
|
||||
- name: Download executable artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
pattern: executable-*
|
||||
path: ./artifacts
|
||||
merge-multiple: true
|
||||
|
||||
- name: Download Windows installer
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: Cleanuparr-windows-installer
|
||||
path: ./artifacts
|
||||
|
||||
- name: Download macOS installers
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
pattern: Cleanuparr-macos-*-installer
|
||||
path: ./artifacts
|
||||
@@ -217,7 +281,7 @@ jobs:
|
||||
echo "Total files: $(find ./artifacts -type f \( -name "*.zip" -o -name "*.pkg" -o -name "*.exe" \) | wc -l)"
|
||||
|
||||
- name: Create release
|
||||
uses: softprops/action-gh-release@v2
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
name: ${{ needs.validate.outputs.release_version }}
|
||||
tag_name: ${{ needs.validate.outputs.release_version }}
|
||||
@@ -230,10 +294,32 @@ jobs:
|
||||
./artifacts/*.pkg
|
||||
./artifacts/*.exe
|
||||
|
||||
# Deploy docs after a successful release
|
||||
deploy-docs:
|
||||
needs: [create-release]
|
||||
if: always() && needs.create-release.result == 'success'
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
uses: ./.github/workflows/docs.yml
|
||||
secrets: inherit
|
||||
|
||||
# Deploy Cloudflare status page after a successful release
|
||||
deploy-status:
|
||||
needs: [validate, create-release]
|
||||
if: always() && needs.create-release.result == 'success'
|
||||
uses: ./.github/workflows/cloudflare-pages-status.yml
|
||||
with:
|
||||
version: ${{ needs.validate.outputs.release_version }}
|
||||
secrets: inherit
|
||||
|
||||
# Summary job
|
||||
summary:
|
||||
needs: [validate, test, build-frontend, build-executables, build-windows-installer, build-macos, build-docker]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
if: always()
|
||||
|
||||
steps:
|
||||
|
||||
+75
-10
@@ -6,35 +6,50 @@ on:
|
||||
- main
|
||||
paths:
|
||||
- 'code/backend/**'
|
||||
- 'code/frontend/**'
|
||||
- '.github/workflows/test.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'code/backend/**'
|
||||
- 'code/frontend/**'
|
||||
- '.github/workflows/test.yml'
|
||||
workflow_call:
|
||||
inputs:
|
||||
ref:
|
||||
description: 'Git ref to checkout (branch, tag, or SHA). Defaults to the triggering ref.'
|
||||
type: string
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
# Cancel in-progress runs for the same PR
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
group: Tests-${{ inputs.ref || github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test:
|
||||
backend:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
timeout-minutes: 1
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
uses: actions/setup-dotnet@v6
|
||||
with:
|
||||
dotnet-version: 10.0.x
|
||||
dotnet-version: 10.0.200
|
||||
|
||||
- name: Cache NuGet packages
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ~/.nuget/packages
|
||||
key: ${{ runner.os }}-nuget-${{ hashFiles('**/packages.lock.json', '**/*.csproj') }}
|
||||
@@ -42,7 +57,7 @@ jobs:
|
||||
${{ runner.os }}-nuget-
|
||||
|
||||
- name: Get vault secrets
|
||||
uses: hashicorp/vault-action@v2
|
||||
uses: hashicorp/vault-action@v4
|
||||
with:
|
||||
url: ${{ secrets.VAULT_HOST }}
|
||||
method: approle
|
||||
@@ -64,21 +79,22 @@ jobs:
|
||||
run: dotnet test code/backend/cleanuparr.sln --configuration Release --no-build --verbosity normal --logger trx --collect:"XPlat Code Coverage" --settings code/backend/coverage.runsettings --results-directory ./coverage
|
||||
|
||||
- name: Upload test results
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: test-results
|
||||
path: ./coverage/*.trx
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload coverage reports
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: coverage-report
|
||||
path: ./coverage/**/coverage.cobertura.xml
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v5
|
||||
if: inputs.ref == ''
|
||||
uses: codecov/codecov-action@v7
|
||||
with:
|
||||
files: ./coverage/**/coverage.cobertura.xml
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
@@ -97,3 +113,52 @@ jobs:
|
||||
fi
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Test artifacts have been uploaded for detailed analysis." >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
frontend:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
||||
defaults:
|
||||
run:
|
||||
working-directory: code/frontend
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
timeout-minutes: 1
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '26'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: code/frontend/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
|
||||
- name: Run tests
|
||||
run: npm run test:ci
|
||||
|
||||
- name: Upload coverage reports
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: frontend-coverage-report
|
||||
path: code/frontend/coverage/ui/lcov.info
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
if: inputs.ref == ''
|
||||
uses: codecov/codecov-action@v7
|
||||
with:
|
||||
files: code/frontend/coverage/ui/lcov.info
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
fail_ci_if_error: false
|
||||
flags: frontend
|
||||
name: frontend-coverage
|
||||
@@ -22,6 +22,9 @@ on:
|
||||
description: 'Repository name without owner'
|
||||
value: ${{ jobs.version.outputs.repository_name }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
version:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -1,83 +1,135 @@
|
||||
# Cleanuparr - Claude AI Rules
|
||||
|
||||
## 🚨 Critical Guidelines
|
||||
## Rules
|
||||
|
||||
**READ THIS FIRST:**
|
||||
1. ⚠️ **DO NOT break existing functionality** - All features are critical and must continue to work
|
||||
2. ❓ **When in doubt, ASK** - Always clarify before implementing uncertain changes
|
||||
3. 📋 **Follow existing patterns** - Study the codebase style before making changes
|
||||
4. 🆕 **Ask before introducing new patterns** - Use current coding standards or get approval first
|
||||
1. **DO NOT break existing functionality** - All features are critical and must continue to work
|
||||
2. **When in doubt, ASK** - Don't assume, clarify with the maintainer first
|
||||
3. **Always read existing code before making changes** - Understand the current architecture and patterns
|
||||
4. **Follow existing patterns** - Study the codebase style and match it exactly
|
||||
5. **Ask before introducing new patterns** - Use current coding standards or get approval first
|
||||
6. **Prefer editing existing files over creating new ones** - Build on existing work
|
||||
7. **Flag potential gotchas or issues immediately** - Document and report anything unexpected
|
||||
8. **If unsure about an approach, ask before implementing**
|
||||
|
||||
## 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 (qBittorrent, Transmission, Deluge, uTorrent, rTorrent). It provides malware protection, automated cleanup, and queue management for *arr applications.
|
||||
|
||||
**Key Features:**
|
||||
- Strike system for bad downloads
|
||||
- Malware detection and blocking
|
||||
- Automatic search triggering after removal
|
||||
- Automatic search triggering after removal (Seeker)
|
||||
- Missing and upgrade search
|
||||
- Orphaned download cleanup with cross-seed support
|
||||
- Support for multiple notification providers (Discord, etc.)
|
||||
- Authentication (OIDC, 2FA)
|
||||
- Notification providers (Apprise, Discord, Gotify, Notifiarr, Ntfy, Pushover, Telegram)
|
||||
|
||||
## Architecture & Tech Stack
|
||||
|
||||
### Backend
|
||||
- **.NET 10.0** (C#) with ASP.NET Core
|
||||
- **Architecture**: Clean Architecture pattern
|
||||
- `Cleanuparr.Domain` - Domain models and business logic
|
||||
- **Architecture**: Clean Architecture with `Features/` subdirectory pattern
|
||||
- `Cleanuparr.Api` - REST API and web host (`Features/` for endpoint groups)
|
||||
- `Cleanuparr.Application` - Application services and use cases
|
||||
- `Cleanuparr.Infrastructure` - External integrations (*arr apps, download clients)
|
||||
- `Cleanuparr.Domain` - Domain models (Entities, Enums, Exceptions)
|
||||
- `Cleanuparr.Infrastructure` - External integrations (`Features/` for Arr, DownloadClient, Notifications, etc.)
|
||||
- `Cleanuparr.Persistence` - Data access with EF Core (SQLite)
|
||||
- `Cleanuparr.Api` - REST API and web host
|
||||
- `Cleanuparr.Shared` - Shared utilities
|
||||
- **Database**: SQLite with Entity Framework Core 10.0
|
||||
- Two separate contexts: `DataContext` and `EventsContext`
|
||||
- **Database**: SQLite with Entity Framework Core
|
||||
- Three separate contexts: `DataContext`, `EventsContext`, `UsersContext`
|
||||
- **Key Libraries**:
|
||||
- MassTransit (messaging)
|
||||
- Quartz.NET (scheduling)
|
||||
- Serilog (logging)
|
||||
- SignalR (real-time communication)
|
||||
- **Testing**: xUnit + NSubstitute + Shouldly
|
||||
- Always use **NSubstitute** for mocking in new tests (Moq is being phased out)
|
||||
|
||||
### Frontend
|
||||
- **Angular 21** with TypeScript 5.9 (standalone components, zoneless, OnPush)
|
||||
- **UI**: Custom glassmorphism design system (no external UI frameworks)
|
||||
- **Angular 22** with TypeScript 6.0, Node 26 (standalone components, zoneless, OnPush)
|
||||
- **UI**: Custom glassmorphism design system with 33 custom components — no external UI frameworks
|
||||
- **Icons**: @ng-icons/core + @ng-icons/tabler-icons
|
||||
- **Design System**: 3-layer SCSS (`_variables` → `_tokens` → `_themes`), dark/light themes
|
||||
- **State Management**: @ngrx/signals (Angular signals-based)
|
||||
- **Real-time Updates**: SignalR (@microsoft/signalr)
|
||||
- **Design System**: 3-layer SCSS (`_variables` -> `_tokens` -> `_themes`), dark/light themes
|
||||
- **State Management**: Angular signals (`signal`/`computed`/`effect`) — `@ngrx/signals` was removed (it was unused)
|
||||
- **Data fetching**: Angular 22 Resource API — `rxResource` from `@angular/core/rxjs-interop` (not manual `HttpClient.subscribe()`)
|
||||
- **Forms**: Angular 22 Signal Forms — `form()` + `[formField]` from `@angular/forms/signals` (settings forms; a few not-yet-migrated forms still use per-field signals)
|
||||
- **Real-time Updates**: @microsoft/signalr 10.0.0
|
||||
- **PWA**: Service Worker support enabled
|
||||
|
||||
### Documentation
|
||||
- **Docusaurus** (TypeScript-based static site)
|
||||
- Hosted at https://cleanuparr.github.io/Cleanuparr/
|
||||
## Project Structure
|
||||
|
||||
### Deployment
|
||||
- **Docker** (primary distribution method)
|
||||
- Standalone executables for Windows, macOS, and Linux
|
||||
- Platform installers for Windows (.exe) and macOS (.pkg)
|
||||
|
||||
## Development Setup
|
||||
|
||||
### Prerequisites
|
||||
- .NET 10.0 SDK
|
||||
- Node.js 18+
|
||||
- Git
|
||||
- (Optional) Make for database migrations
|
||||
- (Optional) JetBrains Rider or Visual Studio
|
||||
|
||||
### GitHub Packages Authentication
|
||||
Cleanuparr uses GitHub Packages for NuGet dependencies. Configure access:
|
||||
|
||||
```bash
|
||||
dotnet nuget add source \
|
||||
--username YOUR_GITHUB_USERNAME \
|
||||
--password YOUR_GITHUB_PAT \
|
||||
--store-password-in-clear-text \
|
||||
--name Cleanuparr \
|
||||
https://nuget.pkg.github.com/Cleanuparr/index.json
|
||||
```
|
||||
Cleanuparr/
|
||||
├── code/
|
||||
│ ├── backend/
|
||||
│ │ ├── Cleanuparr.Api/ # REST API (Features/ for endpoint groups)
|
||||
│ │ ├── Cleanuparr.Api.Tests/ # API layer tests
|
||||
│ │ ├── Cleanuparr.Application/ # Business logic layer
|
||||
│ │ ├── Cleanuparr.Domain/ # Domain models
|
||||
│ │ ├── Cleanuparr.Infrastructure/ # External integrations (Features/ subdirs)
|
||||
│ │ ├── Cleanuparr.Infrastructure.Tests/
|
||||
│ │ ├── Cleanuparr.Persistence/ # SQLite data access
|
||||
│ │ ├── Cleanuparr.Persistence.Tests/
|
||||
│ │ └── Cleanuparr.Shared/ # Shared utilities
|
||||
│ ├── frontend/ # Angular 22 application
|
||||
│ ├── Dockerfile # Multi-stage Docker build
|
||||
│ ├── entrypoint.sh # Docker entrypoint
|
||||
│ └── Makefile # Build & migration helpers
|
||||
├── docs/ # Docusaurus documentation
|
||||
├── e2e/ # Playwright E2E tests
|
||||
├── .github/workflows/ # CI/CD pipelines
|
||||
├── blacklist # Default malware patterns (strict)
|
||||
├── blacklist_permissive # Less strict malware patterns
|
||||
├── whitelist # Safe file extensions
|
||||
└── whitelist_with_subtitles # Includes subtitle formats
|
||||
```
|
||||
|
||||
You need a GitHub PAT with `read:packages` permission.
|
||||
## Code Standards & Conventions
|
||||
|
||||
**IMPORTANT:** Always study existing code in the relevant area before making changes. Match the existing style exactly.
|
||||
|
||||
### Backend (C#)
|
||||
- Follow Microsoft C# Coding Conventions
|
||||
- Use nullable reference types (`<Nullable>enable</Nullable>`)
|
||||
- Add XML documentation comments for public APIs
|
||||
- Use meaningful names - avoid abbreviations unless widely understood
|
||||
- Keep services focused - single responsibility principle
|
||||
- New integrations go under `Features/` subdirectories (e.g., `Infrastructure/Features/Arr/`)
|
||||
- **One type per file** - every class, record, struct and enum lives in its own file, named after it
|
||||
- **Split as you go** - when a change touches a file holding several types, split that file as part of the change
|
||||
- Exception: a test double used by a single spec may stay nested in that spec; doubles shared across specs go in `TestHelpers/`
|
||||
|
||||
### Frontend (TypeScript/Angular)
|
||||
- All components must be **standalone** with **ChangeDetectionStrategy.OnPush**
|
||||
- Use `input()` / `output()` function APIs (not `@Input()` / `@Output()` decorators)
|
||||
- Use Angular **signals** for reactive state (`signal()`, `computed()`, `effect()`)
|
||||
- **Data fetching**: use the **Resource API** (`rxResource`) with a reactive `params` + `stream`, not manual `HttpClient.subscribe()`; drive spinners/errors off `isLoading()`/`error()`
|
||||
- **Forms**: use **Signal Forms** (`form()` + `[formField]`) with a single model signal + schema validators; keep the JSON-snapshot dirty tracking (`buildSnapshot()`/`hasPendingChanges()`), do NOT use Signal Forms `dirty()` for the unsaved-changes guard
|
||||
- Follow the 3-layer SCSS design system (`_variables` -> `_tokens` -> `_themes`)
|
||||
- **Do not introduce external UI frameworks** (no PrimeNG, Material, Tailwind, etc.)
|
||||
- Component naming: `{feature}.component.ts`
|
||||
- Service naming: `{feature}.service.ts`
|
||||
- **Look at similar existing components before creating new ones**
|
||||
|
||||
### Testing
|
||||
- **Backend**: xUnit + NSubstitute + Shouldly
|
||||
- Always use **NSubstitute** for mocking (Moq is being phased out)
|
||||
- Write unit tests for new features and bug fixes
|
||||
- Use descriptive test names that explain what is being tested
|
||||
- **Frontend**: Vitest via the `@angular/build:unit-test` builder in jsdom (`cd code/frontend && npm test`). Specs live next to the source as `{feature}.component.spec.ts`
|
||||
- Vitest globals are enabled in `tsconfig.spec.json`, so do NOT import `describe`/`it`/`expect`/`vi`
|
||||
- `angular.json` sets `skipTests: true` for all schematics, so `ng generate` never creates a spec. Write them by hand
|
||||
- Test components through `TestBed.createComponent` and the rendered DOM. For inputs/outputs, declare a standalone host component in the spec. In a **component** spec, stub the API class with a plain object of methods returning `of(...)`, never `HttpClient`
|
||||
- A spec for an **api class itself** (`{feature}.api.spec.ts`) is the one exception: override the `HttpClient` token with `vi.fn()` stubs and assert the URL and body, as `events.api.spec.ts` and `account.api.spec.ts` do. `provideHttpClientTesting` is banned everywhere
|
||||
- Keep stub observables synchronous: an `rxResource` fed by `of(...)` resolves inside one `fixture.detectChanges()`, an async source needs `await fixture.whenStable()`
|
||||
- Call `fixture.detectChanges()` after every interaction (zoneless + OnPush). For a bare `effect()`, use `TestBed.runInInjectionContext()` then `TestBed.tick()`
|
||||
|
||||
### Git Commit Messages
|
||||
- Use clear, descriptive messages in imperative mood
|
||||
- Examples: "Add Discord notification support", "Fix memory leak in download client polling"
|
||||
- Reference issue numbers when applicable: "Fix #123: Handle null response from Radarr API"
|
||||
|
||||
## Development Setup
|
||||
|
||||
### Running the Backend
|
||||
```bash
|
||||
@@ -97,254 +149,68 @@ UI runs at http://localhost:4200
|
||||
|
||||
### Running Tests
|
||||
```bash
|
||||
# Backend
|
||||
cd code/backend
|
||||
dotnet test
|
||||
|
||||
# Frontend
|
||||
cd code/frontend
|
||||
npm test # ng test; watches in an interactive terminal, single run when not a TTY
|
||||
npm run test:ci # single run with coverage, written to coverage/ui/ (what CI runs)
|
||||
npm run lint
|
||||
```
|
||||
|
||||
### Running Documentation
|
||||
```bash
|
||||
cd docs
|
||||
npm install
|
||||
npm start
|
||||
```
|
||||
Docs run at http://localhost:3000
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
Cleanuparr/
|
||||
├── code/
|
||||
│ ├── backend/
|
||||
│ │ ├── Cleanuparr.Api/ # API entry point
|
||||
│ │ ├── Cleanuparr.Application/ # Business logic layer
|
||||
│ │ ├── Cleanuparr.Domain/ # Domain models
|
||||
│ │ ├── Cleanuparr.Infrastructure/ # External integrations
|
||||
│ │ ├── Cleanuparr.Persistence/ # Database & EF Core
|
||||
│ │ ├── Cleanuparr.Shared/ # Shared utilities
|
||||
│ │ └── *.Tests/ # Unit tests
|
||||
│ ├── frontend/ # Angular 21 application
|
||||
│ ├── ui/ # Built frontend assets
|
||||
│ ├── Dockerfile # Multi-stage Docker build
|
||||
│ ├── entrypoint.sh # Docker entrypoint
|
||||
│ └── Makefile # Build & migration helpers
|
||||
├── docs/ # Docusaurus documentation
|
||||
├── Logo/ # Branding assets
|
||||
├── .github/workflows/ # CI/CD pipelines
|
||||
├── blacklist # Default malware patterns
|
||||
├── blacklist_permissive # Alternative blacklist
|
||||
├── whitelist # Safe file patterns
|
||||
└── CONTRIBUTING.md # Contribution guidelines
|
||||
```
|
||||
|
||||
## Code Standards & Conventions
|
||||
|
||||
**IMPORTANT:** Always study existing code in the relevant area before making changes. Match the existing style exactly.
|
||||
|
||||
### Backend (C#)
|
||||
- Follow [Microsoft C# Coding Conventions](https://docs.microsoft.com/dotnet/csharp/fundamentals/coding-style/coding-conventions)
|
||||
- Use nullable reference types (`<Nullable>enable</Nullable>`)
|
||||
- Add XML documentation comments for public APIs
|
||||
- Write unit tests for business logic
|
||||
- Use meaningful names - avoid abbreviations unless widely understood
|
||||
- Keep services focused - single responsibility principle
|
||||
- **Study existing service implementations before creating new ones**
|
||||
|
||||
### Frontend (TypeScript/Angular)
|
||||
- Follow [Angular Style Guide](https://angular.io/guide/styleguide)
|
||||
- Use TypeScript strict mode
|
||||
- All components must be **standalone** (no NgModules) with **ChangeDetectionStrategy.OnPush**
|
||||
- Use `input()` / `output()` function APIs (not `@Input()` / `@Output()` decorators)
|
||||
- Use Angular **signals** for reactive state (`signal()`, `computed()`, `effect()`)
|
||||
- Follow the 3-layer SCSS design system (`_variables` → `_tokens` → `_themes`) for styling
|
||||
- Component naming: `{feature}.component.ts`
|
||||
- Service naming: `{feature}.service.ts`
|
||||
- **Look at similar existing components before creating new ones**
|
||||
|
||||
### Testing
|
||||
- Write unit tests for new features and bug fixes
|
||||
- Use descriptive test names that explain what is being tested
|
||||
- Backend: xUnit or NUnit conventions
|
||||
- Frontend: Jasmine/Karma
|
||||
- **Test that existing functionality still works after changes**
|
||||
|
||||
### Git Commit Messages
|
||||
- Use clear, descriptive messages in imperative mood
|
||||
- Examples: "Add Discord notification support", "Fix memory leak in download client polling"
|
||||
- Reference issue numbers when applicable: "Fix #123: Handle null response from Radarr API"
|
||||
|
||||
### Discovering Issues
|
||||
If you encounter potential gotchas, common mistakes, or areas that need special attention during development:
|
||||
- **Flag them to the maintainer immediately**
|
||||
- Document them if confirmed
|
||||
- Consider if they should be added to this guide
|
||||
Both run as concurrent `backend` and `frontend` jobs in `.github/workflows/test.yml`.
|
||||
|
||||
## Database Migrations
|
||||
|
||||
Cleanuparr uses two separate database contexts:
|
||||
- **DataContext**: Main application data
|
||||
- **EventsContext**: Event logging and audit trail
|
||||
|
||||
### Creating Migrations
|
||||
From the `code` directory:
|
||||
Three separate database contexts, all commands run from the `code` directory:
|
||||
|
||||
```bash
|
||||
# Data migrations
|
||||
# Data migrations (DataContext)
|
||||
make migrate-data name=YourMigrationName
|
||||
|
||||
# Events migrations
|
||||
# Events migrations (EventsContext)
|
||||
make migrate-events name=YourMigrationName
|
||||
```
|
||||
|
||||
Example:
|
||||
```bash
|
||||
make migrate-data name=AddDownloadClientConfig
|
||||
make migrate-events name=AddStrikeEvents
|
||||
# Users migrations (UsersContext)
|
||||
make migrate-users name=YourMigrationName
|
||||
```
|
||||
|
||||
## Common Development Workflows
|
||||
|
||||
### Adding a New *arr Application Integration
|
||||
1. Add integration in `Cleanuparr.Infrastructure/Arr/`
|
||||
1. Add integration in `Cleanuparr.Infrastructure/Features/Arr/`
|
||||
2. Update domain models in `Cleanuparr.Domain/`
|
||||
3. Create/update services in `Cleanuparr.Application/`
|
||||
4. Add API endpoints in `Cleanuparr.Api/`
|
||||
4. Add API endpoints in `Cleanuparr.Api/Features/Arr/`
|
||||
5. Update frontend in `code/frontend/src/app/`
|
||||
6. Document in `docs/docs/`
|
||||
|
||||
### Adding a New Download Client
|
||||
1. Add client implementation in `Cleanuparr.Infrastructure/DownloadClients/`
|
||||
1. Add client implementation in `Cleanuparr.Infrastructure/Features/DownloadClient/`
|
||||
2. Follow existing patterns (qBittorrent, Transmission, etc.)
|
||||
3. Add configuration models to `Cleanuparr.Domain/`
|
||||
4. Update API and frontend as above
|
||||
|
||||
### Adding a New Notification Provider
|
||||
1. Add provider in `Cleanuparr.Infrastructure/Notifications/`
|
||||
1. Add provider in `Cleanuparr.Infrastructure/Features/Notifications/`
|
||||
2. Update configuration models
|
||||
3. Add UI configuration in frontend
|
||||
4. Test with actual service
|
||||
|
||||
## Important Files
|
||||
|
||||
### Configuration Files
|
||||
- `code/backend/Cleanuparr.Api/appsettings.json` - Backend configuration
|
||||
- `code/frontend/angular.json` - Angular build configuration
|
||||
- `code/Dockerfile` - Docker multi-stage build
|
||||
- `docs/docusaurus.config.ts` - Documentation site config
|
||||
|
||||
### CI/CD Workflows
|
||||
- `.github/workflows/test.yml` - Run tests
|
||||
- `.github/workflows/build-docker.yml` - Build Docker images
|
||||
- `.github/workflows/build-executable.yml` - Build standalone executables
|
||||
- `.github/workflows/release.yml` - Create releases
|
||||
- `.github/workflows/docs.yml` - Deploy documentation
|
||||
|
||||
### Malware Protection
|
||||
- `blacklist` - Default malware file patterns (strict)
|
||||
- `blacklist_permissive` - Less strict patterns
|
||||
- `whitelist` - Known safe file extensions
|
||||
- `whitelist_with_subtitles` - Includes subtitle formats
|
||||
|
||||
## Contributing Guidelines
|
||||
|
||||
### Before Starting Work
|
||||
1. **Announce your intent** - Comment on an issue or create a new one
|
||||
2. **Wait for approval** from maintainers
|
||||
3. Fork the repository and create a feature branch
|
||||
4. Make your changes following code standards
|
||||
5. Test thoroughly (both manual and automated tests)
|
||||
6. Submit a PR with clear description and testing notes
|
||||
|
||||
### Pull Request Requirements
|
||||
- Link to related issue
|
||||
- Clear description of changes
|
||||
- Evidence of testing
|
||||
- Updated documentation if needed
|
||||
- No breaking changes without discussion
|
||||
|
||||
## Docker Development
|
||||
|
||||
### Build Local Docker Image
|
||||
```bash
|
||||
cd code
|
||||
docker build \
|
||||
--build-arg PACKAGES_USERNAME=YOUR_GITHUB_USERNAME \
|
||||
--build-arg PACKAGES_PAT=YOUR_GITHUB_PAT \
|
||||
-t cleanuparr:local \
|
||||
-f Dockerfile .
|
||||
```
|
||||
|
||||
### Multi-Architecture Build
|
||||
```bash
|
||||
docker buildx build \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--build-arg PACKAGES_USERNAME=YOUR_GITHUB_USERNAME \
|
||||
--build-arg PACKAGES_PAT=YOUR_GITHUB_PAT \
|
||||
-t cleanuparr:local \
|
||||
-f Dockerfile .
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
When running via Docker:
|
||||
- `PORT` - API port (default: 11011)
|
||||
- `PUID` - User ID for file permissions
|
||||
- `PGID` - Group ID for file permissions
|
||||
- `TZ` - Timezone (e.g., `America/New_York`)
|
||||
|
||||
## Security & Safety
|
||||
|
||||
- Never commit sensitive data (API keys, tokens, passwords)
|
||||
- All *arr and download client credentials are stored encrypted
|
||||
- The malware detection system uses pattern matching on file extensions and names
|
||||
- Always validate user input on both frontend and backend
|
||||
- Follow OWASP guidelines for web application security
|
||||
|
||||
## Additional Resources
|
||||
|
||||
- **Documentation**: https://cleanuparr.github.io/Cleanuparr/
|
||||
- **Discord**: https://discord.gg/SCtMCgtsc4
|
||||
- **GitHub Issues**: https://github.com/Cleanuparr/Cleanuparr/issues
|
||||
- **Releases**: https://github.com/Cleanuparr/Cleanuparr/releases
|
||||
|
||||
## Working with Claude - IMPORTANT
|
||||
|
||||
### Core Principles
|
||||
1. **When in doubt, ASK** - Don't assume, clarify with the maintainer first
|
||||
2. **Don't break existing functionality** - Everything is important and needs to work
|
||||
3. **Follow existing coding style** - Study the codebase patterns before making changes
|
||||
4. **Use current coding standards** - If you want to introduce something new, ask first
|
||||
|
||||
### When Modifying Code
|
||||
- **ALWAYS read existing files before suggesting changes**
|
||||
- Understand the current architecture and patterns
|
||||
- Prefer editing existing files over creating new ones
|
||||
- Follow the established conventions in the codebase exactly
|
||||
- Test changes locally when possible
|
||||
- **If you're unsure about an approach, ask before implementing**
|
||||
|
||||
### When Adding Features
|
||||
- Review similar existing features first to understand patterns
|
||||
- Maintain consistency with existing UI/UX patterns
|
||||
- Update both backend and frontend together
|
||||
- Add/update documentation
|
||||
- Consider backwards compatibility
|
||||
- **Ask about architectural decisions before implementing new patterns**
|
||||
|
||||
### When Fixing Bugs
|
||||
- Understand the root cause before proposing a fix
|
||||
- **Be careful not to break other functionality** - test related areas
|
||||
- Add tests to prevent regression
|
||||
- Update relevant documentation if behavior changes
|
||||
- Consider if other parts of the codebase might have similar issues
|
||||
- **Flag any potential gotchas or issues you discover**
|
||||
|
||||
## Notes
|
||||
## Key Gotchas
|
||||
|
||||
- **Custom glassmorphism design system** - Do not introduce external UI frameworks (no PrimeNG, Material, Tailwind)
|
||||
- **All frontend components** must be standalone with OnPush change detection
|
||||
- **Frontend tests are zoneless**: `fakeAsync`/`tick()`/`flush()` from `@angular/core/testing` require Zone.js and will throw. Use `vi.useFakeTimers()` + `vi.advanceTimersByTime()`, restore with `vi.useRealTimers()`
|
||||
- **`@angular/build`'s unit-test runner sets `isolate: false`** (Vitest's own default is `isolate: true`, so its docs will tell you the opposite): module state, `localStorage`, fake timers and `document.documentElement` attributes/inline styles leak between spec files. Undo them in `afterEach`
|
||||
- **Node >= 25 shadows jsdom's `localStorage`**: vitest skips copying globals that already exist on `globalThis`, so specs would get an inert object (or `undefined` on Node 26). `src/testing/test-setup.ts` restores jsdom's Storage and is wired via `setupFiles` in `angular.json`. `matchMedia`, `IntersectionObserver`, `ResizeObserver` and `navigator.clipboard` are still absent in jsdom, stub them per spec
|
||||
- **Frontend toolchain is pinned high**: Node >= 26 and npm 11.6.2. CI uses `setup-node@v7` with `node-version: '26'`
|
||||
- **Database migrations** require awareness of all three contexts (Data, Events, Users)
|
||||
- **Malware blocker** is a critical security feature - changes require careful testing
|
||||
- **Cross-seed integration** allows keeping torrents that are actively seeding
|
||||
- **Real-time updates** use SignalR - maintain websocket patterns when adding features
|
||||
- Use `@ng-icons/core` + `@ng-icons/tabler-icons` for icons (NOT `angular-tabler-icons` which doesn't support Angular 22)
|
||||
- **Sidebar** stays dark purple in both themes - uses sidebar-specific CSS variables
|
||||
- The project uses **Clean Architecture** - respect layer boundaries
|
||||
- Database migrations require both contexts - don't forget EventsContext
|
||||
- Frontend uses a **custom glassmorphism design system** - don't introduce external UI frameworks (no PrimeNG, Material, etc.)
|
||||
- All frontend components are **standalone** with **OnPush** change detection
|
||||
- All downloads from *arr apps are processed through a **strike system**
|
||||
- The malware blocker is a critical security feature - changes require careful testing
|
||||
- Cross-seed integration allows keeping torrents that are actively seeding
|
||||
- Real-time updates use **SignalR** - maintain websocket patterns when adding features
|
||||
- **Settings dirty tracking** uses JSON snapshot comparison (`buildSnapshot()` + `hasPendingChanges()`) — keep this even with Signal Forms; Signal Forms `dirty()` means "touched", not "differs from saved"
|
||||
- **Resource API** (`rxResource`): `value()` throws in the error state — always set a `defaultValue` (lists) or guard with `hasValue()` before reading
|
||||
- **Signal Forms** (`[formField]`) owns `min`/`max`/`disabled`/`required` — set these via schema validators, not template bindings. Custom controls satisfy the contract via `model()` signals (`chip-input` exposes a `value` model; `size-input`'s numeric-min input is named `minValue` to avoid clashing with the field min)
|
||||
+54
-2
@@ -4,6 +4,13 @@ Thanks for your interest in contributing to Cleanuparr! This guide will help you
|
||||
|
||||
## Before You Start
|
||||
|
||||
### AI usage
|
||||
|
||||
In this ever-evolving field of work, AI is now the shiny new tool to help programmers work faster and there's nothing wrong with that.
|
||||
But it is **very** wrong to rely solely on AI tools to write, review and test your code.
|
||||
|
||||
**If you do not have a background in programming and you do not intend to test your code properly, please do not submit AI-generated code.** If you still want to help in other ways such as testing features, that would also help a lot!
|
||||
|
||||
### Announce Your Intent
|
||||
|
||||
Before starting any work, please let us know what you want to contribute:
|
||||
@@ -20,7 +27,7 @@ This helps us avoid redundant work, git conflicts, and contributions that may no
|
||||
### Prerequisites
|
||||
|
||||
- [.NET 10.0 SDK](https://dotnet.microsoft.com/download/dotnet/10.0)
|
||||
- [Node.js 18+](https://nodejs.org/)
|
||||
- [Node.js 26+](https://nodejs.org/)
|
||||
- [Git](https://git-scm.com/)
|
||||
- (Optional) [Make](https://www.gnu.org/software/make/) for database migrations
|
||||
- (Optional) IDE: [JetBrains Rider](https://www.jetbrains.com/rider/) or [Visual Studio](https://visualstudio.microsoft.com/)
|
||||
@@ -153,6 +160,51 @@ make migrate-events name=AddAuditLogEvents
|
||||
|
||||
The UI will be available at http://localhost:4200
|
||||
|
||||
### Testing
|
||||
|
||||
Unit tests run on [Vitest](https://vitest.dev/) through the Angular `@angular/build:unit-test` builder, in a jsdom environment. No browser download is required.
|
||||
|
||||
```bash
|
||||
cd code/frontend
|
||||
|
||||
# Watch mode, re-runs on save
|
||||
npm test
|
||||
|
||||
# Single run with a coverage report, written to coverage/ui/. This is what CI runs
|
||||
npm run test:ci
|
||||
|
||||
# Single run without coverage
|
||||
npm test -- --watch=false
|
||||
|
||||
# Run a single spec
|
||||
npm test -- --include src/app/ui/chip-input/chip-input.component.spec.ts
|
||||
|
||||
# Lint, also a CI gate
|
||||
npm run lint
|
||||
```
|
||||
|
||||
`npm test` is Angular's `ng test`, which watches by default in an interactive terminal and runs once when stdout is not a TTY. CI therefore needs no extra flag, but do not rely on that locally: in your terminal `npm test` stays open until you quit it.
|
||||
|
||||
#### Writing specs
|
||||
|
||||
Specs live next to the code they cover, named `{feature}.component.spec.ts`. The Angular schematics in `angular.json` are configured with `skipTests: true`, so `ng generate` will not create a spec for you. Write it by hand.
|
||||
|
||||
House style, follow the existing specs as reference:
|
||||
|
||||
- Vitest globals (`describe`, `it`, `expect`, `vi`) are enabled via `tsconfig.spec.json`. Do not import them.
|
||||
- Use `TestBed.createComponent` and drive the rendered DOM. Assert on output, not on private internals.
|
||||
- For a component with inputs and outputs, declare a small standalone host component in the spec file and test through it. See `src/app/ui/chip-input/chip-input.component.spec.ts`.
|
||||
- Stub API classes with a plain object of methods returning `of(...)` from RxJS. Do not mock `HttpClient` and do not use `provideHttpClientTesting`. See `src/app/features/settings/seeker/seeker.component.spec.ts`.
|
||||
- Keep stub observables synchronous. An `rxResource` backed by `of(...)` resolves within a single `fixture.detectChanges()`, while an async source would need `await fixture.whenStable()`.
|
||||
- To flush a bare `effect()` outside a component, call it inside `TestBed.runInInjectionContext(...)` and flush with `TestBed.tick()`. See `src/app/core/services/overlay-stack.service.spec.ts`.
|
||||
- Put shared setup in a local `function setup()` inside the `describe` rather than in `beforeEach`, so each test reads top to bottom.
|
||||
- Components are zoneless and `OnPush`, so call `fixture.detectChanges()` after every interaction that should update the view.
|
||||
|
||||
Two consequences of the zoneless, non-isolated test environment are worth knowing before you debug something strange:
|
||||
|
||||
- `fakeAsync`, `tick()` and `flush()` from `@angular/core/testing` require Zone.js and **cannot** be used. Use `vi.useFakeTimers()` and `vi.advanceTimersByTime()` instead, and restore with `vi.useRealTimers()`.
|
||||
- The Angular `@angular/build` unit-test runner sets Vitest's `isolate` option to `false` to match the old Karma behaviour. Vitest's own default is `isolate: true`, so its documentation will tell you the opposite. In practice module-level state, `localStorage`, fake timers and anything written to `document.documentElement` leak into other spec files. Always undo them in `afterEach`.
|
||||
|
||||
## Documentation Development
|
||||
|
||||
### Setup
|
||||
@@ -234,7 +286,7 @@ docker buildx build \
|
||||
### Frontend (Angular/TypeScript)
|
||||
- Follow existing conventions and the [Angular Style Guide](https://angular.io/guide/styleguide)
|
||||
- Use TypeScript strict mode
|
||||
- Write unit tests whenever possible
|
||||
- Write unit tests whenever possible, see [Frontend Development > Testing](#testing)
|
||||
|
||||
### Documentation
|
||||
- Use clear, concise language
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
thepirateheaven.org
|
||||
RARBG.work
|
||||
+12
-9
File diff suppressed because one or more lines are too long.
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 69 KiB |
@@ -1,7 +1,11 @@
|
||||
<div align="center">
|
||||
|
||||
_Love this project? Give it a ⭐️ and let others know!_
|
||||
|
||||
# <img width="24px" src="./Logo/256.png" alt="Cleanuparr"></img> Cleanuparr
|
||||
|
||||
_/kliː.nʌp.ər/ — like "cleanuper", someone who does the cleanup. Not "CleanupArr" or "CleanUpArr"._
|
||||
|
||||

|
||||

|
||||
[](https://github.com/Cleanuparr/Cleanuparr/actions/workflows/test.yml)
|
||||
@@ -9,7 +13,9 @@ _Love this project? Give it a ⭐️ and let others know!_
|
||||
|
||||
[](https://discord.gg/SCtMCgtsc4)
|
||||
|
||||
Cleanuparr is a tool for automating the cleanup of unwanted or blocked files in Sonarr, Radarr, and supported download clients like qBittorrent. It removes incomplete or blocked downloads, updates queues, and enforces blacklists or whitelists to manage file selection. After removing blocked content, Cleanuparr can also trigger a search to replace the deleted shows/movies.
|
||||
</div>
|
||||
|
||||
Cleanuparr is an advanced download manager for the Servarr ecosystem. It works with Sonarr, Radarr, Lidarr, Readarr, and Whisparr alongside download clients like qBittorrent, Transmission, and Deluge. Beyond cleaning up stalled, blocked, and malicious downloads, it searches for missing content and quality upgrades, manages seeding, and removes orphaned files.
|
||||
|
||||
Cleanuparr was created primarily to address malicious files, such as `*.lnk` or `*.zipx`, that were getting stuck in Sonarr/Radarr and required manual intervention. Some of the reddit posts that made Cleanuparr come to life can be found [here](https://www.reddit.com/r/sonarr/comments/1gqnx16/psa_sonarr_downloaded_a_virus/), [here](https://www.reddit.com/r/sonarr/comments/1gqwklr/sonar_downloaded_a_mkv_file_which_looked_like_a/), [here](https://www.reddit.com/r/sonarr/comments/1gpw2wa/downloaded_waiting_to_import/) and [here](https://www.reddit.com/r/sonarr/comments/1gpi344/downloads_not_importing_no_files_found/).
|
||||
|
||||
@@ -23,8 +29,12 @@ Cleanuparr was created primarily to address malicious files, such as `*.lnk` or
|
||||
> - Remove and block downloads blocked by qBittorrent or by Cleanuparr's **Malware Blocker**.
|
||||
> - Remove and block known malware based on patterns found by the community.
|
||||
> - Automatically trigger a search for downloads removed from the arrs.
|
||||
> - Proactively search for **missing** items across your Radarr and Sonarr libraries.
|
||||
> - Search for **quality upgrades** for items that haven't met their quality profile's cutoff (a.k.a. **Cutoff Unmet**).
|
||||
> - Search for **custom format score upgrades** with automatic score tracking.
|
||||
> - Clean up downloads that have been **seeding** for a certain amount of time.
|
||||
> - Remove downloads that are **orphaned**/have no **hardlinks**/are not referenced by the arrs anymore (with [cross-seed](https://www.cross-seed.org/) support).
|
||||
> - Scan configured directories for **files not claimed by any active torrent**, move them to a dedicated orphaned directory, and optionally auto-purge.
|
||||
> - Notify on strike or download removal.
|
||||
> - Ignore certain torrent hashes, categories, tags or trackers from being processed by Cleanuparr.
|
||||
|
||||
@@ -43,12 +53,16 @@ https://cleanuparr.github.io/Cleanuparr/docs/screenshots
|
||||
- **Lidarr**
|
||||
- **Readarr**
|
||||
- **Whisparr v2**
|
||||
- **Whisparr v3**
|
||||
- **Sportarr**
|
||||
- **LazyLibrarian**
|
||||
|
||||
### Download Clients (latest version)
|
||||
- **qBittorrent**
|
||||
- **Transmission**
|
||||
- **Deluge**
|
||||
- **µTorrent**
|
||||
- **rTorrent**
|
||||
|
||||
### Platforms
|
||||
- **Docker**
|
||||
@@ -73,6 +87,8 @@ docker run -d --name cleanuparr \
|
||||
|
||||
For Docker Compose, health checks, and other installation methods, see the [Complete Installation Guide](https://cleanuparr.github.io/Cleanuparr/docs/installation/detailed), but not before reading the [Prerequisites](https://cleanuparr.github.io/Cleanuparr/docs/installation/).
|
||||
|
||||
> Prefer not to self-host? A managed Cleanuparr instance is available via [ElfHosted](https://store.elfhosted.com/product-category/personal-stacks/?utm_source=github&utm_medium=readme&utm_campaign=cleanuparr-readme), bundled alongside Sonarr/Radarr to keep your queues tidy (7-day trial).
|
||||
|
||||
### 🌐 Access the Web Interface
|
||||
|
||||
After installation, open your browser and navigate to:
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
# Releasing Cleanuparr
|
||||
|
||||
Versioning rules and the release process are detailed in the user-facing version of this policy at [docs/docs/installation/versioning.mdx](docs/docs/installation/versioning.mdx) and published under [Versioning](https://cleanuparr.github.io/docs/installation/versioning).
|
||||
|
||||
## Versioning
|
||||
|
||||
This policy takes effect starting with **v2.10.0**. Previous releases did not follow this approach, and their version numbers have no compatibility meaning.
|
||||
|
||||
| Part | Bump when |
|
||||
| --- | --- |
|
||||
| Major | Probably never, unless something significant changes. |
|
||||
| Minor | The release contains a breaking change. |
|
||||
| Patch | Anything else: bug fixes and new features. |
|
||||
|
||||
## Deciding the number
|
||||
|
||||
Every pull request includes a category label, which is enforced by [pr-label.yml](.github/workflows/pr-label.yml). The `breaking` label determines the version number.
|
||||
|
||||
From the `code` directory, use this command to list every breaking pull request that was merged since the last release and indicate which number to bump:
|
||||
|
||||
```bash
|
||||
make release-check
|
||||
```
|
||||
|
||||
To view the full release notes for a version before tagging it, without creating anything:
|
||||
|
||||
```bash
|
||||
make release-notes version=2.11.0
|
||||
```
|
||||
|
||||
A `## Breaking Changes` section means the release is a minor version. If this section is absent, the release is a patch.
|
||||
|
||||
[release.yml](.github/workflows/release.yml) performs the same check and stops the release process before anything is built if breaking pull requests were merged but the minor version did not increment. It issues a warning but continues if the minor version moves without a breaking pull request leading it.
|
||||
|
||||
Examples of breaking changes:
|
||||
|
||||
- A migration that drops or rewrites data, preventing rollback to the previous version
|
||||
- Renaming or removing an environment variable
|
||||
- Dropping a platform, architecture, installer, or database provider
|
||||
- A default value that alters how an existing configuration interacts with user data
|
||||
- A setting whose unit or interpretation changes and cannot be altered by a migration
|
||||
- Dropping a download client or *arr application, or increasing the minimum supported version
|
||||
- A change to the public REST API (e.g., the `stats` endpoint)
|
||||
|
||||
Examples of non-breaking changes:
|
||||
|
||||
- A migration that simply adds columns or tables
|
||||
- Renaming any value stored in the database and configured through the UI, accompanied by a migration
|
||||
- Any change to the private REST API, which is intended only for the UI
|
||||
- Modifications to the Web UI layout, internal structure, or logging
|
||||
|
||||
## Auditing migrations before a release
|
||||
|
||||
The easiest breaking change to overlook is a migration that prevents rollback. To see the migrations added since the previous tag:
|
||||
|
||||
```bash
|
||||
git diff --name-only <last-tag>..main -- '*/Migrations/**' \
|
||||
| grep -v -e Designer -e Snapshot \
|
||||
| xargs grep -l 'DropColumn\|DropTable\|AlterColumn\|RenameColumn\|DeleteData\|migrationBuilder\.Sql'
|
||||
```
|
||||
|
||||
Only `Up()` matters, since every migration drops things in `Down()` by definition, leading to false positives in that grep.
|
||||
|
||||
Additive changes are safe. `AddColumn` and `CreateTable` allow previous versions to start. However, a conversion or rewrite does not.
|
||||
|
||||
## Release process
|
||||
|
||||
Tag pushes that match `v*.*.*` trigger [release.yml](.github/workflows/release.yml).
|
||||
|
||||
```bash
|
||||
git tag v2.10.0
|
||||
git push origin v2.10.0
|
||||
```
|
||||
|
||||
### Release notes
|
||||
|
||||
GitHub generates notes from the pull requests merged since the last tag, grouped into sections by label according to [release.yml](.github/release.yml):
|
||||
|
||||
| Label | Section |
|
||||
| --- | --- |
|
||||
| `breaking` | Breaking Changes |
|
||||
| `enhancement` | Features |
|
||||
| `bug` | Bug Fixes |
|
||||
| `documentation` | Documentation |
|
||||
| `chore` | Maintenance |
|
||||
|
||||
The status check for `Require category label` is mandatory on `main`, so a missing label will be caught before merging. Labels added after merging still apply because notes are generated at release time.
|
||||
+6
-1
@@ -1,5 +1,5 @@
|
||||
# Build Angular frontend
|
||||
FROM --platform=$BUILDPLATFORM node:25-alpine AS frontend-build
|
||||
FROM --platform=$BUILDPLATFORM node:26-alpine AS frontend-build
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files first for better layer caching
|
||||
@@ -68,6 +68,11 @@ ENV PUID=1000 \
|
||||
# Fix FileSystemWatcher in Docker: https://github.com/dotnet/dotnet-docker/issues/3546
|
||||
ENV DOTNET_USE_POLLING_FILE_WATCHER=true
|
||||
|
||||
# GC tuning: prefer lower memory footprint over throughput
|
||||
ENV DOTNET_GCDynamicAdaptationMode=1 \
|
||||
DOTNET_GCConserveMemory=5 \
|
||||
DOTNET_GCRetainVM=0
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy backend
|
||||
|
||||
+53
-3
@@ -7,19 +7,69 @@ migrate-data:
|
||||
ifndef name
|
||||
$(error name is required. Usage: make migrate-data name=YourMigrationName)
|
||||
endif
|
||||
dotnet ef migrations add $(name) --context DataContext --project backend/Cleanuparr.Persistence/Cleanuparr.Persistence.csproj --startup-project backend/Cleanuparr.Api/Cleanuparr.Api.csproj --output-dir Migrations/Data
|
||||
dotnet ef migrations add $(name) --context DataContext --project backend/Cleanuparr.Persistence.Sqlite/Cleanuparr.Persistence.Sqlite.csproj --startup-project backend/Cleanuparr.Api/Cleanuparr.Api.csproj --output-dir Migrations/Data
|
||||
|
||||
migrate-events:
|
||||
ifndef name
|
||||
$(error name is required. Usage: make migrate-events name=YourMigrationName)
|
||||
endif
|
||||
dotnet ef migrations add $(name) --context EventsContext --project backend/Cleanuparr.Persistence/Cleanuparr.Persistence.csproj --startup-project backend/Cleanuparr.Api/Cleanuparr.Api.csproj --output-dir Migrations/Events
|
||||
dotnet ef migrations add $(name) --context EventsContext --project backend/Cleanuparr.Persistence.Sqlite/Cleanuparr.Persistence.Sqlite.csproj --startup-project backend/Cleanuparr.Api/Cleanuparr.Api.csproj --output-dir Migrations/Events
|
||||
|
||||
migrate-users:
|
||||
ifndef name
|
||||
$(error name is required. Usage: make migrate-users name=YourMigrationName)
|
||||
endif
|
||||
dotnet ef migrations add $(name) --context UsersContext --project backend/Cleanuparr.Persistence/Cleanuparr.Persistence.csproj --startup-project backend/Cleanuparr.Api/Cleanuparr.Api.csproj --output-dir Migrations/Users
|
||||
dotnet ef migrations add $(name) --context UsersContext --project backend/Cleanuparr.Persistence.Sqlite/Cleanuparr.Persistence.Sqlite.csproj --startup-project backend/Cleanuparr.Api/Cleanuparr.Api.csproj --output-dir Migrations/Users
|
||||
|
||||
PG_DUMMY = DATABASE_PROVIDER=postgres POSTGRES_HOST=localhost POSTGRES_PORT=5432 POSTGRES_USER=postgres POSTGRES_PASS=postgres POSTGRES_DB=cleanuparr
|
||||
|
||||
migrate-postgres-data:
|
||||
ifndef name
|
||||
$(error name is required. Usage: make migrate-postgres-data name=YourMigrationName)
|
||||
endif
|
||||
$(PG_DUMMY) dotnet ef migrations add $(name) --context DataContext --project backend/Cleanuparr.Persistence.Postgres/Cleanuparr.Persistence.Postgres.csproj --startup-project backend/Cleanuparr.Api/Cleanuparr.Api.csproj --output-dir Migrations/Data
|
||||
|
||||
migrate-postgres-events:
|
||||
ifndef name
|
||||
$(error name is required. Usage: make migrate-postgres-events name=YourMigrationName)
|
||||
endif
|
||||
$(PG_DUMMY) dotnet ef migrations add $(name) --context EventsContext --project backend/Cleanuparr.Persistence.Postgres/Cleanuparr.Persistence.Postgres.csproj --startup-project backend/Cleanuparr.Api/Cleanuparr.Api.csproj --output-dir Migrations/Events
|
||||
|
||||
migrate-postgres-users:
|
||||
ifndef name
|
||||
$(error name is required. Usage: make migrate-postgres-users name=YourMigrationName)
|
||||
endif
|
||||
$(PG_DUMMY) dotnet ef migrations add $(name) --context UsersContext --project backend/Cleanuparr.Persistence.Postgres/Cleanuparr.Persistence.Postgres.csproj --startup-project backend/Cleanuparr.Api/Cleanuparr.Api.csproj --output-dir Migrations/Users
|
||||
|
||||
release-check:
|
||||
@previous=$$(gh release view --json tagName --jq .tagName) || exit 1; \
|
||||
published=$$(gh release view --json publishedAt --jq .publishedAt); \
|
||||
echo "Previous release: $$previous ($$published)"; \
|
||||
echo ""; \
|
||||
if ! breaking=$$(gh api --paginate "repos/{owner}/{repo}/issues?labels=breaking&state=closed&since=$$published&per_page=100" \
|
||||
--jq ".[] | select(.pull_request.merged_at > \"$$published\") | \" #\(.number) \(.title)\""); then \
|
||||
echo "Failed to query pull request labels:" >&2; \
|
||||
echo "$$breaking" >&2; \
|
||||
exit 1; \
|
||||
fi; \
|
||||
if [ -n "$$breaking" ]; then \
|
||||
echo "Breaking changes merged since $$previous:"; \
|
||||
echo "$$breaking"; \
|
||||
echo ""; \
|
||||
echo "=> the next release must bump the MINOR"; \
|
||||
else \
|
||||
echo "No breaking changes merged since $$previous"; \
|
||||
echo ""; \
|
||||
echo "=> the next release can be a PATCH"; \
|
||||
fi
|
||||
|
||||
release-notes:
|
||||
ifndef version
|
||||
$(error version is required. Usage: make release-notes version=2.11.0)
|
||||
endif
|
||||
@previous=$$(gh release view --json tagName --jq .tagName); \
|
||||
gh api -X POST repos/{owner}/{repo}/releases/generate-notes \
|
||||
-f tag_name=v$(version) -f target_commitish=main -f previous_tag_name=$$previous --jq .body
|
||||
|
||||
docker-build:
|
||||
ifndef tag
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="NSubstitute" Version="5.3.0" />
|
||||
<PackageReference Include="Shouldly" Version="4.3.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||
@@ -24,4 +25,8 @@
|
||||
<ProjectReference Include="..\Cleanuparr.Api\Cleanuparr.Api.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,39 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Controllers;
|
||||
|
||||
public sealed class EnumSentinelTests
|
||||
{
|
||||
[Fact]
|
||||
public void SelectableNames_omits_the_sentinel()
|
||||
{
|
||||
List<string> names = EnumSentinel.SelectableNames<EventType>();
|
||||
|
||||
names.ShouldNotContain(EnumSentinel.Unknown);
|
||||
names.ShouldContain(nameof(EventType.StrikeReset));
|
||||
names.Count.ShouldBe(Enum.GetNames<EventType>().Length - 1);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(typeof(InstanceType))]
|
||||
[InlineData(typeof(DownloadClientTypeName))]
|
||||
[InlineData(typeof(DownloadClientType))]
|
||||
[InlineData(typeof(NotificationProviderType))]
|
||||
[InlineData(typeof(EventType))]
|
||||
[InlineData(typeof(EventSeverity))]
|
||||
[InlineData(typeof(ManualEventType))]
|
||||
[InlineData(typeof(StrikeType))]
|
||||
[InlineData(typeof(JobType))]
|
||||
[InlineData(typeof(SearchCommandStatus))]
|
||||
[InlineData(typeof(SeedingRuleAction))]
|
||||
public void Identity_enums_pin_the_sentinel_to_its_own_value(Type enumType)
|
||||
{
|
||||
Convert.ToInt32(Enum.Parse(enumType, EnumSentinel.Unknown))
|
||||
.ShouldBe(EnumSentinel.UnknownValue);
|
||||
|
||||
// A member added later cannot take the sentinel's place.
|
||||
Enum.GetNames(enumType)[^1].ShouldBe(EnumSentinel.Unknown);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using Cleanuparr.Api.Contracts.Responses;
|
||||
using Cleanuparr.Api.Controllers;
|
||||
using Cleanuparr.Api.Features.Events.Contracts.Responses;
|
||||
using Cleanuparr.Api.Tests.Features.Seeker.TestHelpers;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Events;
|
||||
using Cleanuparr.Persistence.Providers;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Shouldly;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// The sentinel is not a database value.
|
||||
/// A filter naming it has to be dropped before the query.
|
||||
/// </summary>
|
||||
public class EventsControllerFilterTests : IDisposable
|
||||
{
|
||||
private readonly EventsContext _context;
|
||||
private readonly EventsController _controller;
|
||||
|
||||
public EventsControllerFilterTests()
|
||||
{
|
||||
_context = SeekerTestDataFactory.CreateEventsContext();
|
||||
_controller = new EventsController(_context, new SqliteDatabaseProvider());
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_context.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
private async Task SeedOneEventAsync()
|
||||
{
|
||||
_context.Events.Add(new AppEvent
|
||||
{
|
||||
EventType = EventType.StalledStrike,
|
||||
Message = "an event",
|
||||
Severity = EventSeverity.Important,
|
||||
Timestamp = DateTimeOffset.UtcNow,
|
||||
});
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private static PaginatedResult<EventListItem> GetEvents(ActionResult<PaginatedResult<EventListItem>> action)
|
||||
{
|
||||
OkObjectResult ok = action.Result.ShouldBeOfType<OkObjectResult>();
|
||||
return ok.Value.ShouldBeOfType<PaginatedResult<EventListItem>>();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(EnumSentinel.Unknown)]
|
||||
[InlineData("999")]
|
||||
public async Task GetEvents_WithAnUnusableEventTypeFilter_IgnoresIt(string eventType)
|
||||
{
|
||||
await SeedOneEventAsync();
|
||||
|
||||
PaginatedResult<EventListItem> result = GetEvents(await _controller.GetEvents(eventType: eventType));
|
||||
|
||||
result.TotalCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(EnumSentinel.Unknown)]
|
||||
[InlineData("999")]
|
||||
public async Task GetEvents_WithAnUnusableSeverityFilter_IgnoresIt(string severity)
|
||||
{
|
||||
await SeedOneEventAsync();
|
||||
|
||||
PaginatedResult<EventListItem> result = GetEvents(await _controller.GetEvents(severity: severity));
|
||||
|
||||
result.TotalCount.ShouldBe(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
using Cleanuparr.Api.Contracts.Responses;
|
||||
using Cleanuparr.Api.Controllers;
|
||||
using Cleanuparr.Api.Features.Events.Contracts.Responses;
|
||||
using Cleanuparr.Api.Tests.Features.Seeker.TestHelpers;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Events;
|
||||
using Cleanuparr.Persistence.Providers;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Shouldly;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the events list endpoint's ordering, filtering, search, and primitive-collection round-tripping.
|
||||
/// Runs against real SQLite so the projection is actually translated.
|
||||
/// </summary>
|
||||
public class EventsControllerMergeTests : IDisposable
|
||||
{
|
||||
private readonly EventsContext _context;
|
||||
private readonly EventsController _controller;
|
||||
|
||||
public EventsControllerMergeTests()
|
||||
{
|
||||
_context = SeekerTestDataFactory.CreateEventsContext();
|
||||
_controller = new EventsController(_context, new SqliteDatabaseProvider());
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_context.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
private async Task SeedAsync()
|
||||
{
|
||||
_context.Events.Add(new AppEvent
|
||||
{
|
||||
EventType = EventType.FailedImportStrike,
|
||||
Message = "active",
|
||||
Severity = EventSeverity.Important,
|
||||
Timestamp = DateTimeOffset.UtcNow.AddDays(-1),
|
||||
ItemTitle = "Active Item",
|
||||
FailedImportReasons = ["reason one", "reason two"],
|
||||
GrabbedItems = ["grab one"],
|
||||
});
|
||||
_context.Events.Add(new AppEvent
|
||||
{
|
||||
EventType = EventType.QueueItemDeleted,
|
||||
Message = "archived",
|
||||
Severity = EventSeverity.Important,
|
||||
Timestamp = DateTimeOffset.UtcNow.AddDays(-100),
|
||||
ItemTitle = "Archived Item",
|
||||
});
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private static PaginatedResult<EventListItem> GetPage(ActionResult<PaginatedResult<EventListItem>> action)
|
||||
{
|
||||
OkObjectResult ok = action.Result.ShouldBeOfType<OkObjectResult>();
|
||||
return ok.Value.ShouldBeOfType<PaginatedResult<EventListItem>>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEvents_OrdersNewestFirst_AndRoundTripsCollections()
|
||||
{
|
||||
await SeedAsync();
|
||||
|
||||
PaginatedResult<EventListItem> page = GetPage(await _controller.GetEvents());
|
||||
|
||||
page.TotalCount.ShouldBe(2);
|
||||
page.Items.Count.ShouldBe(2);
|
||||
page.Items[0].Message.ShouldBe("active"); // newer
|
||||
page.Items[1].Message.ShouldBe("archived");
|
||||
|
||||
// The primitive-collection columns must survive the Concat projection.
|
||||
page.Items[0].FailedImportReasons.ShouldBe(["reason one", "reason two"]);
|
||||
page.Items[0].GrabbedItems.ShouldBe(["grab one"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEvents_EventTypeFilter_Applies()
|
||||
{
|
||||
await SeedAsync();
|
||||
|
||||
PaginatedResult<EventListItem> page = GetPage(await _controller.GetEvents(eventType: nameof(EventType.QueueItemDeleted)));
|
||||
|
||||
page.TotalCount.ShouldBe(1);
|
||||
page.Items[0].Message.ShouldBe("archived");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEvents_SearchFilter_MatchesArchivedItemTitle()
|
||||
{
|
||||
await SeedAsync();
|
||||
|
||||
PaginatedResult<EventListItem> page = GetPage(await _controller.GetEvents(search: "Archived"));
|
||||
|
||||
page.TotalCount.ShouldBe(1);
|
||||
page.Items[0].Message.ShouldBe("archived");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
using Cleanuparr.Api.Controllers;
|
||||
using Cleanuparr.Api.Features.Events.Contracts.Responses;
|
||||
using Cleanuparr.Api.Tests.Features.Seeker.TestHelpers;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Events;
|
||||
using Cleanuparr.Persistence.Providers;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Shouldly;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Controllers;
|
||||
|
||||
public class EventsControllerTimelineTests : IDisposable
|
||||
{
|
||||
private readonly EventsContext _context;
|
||||
private readonly EventsController _controller;
|
||||
|
||||
public EventsControllerTimelineTests()
|
||||
{
|
||||
_context = SeekerTestDataFactory.CreateEventsContext();
|
||||
_controller = new EventsController(_context, new SqliteDatabaseProvider());
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_context.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
private static EventTypeTimelineResponse GetTimeline(ActionResult<EventTypeTimelineResponse> action)
|
||||
{
|
||||
OkObjectResult ok = action.Result.ShouldBeOfType<OkObjectResult>();
|
||||
return ok.Value.ShouldBeOfType<EventTypeTimelineResponse>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTimeline_BucketsEventsByTypeAndDay()
|
||||
{
|
||||
DateOnly today = DateOnly.FromDateTime(DateTimeOffset.UtcNow.UtcDateTime);
|
||||
DateTimeOffset sameDay = new(today.ToDateTime(new TimeOnly(12, 0)), TimeSpan.Zero);
|
||||
|
||||
_context.Events.Add(new AppEvent
|
||||
{
|
||||
EventType = EventType.FailedImportStrike,
|
||||
Message = "active a",
|
||||
Severity = EventSeverity.Important,
|
||||
Timestamp = sameDay,
|
||||
});
|
||||
_context.Events.Add(new AppEvent
|
||||
{
|
||||
EventType = EventType.FailedImportStrike,
|
||||
Message = "active b",
|
||||
Severity = EventSeverity.Important,
|
||||
Timestamp = sameDay.AddHours(-1),
|
||||
});
|
||||
_context.Events.Add(new AppEvent
|
||||
{
|
||||
EventType = EventType.StalledStrike,
|
||||
Message = "active c",
|
||||
Severity = EventSeverity.Important,
|
||||
Timestamp = sameDay.AddHours(-2),
|
||||
});
|
||||
_context.Events.Add(new AppEvent
|
||||
{
|
||||
EventType = EventType.QueueItemDeleted,
|
||||
Message = "older removal",
|
||||
Severity = EventSeverity.Important,
|
||||
Timestamp = DateTimeOffset.UtcNow.AddDays(-10),
|
||||
});
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
EventTypeTimelineResponse timeline = GetTimeline(await _controller.GetTimeline(hours: 24 * 30));
|
||||
|
||||
timeline.Types.ShouldBe(["FailedImportStrike", "StalledStrike", "QueueItemDeleted"]);
|
||||
|
||||
int failedImport = timeline.Buckets.Sum(b => b.Counts.GetValueOrDefault("FailedImportStrike"));
|
||||
int stalled = timeline.Buckets.Sum(b => b.Counts.GetValueOrDefault("StalledStrike"));
|
||||
int removed = timeline.Buckets.Sum(b => b.Counts.GetValueOrDefault("QueueItemDeleted"));
|
||||
|
||||
failedImport.ShouldBe(2);
|
||||
stalled.ShouldBe(1);
|
||||
removed.ShouldBe(1);
|
||||
|
||||
DateTimeOffset todayStart = new(today.ToDateTime(TimeOnly.MinValue), TimeSpan.Zero);
|
||||
EventTypeTimelineBucket todayBucket = timeline.Buckets.Single(b => b.Date == todayStart);
|
||||
todayBucket.Counts["FailedImportStrike"].ShouldBe(2);
|
||||
todayBucket.Counts["StalledStrike"].ShouldBe(1);
|
||||
todayBucket.Counts.ShouldNotContainKey("QueueItemDeleted");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTimeline_UsesHourlyBucketsForDayWindow()
|
||||
{
|
||||
DateTimeOffset now = DateTimeOffset.UtcNow;
|
||||
_context.Events.Add(new AppEvent
|
||||
{
|
||||
EventType = EventType.StalledStrike,
|
||||
Message = "recent",
|
||||
Severity = EventSeverity.Important,
|
||||
Timestamp = now.AddHours(-1),
|
||||
});
|
||||
_context.Events.Add(new AppEvent
|
||||
{
|
||||
EventType = EventType.StalledStrike,
|
||||
Message = "earlier",
|
||||
Severity = EventSeverity.Important,
|
||||
Timestamp = now.AddHours(-3),
|
||||
});
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
EventTypeTimelineResponse timeline = GetTimeline(await _controller.GetTimeline(hours: 24));
|
||||
|
||||
int nonEmpty = timeline.Buckets.Count(b => b.Counts.GetValueOrDefault("StalledStrike") > 0);
|
||||
nonEmpty.ShouldBe(2);
|
||||
timeline.Buckets.Count.ShouldBeGreaterThan(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTimeline_ExcludesEventsOutsideWindow()
|
||||
{
|
||||
_context.Events.Add(new AppEvent
|
||||
{
|
||||
EventType = EventType.QueueItemDeleted,
|
||||
Message = "too old",
|
||||
Severity = EventSeverity.Important,
|
||||
Timestamp = DateTimeOffset.UtcNow.AddDays(-40),
|
||||
});
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
EventTypeTimelineResponse timeline = GetTimeline(await _controller.GetTimeline(hours: 24 * 7));
|
||||
|
||||
timeline.Types.ShouldBeEmpty();
|
||||
timeline.Buckets.ShouldAllBe(b => b.Counts.Count == 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTimeline_SumsEveryUnknownTypeInABucket()
|
||||
{
|
||||
DateOnly today = DateOnly.FromDateTime(DateTimeOffset.UtcNow.UtcDateTime);
|
||||
DateTimeOffset sameDay = new(today.ToDateTime(new TimeOnly(12, 0)), TimeSpan.Zero);
|
||||
|
||||
_context.Events.Add(new AppEvent
|
||||
{
|
||||
EventType = EventType.FailedImportStrike,
|
||||
Message = "first unknown",
|
||||
Severity = EventSeverity.Important,
|
||||
Timestamp = sameDay,
|
||||
});
|
||||
_context.Events.Add(new AppEvent
|
||||
{
|
||||
EventType = EventType.StalledStrike,
|
||||
Message = "second unknown",
|
||||
Severity = EventSeverity.Important,
|
||||
Timestamp = sameDay.AddHours(-1),
|
||||
});
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
// Two types a newer version wrote.
|
||||
// This build reads both as one.
|
||||
await _context.Database.ExecuteSqlRawAsync(
|
||||
"UPDATE events SET event_type = 'fromthefuture' WHERE message = 'first unknown'");
|
||||
await _context.Database.ExecuteSqlRawAsync(
|
||||
"UPDATE events SET event_type = 'alsofromthefuture' WHERE message = 'second unknown'");
|
||||
|
||||
EventTypeTimelineResponse timeline = GetTimeline(await _controller.GetTimeline(hours: 24 * 30));
|
||||
|
||||
timeline.Types.ShouldBe([EnumSentinel.Unknown]);
|
||||
|
||||
DateTimeOffset todayStart = new(today.ToDateTime(TimeOnly.MinValue), TimeSpan.Zero);
|
||||
EventTypeTimelineBucket todayBucket = timeline.Buckets.Single(b => b.Date == todayStart);
|
||||
todayBucket.Counts[EnumSentinel.Unknown].ShouldBe(2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
using Cleanuparr.Api.Controllers;
|
||||
using Cleanuparr.Api.Features.Status.Contracts.Responses;
|
||||
using Cleanuparr.Api.Tests.TestHelpers;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Health;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using NSubstitute.ExceptionExtensions;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Controllers;
|
||||
|
||||
public class StatusControllerTests : IDisposable
|
||||
{
|
||||
private readonly DataContext _dataContext;
|
||||
private readonly IInstanceHealthChecker _healthChecker;
|
||||
private readonly StatusController _controller;
|
||||
|
||||
public StatusControllerTests()
|
||||
{
|
||||
_dataContext = ConfigControllerTestDataFactory.CreateDataContext();
|
||||
_healthChecker = Substitute.For<IInstanceHealthChecker>();
|
||||
_controller = new StatusController(
|
||||
Substitute.For<ILogger<StatusController>>(),
|
||||
_dataContext,
|
||||
_healthChecker);
|
||||
ConfigControllerTestDataFactory.ConfigureProblemDetails(_controller);
|
||||
}
|
||||
|
||||
private async Task<ArrInstance> AddEnabledInstance(InstanceType type, string name = "instance")
|
||||
{
|
||||
Guid configId = await _dataContext.ArrConfigs
|
||||
.AsNoTracking()
|
||||
.Where(x => x.Type == type)
|
||||
.Select(x => x.Id)
|
||||
.FirstAsync();
|
||||
|
||||
ArrInstance instance = new()
|
||||
{
|
||||
Name = name,
|
||||
Url = new Uri("http://instance.local"),
|
||||
ApiKey = "key",
|
||||
Enabled = true,
|
||||
ArrConfigId = configId,
|
||||
};
|
||||
|
||||
_dataContext.ArrInstances.Add(instance);
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
private static Dictionary<string, List<InstanceConnectionResponse>> AsDictionary(IActionResult result) =>
|
||||
result.ShouldBeOfType<OkObjectResult>().Value
|
||||
.ShouldBeOfType<Dictionary<string, List<InstanceConnectionResponse>>>();
|
||||
|
||||
[Fact]
|
||||
public async Task GetMediaManagersStatus_CoversEveryInstanceType()
|
||||
{
|
||||
// Act
|
||||
IActionResult result = await _controller.GetMediaManagersStatus();
|
||||
|
||||
// Assert: the list drives the response, so a forgotten member would vanish from the UI.
|
||||
// The Unknown sentinel is not a media manager.
|
||||
Dictionary<string, List<InstanceConnectionResponse>> status = AsDictionary(result);
|
||||
foreach (InstanceType type in EnumSentinel.SelectableValues<InstanceType>())
|
||||
{
|
||||
status.ShouldContainKey(type.ToString());
|
||||
}
|
||||
|
||||
status.ShouldNotContainKey(EnumSentinel.Unknown);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetMediaManagersStatus_ProbesAnEnabledInstance()
|
||||
{
|
||||
// Arrange
|
||||
await AddEnabledInstance(InstanceType.LazyLibrarian);
|
||||
|
||||
// Act
|
||||
await _controller.GetMediaManagersStatus();
|
||||
|
||||
// Assert
|
||||
await _healthChecker.Received(1).CheckAsync(InstanceType.LazyLibrarian, Arg.Any<ArrInstance>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetMediaManagersStatus_ReportsTheFailureReason()
|
||||
{
|
||||
// Arrange
|
||||
await AddEnabledInstance(InstanceType.Sonarr);
|
||||
_healthChecker
|
||||
.CheckAsync(Arg.Any<InstanceType>(), Arg.Any<ArrInstance>())
|
||||
.ThrowsAsync(new Exception("connection refused"));
|
||||
|
||||
// Act
|
||||
IActionResult result = await _controller.GetMediaManagersStatus();
|
||||
|
||||
// Assert
|
||||
InstanceConnectionResponse sonarr = AsDictionary(result)[nameof(InstanceType.Sonarr)].ShouldHaveSingleItem();
|
||||
sonarr.IsConnected.ShouldBeFalse();
|
||||
sonarr.Message.ShouldContain("connection refused");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSystemStatus_CountsInstancesPerType()
|
||||
{
|
||||
// Arrange
|
||||
await AddEnabledInstance(InstanceType.Radarr, "one");
|
||||
await AddEnabledInstance(InstanceType.Radarr, "two");
|
||||
|
||||
// Act
|
||||
IActionResult result = await _controller.GetSystemStatus();
|
||||
|
||||
// Assert
|
||||
SystemStatusResponse status = result.ShouldBeOfType<OkObjectResult>().Value
|
||||
.ShouldBeOfType<SystemStatusResponse>();
|
||||
status.MediaManagers[nameof(InstanceType.Radarr)].InstanceCount.ShouldBe(2);
|
||||
status.MediaManagers[nameof(InstanceType.LazyLibrarian)].InstanceCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetDownloadClientStatus_ReturnsTheClientsKey()
|
||||
{
|
||||
// Act
|
||||
IActionResult result = await _controller.GetDownloadClientStatus();
|
||||
|
||||
// Assert
|
||||
result.ShouldBeOfType<OkObjectResult>().Value
|
||||
.ShouldBeOfType<Dictionary<string, List<DownloadClientStatusResponse>>>()
|
||||
.ShouldContainKey("Clients");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_dataContext.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
@@ -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,66 @@
|
||||
using Cleanuparr.Api.Tests.Features.Seeker.TestHelpers;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Events;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Shouldly;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Events;
|
||||
|
||||
/// <summary>
|
||||
/// Exercises the manual-event partial unique index against a real SQLite context configured with the
|
||||
/// production naming conventions. The EF Core InMemory provider ignores unique indexes, so the guarantee
|
||||
/// that <see cref="Cleanuparr.Infrastructure.Events.EventPublisher.PublishManualAsync"/> relies on to
|
||||
/// dedup racing publishers can only be verified here.
|
||||
/// </summary>
|
||||
public class ManualEventDedupTests : IDisposable
|
||||
{
|
||||
private readonly EventsContext _context;
|
||||
|
||||
public ManualEventDedupTests()
|
||||
{
|
||||
_context = SeekerTestDataFactory.CreateEventsContext();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_context.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
private static ManualEvent NewEvent(string hash, bool isResolved) => new()
|
||||
{
|
||||
Type = ManualEventType.RecurringDownload,
|
||||
Message = "m",
|
||||
Severity = EventSeverity.Warning,
|
||||
ItemHash = hash,
|
||||
IsResolved = isResolved,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task TwoUnresolvedSameTypeAndHash_ViolatesUniqueIndex_WithSqliteConstraintError()
|
||||
{
|
||||
_context.ManualEvents.Add(NewEvent("abc123", isResolved: false));
|
||||
_context.ManualEvents.Add(NewEvent("abc123", isResolved: false));
|
||||
|
||||
// The exception must surface as SQLITE_CONSTRAINT (19) — the exact code PublishManualAsync's
|
||||
// catch filters on to treat the loser of a race as deduped.
|
||||
DbUpdateException ex = await Should.ThrowAsync<DbUpdateException>(() => _context.SaveChangesAsync());
|
||||
SqliteException sqliteEx = ex.InnerException.ShouldBeOfType<SqliteException>();
|
||||
sqliteEx.SqliteErrorCode.ShouldBe(19);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolvedDuplicate_IsExemptFromUniqueIndex()
|
||||
{
|
||||
_context.ManualEvents.Add(NewEvent("abc123", isResolved: false));
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
// The index is filtered on "is_resolved = 0", so a resolved row with the same type/hash is allowed.
|
||||
_context.ManualEvents.Add(NewEvent("abc123", isResolved: true));
|
||||
await Should.NotThrowAsync(() => _context.SaveChangesAsync());
|
||||
|
||||
(await _context.ManualEvents.CountAsync()).ShouldBe(2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,518 @@
|
||||
using Cleanuparr.Infrastructure.Health;
|
||||
using Cleanuparr.Api.Features.Arr.Contracts.Requests;
|
||||
using Cleanuparr.Api.Features.Arr.Controllers;
|
||||
using Cleanuparr.Api.Tests.TestHelpers;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Events.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.Arr.Dtos;
|
||||
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Features.Arr;
|
||||
|
||||
public class ArrConfigControllerTests : IDisposable
|
||||
{
|
||||
private readonly DataContext _dataContext;
|
||||
private readonly EventsContext _eventsContext;
|
||||
private readonly IInstanceHealthChecker _healthChecker;
|
||||
private readonly IEventPublisher _eventPublisher;
|
||||
private readonly ArrConfigController _controller;
|
||||
|
||||
public ArrConfigControllerTests()
|
||||
{
|
||||
_dataContext = ConfigControllerTestDataFactory.CreateDataContext();
|
||||
_eventsContext = ConfigControllerTestDataFactory.CreateEventsContext();
|
||||
var logger = Substitute.For<ILogger<ArrConfigController>>();
|
||||
_healthChecker = Substitute.For<IInstanceHealthChecker>();
|
||||
_eventPublisher = Substitute.For<IEventPublisher>();
|
||||
_controller = new ArrConfigController(logger, _dataContext, _eventsContext, _healthChecker, _eventPublisher);
|
||||
ConfigControllerTestDataFactory.ConfigureProblemDetails(_controller);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_dataContext.Dispose();
|
||||
_eventsContext.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
#region GET configs
|
||||
|
||||
[Theory]
|
||||
[InlineData(InstanceType.Sonarr)]
|
||||
[InlineData(InstanceType.Radarr)]
|
||||
[InlineData(InstanceType.Lidarr)]
|
||||
[InlineData(InstanceType.Readarr)]
|
||||
[InlineData(InstanceType.Whisparr)]
|
||||
[InlineData(InstanceType.Sportarr)]
|
||||
[InlineData(InstanceType.LazyLibrarian)]
|
||||
public async Task GetArrConfig_AllTypes_ReturnOk(InstanceType type)
|
||||
{
|
||||
// Act
|
||||
var result = await DispatchGet(type);
|
||||
|
||||
// Assert
|
||||
var ok = result.ShouldBeOfType<OkObjectResult>();
|
||||
var dto = ok.Value.ShouldBeOfType<ArrConfigDto>();
|
||||
dto.Type.ShouldBe(type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSonarrConfig_OrdersInstancesByName()
|
||||
{
|
||||
// Arrange
|
||||
var config = await _dataContext.ArrConfigs.FirstAsync(c => c.Type == InstanceType.Sonarr);
|
||||
_dataContext.ArrInstances.AddRange(
|
||||
new ArrInstance { Name = "z", Url = new Uri("http://z"), ApiKey = "k", ArrConfigId = config.Id, Enabled = true },
|
||||
new ArrInstance { Name = "a", Url = new Uri("http://a"), ApiKey = "k", ArrConfigId = config.Id, Enabled = true });
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
// Act
|
||||
var result = await _controller.GetSonarrConfig();
|
||||
|
||||
// Assert
|
||||
var ok = result.ShouldBeOfType<OkObjectResult>();
|
||||
var dto = ok.Value.ShouldBeOfType<ArrConfigDto>();
|
||||
dto.Instances[0].Name.ShouldBe("a");
|
||||
dto.Instances[1].Name.ShouldBe("z");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region PUT configs
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSonarrConfig_PersistsFailedImportMaxStrikes()
|
||||
{
|
||||
// Arrange
|
||||
var request = new UpdateArrConfigRequest { FailedImportMaxStrikes = 7 };
|
||||
|
||||
// Act
|
||||
var result = await _controller.UpdateSonarrConfig(request);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeOfType<OkObjectResult>();
|
||||
var saved = await _dataContext.ArrConfigs.AsNoTracking().FirstAsync(c => c.Type == InstanceType.Sonarr);
|
||||
saved.FailedImportMaxStrikes.ShouldBe((short)7);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSonarrConfig_DefaultStrikes_PassesThrough()
|
||||
{
|
||||
// ArrConfig.Validate is currently a no-op; -1 (the default disabled value) is accepted
|
||||
var request = new UpdateArrConfigRequest { FailedImportMaxStrikes = -1 };
|
||||
|
||||
// Act
|
||||
var result = await _controller.UpdateSonarrConfig(request);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeOfType<OkObjectResult>();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Create instance
|
||||
|
||||
[Fact]
|
||||
public async Task CreateSonarrInstance_PersistsInstanceUnderSonarrConfig()
|
||||
{
|
||||
// Arrange
|
||||
var request = new ArrInstanceRequest
|
||||
{
|
||||
Name = "test",
|
||||
Url = "http://sonarr.test:8989",
|
||||
ApiKey = "abc",
|
||||
Version = 4f,
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _controller.CreateSonarrInstance(request);
|
||||
|
||||
// Assert
|
||||
var created = result.ShouldBeOfType<CreatedAtActionResult>();
|
||||
var dto = created.Value.ShouldBeOfType<ArrInstanceDto>();
|
||||
dto.Name.ShouldBe("test");
|
||||
var sonarrConfig = await _dataContext.ArrConfigs
|
||||
.Include(c => c.Instances)
|
||||
.FirstAsync(c => c.Type == InstanceType.Sonarr);
|
||||
sonarrConfig.Instances.ShouldContain(i => i.Name == "test");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateSonarrInstance_PlaceholderApiKey_ThrowsValidationException()
|
||||
{
|
||||
// Arrange — placeholder ApiKey is rejected by ArrInstanceRequest.ToEntity
|
||||
var request = new ArrInstanceRequest
|
||||
{
|
||||
Name = "test",
|
||||
Url = "http://sonarr.test:8989",
|
||||
ApiKey = "••••••••",
|
||||
Version = 4f,
|
||||
};
|
||||
|
||||
// Act / Assert
|
||||
await Should.ThrowAsync<Cleanuparr.Domain.Exceptions.ValidationException>(
|
||||
() => _controller.CreateSonarrInstance(request));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Update instance
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSonarrInstance_UnknownId_ReturnsNotFound()
|
||||
{
|
||||
// Arrange
|
||||
var request = new ArrInstanceRequest
|
||||
{
|
||||
Name = "x",
|
||||
Url = "http://x",
|
||||
ApiKey = "k",
|
||||
Version = 4f,
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _controller.UpdateSonarrInstance(Guid.NewGuid(), request);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(404);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSonarrInstance_Existing_PersistsChanges()
|
||||
{
|
||||
// Arrange
|
||||
var sonarr = await _dataContext.ArrConfigs.FirstAsync(c => c.Type == InstanceType.Sonarr);
|
||||
var instance = new ArrInstance
|
||||
{
|
||||
Name = "orig",
|
||||
Url = new Uri("http://orig:8989"),
|
||||
ApiKey = "k",
|
||||
ArrConfigId = sonarr.Id,
|
||||
Enabled = true,
|
||||
};
|
||||
_dataContext.ArrInstances.Add(instance);
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
var request = new ArrInstanceRequest
|
||||
{
|
||||
Name = "renamed",
|
||||
Url = "http://renamed:8989",
|
||||
ApiKey = "newkey",
|
||||
Version = 4f,
|
||||
Enabled = false,
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _controller.UpdateSonarrInstance(instance.Id, request);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeOfType<OkObjectResult>();
|
||||
var saved = await _dataContext.ArrInstances.AsNoTracking().FirstAsync(i => i.Id == instance.Id);
|
||||
saved.Name.ShouldBe("renamed");
|
||||
saved.Enabled.ShouldBeFalse();
|
||||
saved.ApiKey.ShouldBe("newkey");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Delete instance
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteSonarrInstance_UnknownId_ReturnsNotFound()
|
||||
{
|
||||
// Act
|
||||
var result = await _controller.DeleteSonarrInstance(Guid.NewGuid());
|
||||
|
||||
// Assert
|
||||
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(404);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteSonarrInstance_Existing_ReturnsNoContent()
|
||||
{
|
||||
// Arrange
|
||||
var sonarr = await _dataContext.ArrConfigs.FirstAsync(c => c.Type == InstanceType.Sonarr);
|
||||
var instance = new ArrInstance
|
||||
{
|
||||
Name = "doomed",
|
||||
Url = new Uri("http://doomed:8989"),
|
||||
ApiKey = "k",
|
||||
ArrConfigId = sonarr.Id,
|
||||
Enabled = true,
|
||||
};
|
||||
_dataContext.ArrInstances.Add(instance);
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
// Act
|
||||
var result = await _controller.DeleteSonarrInstance(instance.Id);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeOfType<NoContentResult>();
|
||||
(await _dataContext.ArrInstances.CountAsync(i => i.Id == instance.Id)).ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteSonarrInstance_FailsSearchEventsThatAreStillInFlight()
|
||||
{
|
||||
// Arrange
|
||||
var sonarr = await _dataContext.ArrConfigs.FirstAsync(c => c.Type == InstanceType.Sonarr);
|
||||
var instance = new ArrInstance
|
||||
{
|
||||
Name = "doomed",
|
||||
Url = new Uri("http://doomed:8989"),
|
||||
ApiKey = "k",
|
||||
ArrConfigId = sonarr.Id,
|
||||
Enabled = true,
|
||||
};
|
||||
_dataContext.ArrInstances.Add(instance);
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
// Act
|
||||
await _controller.DeleteSonarrInstance(instance.Id);
|
||||
|
||||
// Assert
|
||||
await _eventPublisher.Received(1).FailStrandedSearchEvents(instance.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteSonarrInstance_WhenStateCleanupFails_KeepsInstance()
|
||||
{
|
||||
// Arrange
|
||||
var sonarr = await _dataContext.ArrConfigs.FirstAsync(c => c.Type == InstanceType.Sonarr);
|
||||
var instance = new ArrInstance
|
||||
{
|
||||
Name = "doomed",
|
||||
Url = new Uri("http://doomed:8989"),
|
||||
ApiKey = "k",
|
||||
ArrConfigId = sonarr.Id,
|
||||
Enabled = true,
|
||||
};
|
||||
_dataContext.ArrInstances.Add(instance);
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
// Force the events cleanup to fail so the instance delete must roll back
|
||||
await _eventsContext.DisposeAsync();
|
||||
|
||||
// Act
|
||||
await Should.ThrowAsync<Exception>(() => _controller.DeleteSonarrInstance(instance.Id));
|
||||
|
||||
// Assert — instance is preserved (never deleted without its events state being removed)
|
||||
(await _dataContext.ArrInstances.AsNoTracking().CountAsync(i => i.Id == instance.Id)).ShouldBe(1);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Test instance
|
||||
|
||||
[Fact]
|
||||
public async Task TestSonarrInstance_HealthCheckSucceeds_ReturnsOk()
|
||||
{
|
||||
// Arrange — IArrClient.HealthCheckAsync returns Task.CompletedTask by default
|
||||
var request = new TestArrInstanceRequest
|
||||
{
|
||||
Url = "http://sonarr.test:8989",
|
||||
ApiKey = "k",
|
||||
Version = 4f,
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _controller.TestSonarrInstance(request);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeOfType<OkObjectResult>();
|
||||
await _healthChecker.Received(1).CheckAsync(InstanceType.Sonarr, Arg.Any<ArrInstance>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TestSonarrInstance_HealthCheckThrows_ReturnsBadRequest()
|
||||
{
|
||||
// Arrange
|
||||
_healthChecker.CheckAsync(Arg.Any<InstanceType>(), Arg.Any<ArrInstance>())
|
||||
.Returns(Task.FromException(new HttpRequestException("unreachable")));
|
||||
|
||||
var request = new TestArrInstanceRequest
|
||||
{
|
||||
Url = "http://sonarr.test:8989",
|
||||
ApiKey = "k",
|
||||
Version = 4f,
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _controller.TestSonarrInstance(request);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(400);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TestSonarrInstance_PlaceholderApiKeyNoInstanceId_ReturnsBadRequest()
|
||||
{
|
||||
// Arrange — placeholder API key with no InstanceId means we can't resolve it; ToTestInstance throws
|
||||
var request = new TestArrInstanceRequest
|
||||
{
|
||||
Url = "http://sonarr.test:8989",
|
||||
ApiKey = "••••••••",
|
||||
Version = 4f,
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _controller.TestSonarrInstance(request);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(400);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TestSonarrInstance_PlaceholderApiKeyResolvesFromInstanceId_RunsCheck()
|
||||
{
|
||||
// Arrange
|
||||
var sonarr = await _dataContext.ArrConfigs.FirstAsync(c => c.Type == InstanceType.Sonarr);
|
||||
var stored = new ArrInstance
|
||||
{
|
||||
Name = "stored",
|
||||
Url = new Uri("http://stored:8989"),
|
||||
ApiKey = "stored-key",
|
||||
ArrConfigId = sonarr.Id,
|
||||
Enabled = true,
|
||||
};
|
||||
_dataContext.ArrInstances.Add(stored);
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
var request = new TestArrInstanceRequest
|
||||
{
|
||||
Url = "http://sonarr.test:8989",
|
||||
ApiKey = "••••••••",
|
||||
Version = 4f,
|
||||
InstanceId = stored.Id,
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _controller.TestSonarrInstance(request);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeOfType<OkObjectResult>();
|
||||
await _healthChecker.Received(1).CheckAsync(InstanceType.Sonarr, Arg.Is<ArrInstance>(i => i.ApiKey == "stored-key"));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Route wiring
|
||||
|
||||
[Theory]
|
||||
[InlineData(InstanceType.Sonarr)]
|
||||
[InlineData(InstanceType.Radarr)]
|
||||
[InlineData(InstanceType.Lidarr)]
|
||||
[InlineData(InstanceType.Readarr)]
|
||||
[InlineData(InstanceType.Whisparr)]
|
||||
[InlineData(InstanceType.Sportarr)]
|
||||
[InlineData(InstanceType.LazyLibrarian)]
|
||||
public async Task InstanceRoutes_AllTypes_CreateUpdateDeleteAndTest(InstanceType type)
|
||||
{
|
||||
// Arrange: every route delegates to the same helper, so this covers the wiring per type.
|
||||
ArrInstanceRequest request = new()
|
||||
{
|
||||
Name = "wired",
|
||||
Url = "http://instance.test:1234",
|
||||
ApiKey = "abc",
|
||||
Version = 1f,
|
||||
};
|
||||
|
||||
// Act + Assert: create
|
||||
IActionResult created = await DispatchCreate(type, request);
|
||||
ArrInstanceDto dto = created.ShouldBeOfType<CreatedAtActionResult>().Value.ShouldBeOfType<ArrInstanceDto>();
|
||||
|
||||
// Act + Assert: update
|
||||
Guid id = dto.Id.ShouldNotBeNull();
|
||||
IActionResult updated = await DispatchUpdate(type, id, request with { Name = "rewired" });
|
||||
updated.ShouldBeOfType<OkObjectResult>().Value.ShouldBeOfType<ArrInstanceDto>().Name.ShouldBe("rewired");
|
||||
|
||||
// Act + Assert: connection test
|
||||
IActionResult tested = await DispatchTest(type, new TestArrInstanceRequest
|
||||
{
|
||||
Url = request.Url,
|
||||
ApiKey = request.ApiKey,
|
||||
Version = request.Version,
|
||||
});
|
||||
tested.ShouldBeOfType<OkObjectResult>();
|
||||
await _healthChecker.Received(1).CheckAsync(type, Arg.Any<ArrInstance>());
|
||||
|
||||
// Act + Assert: delete
|
||||
IActionResult deleted = await DispatchDelete(type, id);
|
||||
deleted.ShouldBeOfType<NoContentResult>();
|
||||
|
||||
ArrConfig config = await _dataContext.ArrConfigs
|
||||
.Include(c => c.Instances)
|
||||
.FirstAsync(c => c.Type == type);
|
||||
config.Instances.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private Task<IActionResult> DispatchCreate(InstanceType type, ArrInstanceRequest request) => type switch
|
||||
{
|
||||
InstanceType.Sonarr => _controller.CreateSonarrInstance(request),
|
||||
InstanceType.Radarr => _controller.CreateRadarrInstance(request),
|
||||
InstanceType.Lidarr => _controller.CreateLidarrInstance(request),
|
||||
InstanceType.Readarr => _controller.CreateReadarrInstance(request),
|
||||
InstanceType.Whisparr => _controller.CreateWhisparrInstance(request),
|
||||
InstanceType.Sportarr => _controller.CreateSportarrInstance(request),
|
||||
InstanceType.LazyLibrarian => _controller.CreateLazyLibrarianInstance(request),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(type)),
|
||||
};
|
||||
|
||||
private Task<IActionResult> DispatchUpdate(InstanceType type, Guid id, ArrInstanceRequest request) => type switch
|
||||
{
|
||||
InstanceType.Sonarr => _controller.UpdateSonarrInstance(id, request),
|
||||
InstanceType.Radarr => _controller.UpdateRadarrInstance(id, request),
|
||||
InstanceType.Lidarr => _controller.UpdateLidarrInstance(id, request),
|
||||
InstanceType.Readarr => _controller.UpdateReadarrInstance(id, request),
|
||||
InstanceType.Whisparr => _controller.UpdateWhisparrInstance(id, request),
|
||||
InstanceType.Sportarr => _controller.UpdateSportarrInstance(id, request),
|
||||
InstanceType.LazyLibrarian => _controller.UpdateLazyLibrarianInstance(id, request),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(type)),
|
||||
};
|
||||
|
||||
private Task<IActionResult> DispatchDelete(InstanceType type, Guid id) => type switch
|
||||
{
|
||||
InstanceType.Sonarr => _controller.DeleteSonarrInstance(id),
|
||||
InstanceType.Radarr => _controller.DeleteRadarrInstance(id),
|
||||
InstanceType.Lidarr => _controller.DeleteLidarrInstance(id),
|
||||
InstanceType.Readarr => _controller.DeleteReadarrInstance(id),
|
||||
InstanceType.Whisparr => _controller.DeleteWhisparrInstance(id),
|
||||
InstanceType.Sportarr => _controller.DeleteSportarrInstance(id),
|
||||
InstanceType.LazyLibrarian => _controller.DeleteLazyLibrarianInstance(id),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(type)),
|
||||
};
|
||||
|
||||
private Task<IActionResult> DispatchTest(InstanceType type, TestArrInstanceRequest request) => type switch
|
||||
{
|
||||
InstanceType.Sonarr => _controller.TestSonarrInstance(request),
|
||||
InstanceType.Radarr => _controller.TestRadarrInstance(request),
|
||||
InstanceType.Lidarr => _controller.TestLidarrInstance(request),
|
||||
InstanceType.Readarr => _controller.TestReadarrInstance(request),
|
||||
InstanceType.Whisparr => _controller.TestWhisparrInstance(request),
|
||||
InstanceType.Sportarr => _controller.TestSportarrInstance(request),
|
||||
InstanceType.LazyLibrarian => _controller.TestLazyLibrarianInstance(request),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(type)),
|
||||
};
|
||||
|
||||
private Task<IActionResult> DispatchGet(InstanceType type) => type switch
|
||||
{
|
||||
InstanceType.Sonarr => _controller.GetSonarrConfig(),
|
||||
InstanceType.Radarr => _controller.GetRadarrConfig(),
|
||||
InstanceType.Lidarr => _controller.GetLidarrConfig(),
|
||||
InstanceType.Readarr => _controller.GetReadarrConfig(),
|
||||
InstanceType.Whisparr => _controller.GetWhisparrConfig(),
|
||||
InstanceType.Sportarr => _controller.GetSportarrConfig(),
|
||||
InstanceType.LazyLibrarian => _controller.GetLazyLibrarianConfig(),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(type)),
|
||||
};
|
||||
}
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Shouldly;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Features.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for POST /api/account/feature-views. Verifies that feature "first seen"
|
||||
/// timestamps are recorded per user, that recording is idempotent, and that the endpoint
|
||||
/// requires authentication.
|
||||
/// </summary>
|
||||
[Collection("Auth Integration Tests")]
|
||||
[TestCaseOrderer("Cleanuparr.Api.Tests.PriorityOrderer", "Cleanuparr.Api.Tests")]
|
||||
public class AccountControllerFeatureViewsTests : IClassFixture<CustomWebApplicationFactory>
|
||||
{
|
||||
private readonly CustomWebApplicationFactory _factory;
|
||||
private readonly HttpClient _client;
|
||||
|
||||
private static string? _accessToken;
|
||||
|
||||
public AccountControllerFeatureViewsTests(CustomWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
_client = factory.CreateClient();
|
||||
|
||||
if (_accessToken is not null)
|
||||
{
|
||||
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _accessToken);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact, TestPriority(0)]
|
||||
public async Task Setup_CreateAccountAndLogin()
|
||||
{
|
||||
var createResponse = await _client.PostAsJsonAsync("/api/auth/setup/account", new
|
||||
{
|
||||
username = "featureadmin",
|
||||
password = "FeaturePassword123!"
|
||||
});
|
||||
createResponse.StatusCode.ShouldBe(HttpStatusCode.Created);
|
||||
|
||||
var completeResponse = await _client.PostAsJsonAsync("/api/auth/setup/complete", new { });
|
||||
completeResponse.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var loginResponse = await _client.PostAsJsonAsync("/api/auth/login", new
|
||||
{
|
||||
username = "featureadmin",
|
||||
password = "FeaturePassword123!"
|
||||
});
|
||||
loginResponse.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var body = await loginResponse.Content.ReadFromJsonAsync<JsonElement>();
|
||||
_accessToken = body.GetProperty("tokens").GetProperty("accessToken").GetString();
|
||||
_accessToken.ShouldNotBeNullOrEmpty();
|
||||
|
||||
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _accessToken);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(1)]
|
||||
public async Task RecordFeatureViews_NewIds_RecordsTimestampsAndReturnsMapWithAnchor()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/account/feature-views", new
|
||||
{
|
||||
featureIds = new[] { "feature-a", "feature-b" }
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<FeatureViewsResponseDto>();
|
||||
body.ShouldNotBeNull();
|
||||
body.CreatedAt.ShouldNotBe(default);
|
||||
body.Views.ShouldContainKey("feature-a");
|
||||
body.Views.ShouldContainKey("feature-b");
|
||||
body.Views["feature-a"].Offset.ShouldBe(TimeSpan.Zero);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(2)]
|
||||
public async Task RecordFeatureViews_DuplicateId_IsIdempotentAndKeepsOriginalTimestamp()
|
||||
{
|
||||
var firstResponse = await _client.PostAsJsonAsync("/api/account/feature-views", new
|
||||
{
|
||||
featureIds = new[] { "feature-a" }
|
||||
});
|
||||
firstResponse.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
var firstBody = await firstResponse.Content.ReadFromJsonAsync<FeatureViewsResponseDto>();
|
||||
var originalTimestamp = firstBody!.Views["feature-a"];
|
||||
|
||||
var secondResponse = await _client.PostAsJsonAsync("/api/account/feature-views", new
|
||||
{
|
||||
featureIds = new[] { "feature-a" }
|
||||
});
|
||||
secondResponse.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
var secondBody = await secondResponse.Content.ReadFromJsonAsync<FeatureViewsResponseDto>();
|
||||
|
||||
secondBody!.Views["feature-a"].ShouldBe(originalTimestamp);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(3)]
|
||||
public async Task RecordFeatureViews_WhenUnauthenticated_ReturnsUnauthorized()
|
||||
{
|
||||
var unauthClient = _factory.CreateClient();
|
||||
|
||||
var response = await unauthClient.PostAsJsonAsync("/api/account/feature-views", new
|
||||
{
|
||||
featureIds = new[] { "feature-a" }
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(4)]
|
||||
public async Task RecordFeatureViews_TooManyIds_ReturnsBadRequest()
|
||||
{
|
||||
var tooMany = Enumerable.Range(0, 101).Select(i => $"feature-{i}").ToArray();
|
||||
|
||||
var response = await _client.PostAsJsonAsync("/api/account/feature-views", new
|
||||
{
|
||||
featureIds = tooMany
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(5)]
|
||||
public async Task RecordFeatureViews_OverLengthId_IsSkipped()
|
||||
{
|
||||
var overLengthId = new string('x', 65);
|
||||
|
||||
var response = await _client.PostAsJsonAsync("/api/account/feature-views", new
|
||||
{
|
||||
featureIds = new[] { "feature-ok", overLengthId }
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<FeatureViewsResponseDto>();
|
||||
body.ShouldNotBeNull();
|
||||
body.Views.ShouldContainKey("feature-ok");
|
||||
body.Views.ShouldNotContainKey(overLengthId);
|
||||
}
|
||||
|
||||
private sealed record FeatureViewsResponseDto
|
||||
{
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
public Dictionary<string, DateTimeOffset> Views { get; init; } = new();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using Cleanuparr.Infrastructure.Features.Auth;
|
||||
using Cleanuparr.Persistence.Models.Auth;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Shouldly;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Features.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Covers the credential endpoints when a correctly signed access token names a user that does not exist.
|
||||
/// Authentication accepts the token, so the action itself has to reject the request.
|
||||
/// </summary>
|
||||
[Collection("Auth Integration Tests")]
|
||||
[TestCaseOrderer("Cleanuparr.Api.Tests.PriorityOrderer", "Cleanuparr.Api.Tests")]
|
||||
public class AccountControllerMissingUserTests : IClassFixture<CustomWebApplicationFactory>
|
||||
{
|
||||
private const string Username = "ghostadmin";
|
||||
private const string Password = "GhostPassword123!";
|
||||
|
||||
private readonly CustomWebApplicationFactory _factory;
|
||||
private readonly HttpClient _client;
|
||||
|
||||
public AccountControllerMissingUserTests(CustomWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
_client = factory.CreateClient();
|
||||
}
|
||||
|
||||
[Fact, TestPriority(0)]
|
||||
public async Task Setup_CreateAccount()
|
||||
{
|
||||
var createResponse = await _client.PostAsJsonAsync("/api/auth/setup/account", new
|
||||
{
|
||||
username = Username,
|
||||
password = Password
|
||||
});
|
||||
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 ChangePassword_ForAnUnknownUserId_ReturnsUnauthorized()
|
||||
{
|
||||
_client.DefaultRequestHeaders.Authorization = TokenForAnUnknownUser();
|
||||
|
||||
var response = await _client.PutAsJsonAsync("/api/account/password", new
|
||||
{
|
||||
currentPassword = Password,
|
||||
newPassword = "AnotherPassword456!"
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(2)]
|
||||
public async Task ChangeUsername_ForAnUnknownUserId_ReturnsUnauthorized()
|
||||
{
|
||||
_client.DefaultRequestHeaders.Authorization = TokenForAnUnknownUser();
|
||||
|
||||
var response = await _client.PutAsJsonAsync("/api/account/username", new
|
||||
{
|
||||
currentPassword = Password,
|
||||
newUsername = "renamedadmin"
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(3)]
|
||||
public async Task UpdateOidcConfig_ForAnUnknownUserId_ReturnsUnauthorized()
|
||||
{
|
||||
_client.DefaultRequestHeaders.Authorization = TokenForAnUnknownUser();
|
||||
|
||||
var response = await _client.PutAsJsonAsync("/api/account/oidc", new
|
||||
{
|
||||
enabled = true
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
|
||||
}
|
||||
|
||||
private AuthenticationHeaderValue TokenForAnUnknownUser()
|
||||
{
|
||||
using var scope = _factory.Services.CreateScope();
|
||||
var jwtService = scope.ServiceProvider.GetRequiredService<IJwtService>();
|
||||
|
||||
// Signed with the running app's key, so authentication passes and the lookup inside the action is what fails
|
||||
string token = jwtService.GenerateAccessToken(new User
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Username = "gone",
|
||||
PasswordHash = string.Empty,
|
||||
TotpSecret = string.Empty,
|
||||
ApiKey = string.Empty
|
||||
});
|
||||
|
||||
return new AuthenticationHeaderValue("Bearer", token);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,455 @@
|
||||
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("detail").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 ChangeUsername_Blocked_WhenExclusiveModeActive()
|
||||
{
|
||||
var response = await _client.PutAsJsonAsync("/api/account/username", new
|
||||
{
|
||||
currentPassword = "LinkPassword123!",
|
||||
newUsername = "renamedadmin"
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Forbidden);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(13)]
|
||||
public async Task PlexLink_Blocked_WhenExclusiveModeActive()
|
||||
{
|
||||
var response = await _client.PostAsync("/api/account/plex/link", null);
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Forbidden);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(14)]
|
||||
public async Task PlexUnlink_Blocked_WhenExclusiveModeActive()
|
||||
{
|
||||
var response = await _client.DeleteAsync("/api/account/plex/link");
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Forbidden);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(15)]
|
||||
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(16)]
|
||||
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(17)]
|
||||
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);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(18)]
|
||||
public async Task ChangePassword_WithWrongPassword_ReturnsBadRequestAndKeepsThePassword()
|
||||
{
|
||||
var response = await _client.PutAsJsonAsync("/api/account/password", new
|
||||
{
|
||||
currentPassword = "NotThePassword123!",
|
||||
newPassword = "RejectedPassword000!"
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
|
||||
|
||||
var login = await _factory.CreateClient().PostAsJsonAsync("/api/auth/login", new
|
||||
{
|
||||
username = "linkadmin",
|
||||
password = "NewPassword789!"
|
||||
});
|
||||
|
||||
login.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
|
||||
}
|
||||
@@ -0,0 +1,469 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Auth;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Shouldly;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Features.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Tests that 2FA disable and regenerate accept a recovery code.
|
||||
/// </summary>
|
||||
[Collection("Auth Integration Tests")]
|
||||
[TestCaseOrderer("Cleanuparr.Api.Tests.PriorityOrderer", "Cleanuparr.Api.Tests")]
|
||||
public class AccountControllerTwoFactorTests : IClassFixture<CustomWebApplicationFactory>
|
||||
{
|
||||
private const string Username = "twofaadmin";
|
||||
private const string Password = "TwoFactorPassword123!";
|
||||
|
||||
private readonly CustomWebApplicationFactory _factory;
|
||||
private readonly HttpClient _client;
|
||||
|
||||
private static string? _accessToken;
|
||||
private static string _secret = "";
|
||||
private static List<string> _recoveryCodes = [];
|
||||
|
||||
public AccountControllerTwoFactorTests(CustomWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
_client = factory.CreateClient();
|
||||
|
||||
if (_accessToken is not null)
|
||||
{
|
||||
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _accessToken);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact, TestPriority(0)]
|
||||
public async Task Setup_CreateAccountAndLogin()
|
||||
{
|
||||
var createResponse = await _client.PostAsJsonAsync("/api/auth/setup/account", new
|
||||
{
|
||||
username = Username,
|
||||
password = Password
|
||||
});
|
||||
createResponse.StatusCode.ShouldBe(HttpStatusCode.Created);
|
||||
|
||||
var completeResponse = await _client.PostAsJsonAsync("/api/auth/setup/complete", new { });
|
||||
completeResponse.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var loginResponse = await _client.PostAsJsonAsync("/api/auth/login", new
|
||||
{
|
||||
username = Username,
|
||||
password = Password
|
||||
});
|
||||
loginResponse.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var body = await loginResponse.Content.ReadFromJsonAsync<JsonElement>();
|
||||
_accessToken = body.GetProperty("tokens").GetProperty("accessToken").GetString();
|
||||
_accessToken.ShouldNotBeNullOrEmpty();
|
||||
|
||||
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _accessToken);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(1)]
|
||||
public async Task Enable2fa_WithGeneratedTotpCode_TurnsTwoFactorOn()
|
||||
{
|
||||
await EnableTwoFactor();
|
||||
|
||||
(await IsTwoFactorEnabled()).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact, TestPriority(2)]
|
||||
public async Task Disable2fa_WithRecoveryCode_Succeeds()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/account/2fa/disable", new
|
||||
{
|
||||
password = Password,
|
||||
totpCode = _recoveryCodes[0]
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
(await IsTwoFactorEnabled()).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact, TestPriority(3)]
|
||||
public async Task Regenerate2fa_WithRecoveryCode_RotatesSecretAndCodes()
|
||||
{
|
||||
await EnableTwoFactor();
|
||||
|
||||
var previousSecret = _secret;
|
||||
var previousCodes = _recoveryCodes;
|
||||
|
||||
var response = await _client.PostAsJsonAsync("/api/account/2fa/regenerate", new
|
||||
{
|
||||
password = Password,
|
||||
totpCode = previousCodes[0]
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
_secret = body.GetProperty("secret").GetString()!;
|
||||
_recoveryCodes = ReadRecoveryCodes(body);
|
||||
|
||||
_secret.ShouldNotBe(previousSecret);
|
||||
_recoveryCodes.Count.ShouldBe(10);
|
||||
_recoveryCodes.ShouldNotContain(previousCodes[0]);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(4)]
|
||||
public async Task Disable2fa_WithCodeFromRegeneratedBatch_Succeeds()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/account/2fa/disable", new
|
||||
{
|
||||
password = Password,
|
||||
totpCode = _recoveryCodes[0]
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
(await IsTwoFactorEnabled()).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact, TestPriority(5)]
|
||||
public async Task Disable2fa_WithRecoveryCodeAlreadyConsumedAtLogin_IsRejected()
|
||||
{
|
||||
await EnableTwoFactor();
|
||||
|
||||
var anonymousClient = _factory.CreateClient();
|
||||
|
||||
var loginResponse = await anonymousClient.PostAsJsonAsync("/api/auth/login", new
|
||||
{
|
||||
username = Username,
|
||||
password = Password
|
||||
});
|
||||
loginResponse.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var loginBody = await loginResponse.Content.ReadFromJsonAsync<JsonElement>();
|
||||
loginBody.GetProperty("requiresTwoFactor").GetBoolean().ShouldBeTrue();
|
||||
|
||||
var twoFactorResponse = await anonymousClient.PostAsJsonAsync("/api/auth/login/2fa", new
|
||||
{
|
||||
loginToken = loginBody.GetProperty("loginToken").GetString(),
|
||||
code = _recoveryCodes[0],
|
||||
isRecoveryCode = true
|
||||
});
|
||||
twoFactorResponse.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var disableResponse = await _client.PostAsJsonAsync("/api/account/2fa/disable", new
|
||||
{
|
||||
password = Password,
|
||||
totpCode = _recoveryCodes[0]
|
||||
});
|
||||
|
||||
disableResponse.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
|
||||
(await IsTwoFactorEnabled()).ShouldBeTrue();
|
||||
|
||||
await ClearLockout();
|
||||
}
|
||||
|
||||
[Fact, TestPriority(6)]
|
||||
public async Task Disable2fa_WithUnknownCode_IsRejected()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/account/2fa/disable", new
|
||||
{
|
||||
password = Password,
|
||||
totpCode = "ZZZZ-ZZZZ"
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
|
||||
(await IsTwoFactorEnabled()).ShouldBeTrue();
|
||||
|
||||
await ClearLockout();
|
||||
}
|
||||
|
||||
[Fact, TestPriority(7)]
|
||||
public async Task Regenerate2fa_WithUnknownCode_IsRejected()
|
||||
{
|
||||
HttpResponseMessage response = await _client.PostAsJsonAsync("/api/account/2fa/regenerate", new
|
||||
{
|
||||
password = Password,
|
||||
totpCode = "ZZZZ-ZZZZ"
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
|
||||
(await IsTwoFactorEnabled()).ShouldBeTrue();
|
||||
|
||||
await ClearLockout();
|
||||
}
|
||||
|
||||
[Fact, TestPriority(8)]
|
||||
public async Task Disable2fa_WithTotpCode_StillSucceeds()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/account/2fa/disable", new
|
||||
{
|
||||
password = Password,
|
||||
totpCode = TotpTestHelper.GenerateTotpCode(_secret)
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
(await IsTwoFactorEnabled()).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact, TestPriority(9)]
|
||||
public async Task Disable2fa_WithRepeatedBadCodes_EventuallyRateLimits()
|
||||
{
|
||||
await EnableTwoFactor();
|
||||
|
||||
try
|
||||
{
|
||||
var first = await _client.PostAsJsonAsync("/api/account/2fa/disable", new
|
||||
{
|
||||
password = Password,
|
||||
totpCode = "ZZZZ-ZZZZ"
|
||||
});
|
||||
|
||||
first.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
|
||||
|
||||
var firstBody = await first.Content.ReadFromJsonAsync<JsonElement>();
|
||||
firstBody.GetProperty("retryAfterSeconds").GetInt32().ShouldBeGreaterThan(0);
|
||||
|
||||
var second = await _client.PostAsJsonAsync("/api/account/2fa/disable", new
|
||||
{
|
||||
password = Password,
|
||||
totpCode = "ZZZZ-ZZZZ"
|
||||
});
|
||||
|
||||
second.StatusCode.ShouldBe(HttpStatusCode.TooManyRequests);
|
||||
(await IsTwoFactorEnabled()).ShouldBeTrue();
|
||||
}
|
||||
finally
|
||||
{
|
||||
await ClearLockout();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact, TestPriority(10)]
|
||||
public async Task Disable2fa_AfterLockoutCleared_ResetsTheCounterOnSuccess()
|
||||
{
|
||||
await SeedFailedAttempts(3);
|
||||
|
||||
var response = await _client.PostAsJsonAsync("/api/account/2fa/disable", new
|
||||
{
|
||||
password = Password,
|
||||
totpCode = TotpTestHelper.GenerateTotpCode(_secret)
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
(await IsTwoFactorEnabled()).ShouldBeFalse();
|
||||
|
||||
using var scope = _factory.Services.CreateScope();
|
||||
var context = scope.ServiceProvider.GetRequiredService<UsersContext>();
|
||||
var user = await context.Users.FirstAsync();
|
||||
|
||||
user.FailedLoginAttempts.ShouldBe(0);
|
||||
user.LockoutEnd.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact, TestPriority(11)]
|
||||
public async Task Login_WithTwoFactorEnabled_KeepsTheFailedAttemptCounter()
|
||||
{
|
||||
await EnableTwoFactor();
|
||||
|
||||
try
|
||||
{
|
||||
await SeedFailedAttempts(3);
|
||||
|
||||
await RequestLoginToken();
|
||||
|
||||
(await ReadFailedAttempts()).ShouldBe(3);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await ClearLockout();
|
||||
await DisableTwoFactor();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact, TestPriority(12)]
|
||||
public async Task Login_WithConcurrentWrongPasswords_LocksOutTheSecondRequest()
|
||||
{
|
||||
await ClearLockout();
|
||||
|
||||
try
|
||||
{
|
||||
HttpResponseMessage[] responses = await Task.WhenAll(
|
||||
_factory.CreateClient().PostAsJsonAsync("/api/auth/login", new { username = Username, password = "WrongPassword123!" }),
|
||||
_factory.CreateClient().PostAsJsonAsync("/api/auth/login", new { username = Username, password = "WrongPassword123!" }));
|
||||
|
||||
responses.Count(response => response.StatusCode is HttpStatusCode.Unauthorized).ShouldBe(1);
|
||||
responses.Count(response => response.StatusCode is HttpStatusCode.TooManyRequests).ShouldBe(1);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await ClearLockout();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact, TestPriority(20)]
|
||||
public async Task Regenerate2fa_WhenIssuedConcurrently_AppliesOnce()
|
||||
{
|
||||
await EnableTwoFactor();
|
||||
|
||||
string sharedCode = _recoveryCodes[0];
|
||||
|
||||
const int attempts = 8;
|
||||
HttpResponseMessage[] responses = await Task.WhenAll(
|
||||
Enumerable.Range(0, attempts).Select(_ =>
|
||||
_client.PostAsJsonAsync("/api/account/2fa/regenerate", new { password = Password, totpCode = sharedCode })));
|
||||
|
||||
// Losers are rejected as a spent code or as a lockout, depending on which increment lands first
|
||||
responses.Count(response => response.StatusCode is HttpStatusCode.OK).ShouldBe(1);
|
||||
responses.Count(response => response.StatusCode is not HttpStatusCode.OK).ShouldBe(attempts - 1);
|
||||
(await CountRecoveryCodes()).ShouldBe(10);
|
||||
|
||||
HttpResponseMessage accepted = responses.First(response => response.StatusCode is HttpStatusCode.OK);
|
||||
JsonElement body = await accepted.Content.ReadFromJsonAsync<JsonElement>();
|
||||
_secret = body.GetProperty("secret").GetString()!;
|
||||
_recoveryCodes = ReadRecoveryCodes(body);
|
||||
|
||||
await ClearLockout();
|
||||
}
|
||||
|
||||
[Fact, TestPriority(21)]
|
||||
public async Task Login2fa_WithSameRecoveryCodeConcurrently_SucceedsOnce()
|
||||
{
|
||||
string sharedCode = _recoveryCodes[0];
|
||||
|
||||
string firstToken = await RequestLoginToken();
|
||||
string secondToken = await RequestLoginToken();
|
||||
|
||||
HttpResponseMessage[] responses = await Task.WhenAll(
|
||||
_factory.CreateClient().PostAsJsonAsync("/api/auth/login/2fa", new { loginToken = firstToken, code = sharedCode, isRecoveryCode = true }),
|
||||
_factory.CreateClient().PostAsJsonAsync("/api/auth/login/2fa", new { loginToken = secondToken, code = sharedCode, isRecoveryCode = true }));
|
||||
|
||||
responses.Count(response => response.StatusCode is HttpStatusCode.OK).ShouldBe(1);
|
||||
|
||||
await ClearLockout();
|
||||
}
|
||||
|
||||
[Fact, TestPriority(22)]
|
||||
public async Task Disable2fa_WithConcurrentBadCodes_LocksOutTheSecondRequest()
|
||||
{
|
||||
if (!await IsTwoFactorEnabled())
|
||||
{
|
||||
await EnableTwoFactor();
|
||||
}
|
||||
|
||||
await ClearLockout();
|
||||
|
||||
try
|
||||
{
|
||||
HttpResponseMessage[] responses = await Task.WhenAll(
|
||||
_client.PostAsJsonAsync("/api/account/2fa/disable", new { password = Password, totpCode = "ZZZZ-ZZZZ" }),
|
||||
_client.PostAsJsonAsync("/api/account/2fa/disable", new { password = Password, totpCode = "ZZZZ-ZZZZ" }));
|
||||
|
||||
responses.Count(response => response.StatusCode is HttpStatusCode.BadRequest).ShouldBe(1);
|
||||
responses.Count(response => response.StatusCode is HttpStatusCode.TooManyRequests).ShouldBe(1);
|
||||
(await IsTwoFactorEnabled()).ShouldBeTrue();
|
||||
}
|
||||
finally
|
||||
{
|
||||
await ClearLockout();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string> RequestLoginToken()
|
||||
{
|
||||
HttpResponseMessage response = await _factory.CreateClient().PostAsJsonAsync("/api/auth/login", new
|
||||
{
|
||||
username = Username,
|
||||
password = Password
|
||||
});
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
JsonElement body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
body.GetProperty("requiresTwoFactor").GetBoolean().ShouldBeTrue();
|
||||
|
||||
return body.GetProperty("loginToken").GetString()!;
|
||||
}
|
||||
|
||||
private async Task<int> CountRecoveryCodes()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
UsersContext context = scope.ServiceProvider.GetRequiredService<UsersContext>();
|
||||
|
||||
return await context.RecoveryCodes.CountAsync();
|
||||
}
|
||||
|
||||
private async Task SeedFailedAttempts(int attempts)
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
UsersContext context = scope.ServiceProvider.GetRequiredService<UsersContext>();
|
||||
User user = await context.Users.FirstAsync();
|
||||
|
||||
user.FailedLoginAttempts = attempts;
|
||||
user.LockoutEnd = null;
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private async Task<int> ReadFailedAttempts()
|
||||
{
|
||||
using IServiceScope scope = _factory.Services.CreateScope();
|
||||
UsersContext context = scope.ServiceProvider.GetRequiredService<UsersContext>();
|
||||
|
||||
return (await context.Users.AsNoTracking().FirstAsync()).FailedLoginAttempts;
|
||||
}
|
||||
|
||||
private async Task ClearLockout()
|
||||
{
|
||||
using var scope = _factory.Services.CreateScope();
|
||||
var context = scope.ServiceProvider.GetRequiredService<UsersContext>();
|
||||
var user = await context.Users.FirstAsync();
|
||||
|
||||
user.FailedLoginAttempts = 0;
|
||||
user.LockoutEnd = null;
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private async Task EnableTwoFactor()
|
||||
{
|
||||
var enableResponse = await _client.PostAsJsonAsync("/api/account/2fa/enable", new
|
||||
{
|
||||
password = Password
|
||||
});
|
||||
enableResponse.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var body = await enableResponse.Content.ReadFromJsonAsync<JsonElement>();
|
||||
_secret = body.GetProperty("secret").GetString()!;
|
||||
_recoveryCodes = ReadRecoveryCodes(body);
|
||||
_recoveryCodes.Count.ShouldBe(10);
|
||||
|
||||
var verifyResponse = await _client.PostAsJsonAsync("/api/account/2fa/enable/verify", new
|
||||
{
|
||||
code = TotpTestHelper.GenerateTotpCode(_secret)
|
||||
});
|
||||
verifyResponse.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
}
|
||||
|
||||
private async Task DisableTwoFactor()
|
||||
{
|
||||
HttpResponseMessage response = await _client.PostAsJsonAsync("/api/account/2fa/disable", new
|
||||
{
|
||||
password = Password,
|
||||
totpCode = TotpTestHelper.GenerateTotpCode(_secret)
|
||||
});
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
}
|
||||
|
||||
private async Task<bool> IsTwoFactorEnabled()
|
||||
{
|
||||
var response = await _client.GetAsync("/api/account");
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
return body.GetProperty("twoFactorEnabled").GetBoolean();
|
||||
}
|
||||
|
||||
private static List<string> ReadRecoveryCodes(JsonElement body)
|
||||
{
|
||||
return body.GetProperty("recoveryCodes")
|
||||
.EnumerateArray()
|
||||
.Select(code => code.GetString()!)
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Auth;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Shouldly;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Features.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for PUT /api/account/username.
|
||||
/// </summary>
|
||||
[Collection("Auth Integration Tests")]
|
||||
[TestCaseOrderer("Cleanuparr.Api.Tests.PriorityOrderer", "Cleanuparr.Api.Tests")]
|
||||
public class AccountControllerUsernameTests : IClassFixture<CustomWebApplicationFactory>
|
||||
{
|
||||
private const string Username = "renameadmin";
|
||||
private const string NewUsername = "renamedadmin";
|
||||
private const string Password = "RenamePassword123!";
|
||||
|
||||
private readonly CustomWebApplicationFactory _factory;
|
||||
private readonly HttpClient _client;
|
||||
|
||||
private static string? _accessToken;
|
||||
|
||||
public AccountControllerUsernameTests(CustomWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
_client = factory.CreateClient();
|
||||
|
||||
if (_accessToken is not null)
|
||||
{
|
||||
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _accessToken);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact, TestPriority(0)]
|
||||
public async Task Setup_CreateAccountAndLogin()
|
||||
{
|
||||
var createResponse = await _client.PostAsJsonAsync("/api/auth/setup/account", new
|
||||
{
|
||||
username = Username,
|
||||
password = Password
|
||||
});
|
||||
createResponse.StatusCode.ShouldBe(HttpStatusCode.Created);
|
||||
|
||||
var completeResponse = await _client.PostAsJsonAsync("/api/auth/setup/complete", new { });
|
||||
completeResponse.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var loginResponse = await _client.PostAsJsonAsync("/api/auth/login", new
|
||||
{
|
||||
username = Username,
|
||||
password = Password
|
||||
});
|
||||
loginResponse.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var body = await loginResponse.Content.ReadFromJsonAsync<JsonElement>();
|
||||
_accessToken = body.GetProperty("tokens").GetProperty("accessToken").GetString();
|
||||
_accessToken.ShouldNotBeNullOrEmpty();
|
||||
|
||||
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _accessToken);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(1)]
|
||||
public async Task ChangeUsername_WhenUnauthenticated_ReturnsUnauthorized()
|
||||
{
|
||||
var response = await _factory.CreateClient().PutAsJsonAsync("/api/account/username", new
|
||||
{
|
||||
currentPassword = Password,
|
||||
newUsername = NewUsername
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(2)]
|
||||
public async Task ChangeUsername_WithWrongPassword_ReturnsBadRequestAndKeepsUsername()
|
||||
{
|
||||
var response = await _client.PutAsJsonAsync("/api/account/username", new
|
||||
{
|
||||
currentPassword = "WrongPassword123!",
|
||||
newUsername = NewUsername
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
|
||||
(await GetStoredUsername()).ShouldBe(Username);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(3)]
|
||||
public async Task ChangeUsername_TooShortAfterTrimming_ReturnsBadRequest()
|
||||
{
|
||||
var response = await _client.PutAsJsonAsync("/api/account/username", new
|
||||
{
|
||||
currentPassword = Password,
|
||||
newUsername = " ab "
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
|
||||
(await GetStoredUsername()).ShouldBe(Username);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(4)]
|
||||
public async Task ChangeUsername_WithCurrentUsername_ReturnsBadRequest()
|
||||
{
|
||||
var response = await _client.PutAsJsonAsync("/api/account/username", new
|
||||
{
|
||||
currentPassword = Password,
|
||||
newUsername = Username
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(5)]
|
||||
public async Task ChangeUsername_ShorterThanThreeCharacters_IsRejectedByModelValidation()
|
||||
{
|
||||
var response = await _client.PutAsJsonAsync("/api/account/username", new
|
||||
{
|
||||
currentPassword = Password,
|
||||
newUsername = "ab"
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
|
||||
(await GetStoredUsername()).ShouldBe(Username);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(6)]
|
||||
public async Task ChangeUsername_WithValidPassword_TrimsStoresAndRevokesRefreshTokens()
|
||||
{
|
||||
var response = await _client.PutAsJsonAsync("/api/account/username", new
|
||||
{
|
||||
currentPassword = Password,
|
||||
newUsername = $" {NewUsername} "
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
(await GetStoredUsername()).ShouldBe(NewUsername);
|
||||
|
||||
using var scope = _factory.Services.CreateScope();
|
||||
var context = scope.ServiceProvider.GetRequiredService<UsersContext>();
|
||||
List<RefreshToken> tokens = await context.RefreshTokens.ToListAsync();
|
||||
|
||||
tokens.ShouldNotBeEmpty();
|
||||
tokens.ShouldAllBe(t => t.RevokedAt != null);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(7)]
|
||||
public async Task Login_WithOldUsername_IsRejected()
|
||||
{
|
||||
var response = await _factory.CreateClient().PostAsJsonAsync("/api/auth/login", new
|
||||
{
|
||||
username = Username,
|
||||
password = Password
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(8)]
|
||||
public async Task Login_WithNewUsername_Succeeds()
|
||||
{
|
||||
await ClearLockout();
|
||||
|
||||
var response = await _factory.CreateClient().PostAsJsonAsync("/api/auth/login", new
|
||||
{
|
||||
username = NewUsername,
|
||||
password = Password
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
}
|
||||
|
||||
private async Task<string> GetStoredUsername()
|
||||
{
|
||||
using var scope = _factory.Services.CreateScope();
|
||||
var context = scope.ServiceProvider.GetRequiredService<UsersContext>();
|
||||
User user = await context.Users.AsNoTracking().FirstAsync();
|
||||
|
||||
return user.Username;
|
||||
}
|
||||
|
||||
private async Task ClearLockout()
|
||||
{
|
||||
using var scope = _factory.Services.CreateScope();
|
||||
var context = scope.ServiceProvider.GetRequiredService<UsersContext>();
|
||||
User user = await context.Users.FirstAsync();
|
||||
|
||||
user.FailedLoginAttempts = 0;
|
||||
user.LockoutEnd = null;
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
{
|
||||
@@ -233,55 +244,31 @@ public class AuthControllerTests : IClassFixture<CustomWebApplicationFactory>
|
||||
body.GetProperty("setupCompleted").GetBoolean().ShouldBeTrue();
|
||||
}
|
||||
|
||||
#region TOTP helpers
|
||||
[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();
|
||||
}
|
||||
|
||||
private static string _totpSecret = "";
|
||||
|
||||
private static string GenerateTotpCode(string base32Secret)
|
||||
{
|
||||
var key = Base32Decode(base32Secret);
|
||||
var timestep = (long)(DateTime.UtcNow - DateTime.UnixEpoch).TotalSeconds / 30;
|
||||
var timestepBytes = BitConverter.GetBytes(timestep);
|
||||
|
||||
if (BitConverter.IsLittleEndian)
|
||||
Array.Reverse(timestepBytes);
|
||||
|
||||
using var hmac = new System.Security.Cryptography.HMACSHA1(key);
|
||||
var hash = hmac.ComputeHash(timestepBytes);
|
||||
|
||||
var offset = hash[^1] & 0x0F;
|
||||
var binaryCode =
|
||||
((hash[offset] & 0x7F) << 24) |
|
||||
((hash[offset + 1] & 0xFF) << 16) |
|
||||
((hash[offset + 2] & 0xFF) << 8) |
|
||||
(hash[offset + 3] & 0xFF);
|
||||
|
||||
return (binaryCode % 1_000_000).ToString("D6");
|
||||
}
|
||||
|
||||
private static byte[] Base32Decode(string base32)
|
||||
{
|
||||
const string alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
||||
base32 = base32.ToUpperInvariant().TrimEnd('=');
|
||||
|
||||
var bits = new List<byte>();
|
||||
foreach (var c in base32)
|
||||
{
|
||||
var val = alphabet.IndexOf(c);
|
||||
if (val < 0) continue;
|
||||
for (var i = 4; i >= 0; i--)
|
||||
bits.Add((byte)((val >> i) & 1));
|
||||
}
|
||||
|
||||
var bytes = new byte[bits.Count / 8];
|
||||
for (var i = 0; i < bytes.Length; i++)
|
||||
{
|
||||
for (var j = 0; j < 8; j++)
|
||||
bytes[i] = (byte)((bytes[i] << 1) | bits[i * 8 + j]);
|
||||
}
|
||||
|
||||
return bytes;
|
||||
}
|
||||
|
||||
#endregion
|
||||
private static string GenerateTotpCode(string base32Secret) => TotpTestHelper.GenerateTotpCode(base32Secret);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
using Cleanuparr.Api.Features.Auth;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Auth;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Shouldly;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Features.Auth;
|
||||
|
||||
public sealed class LoginAttemptTrackerTests : IDisposable
|
||||
{
|
||||
private readonly SqliteConnection _connection;
|
||||
private readonly UsersContext _usersContext;
|
||||
private readonly LoginAttemptTracker _sut;
|
||||
private readonly Guid _userId = Guid.NewGuid();
|
||||
|
||||
public LoginAttemptTrackerTests()
|
||||
{
|
||||
_connection = new SqliteConnection("DataSource=:memory:");
|
||||
_connection.Open();
|
||||
|
||||
DbContextOptions<UsersContext> options = new DbContextOptionsBuilder<UsersContext>()
|
||||
.UseSqlite(_connection)
|
||||
.Options;
|
||||
|
||||
_usersContext = new UsersContext(options);
|
||||
_usersContext.Database.EnsureCreated();
|
||||
|
||||
_usersContext.Users.Add(new User
|
||||
{
|
||||
Id = _userId,
|
||||
Username = "admin",
|
||||
PasswordHash = "hash",
|
||||
TotpSecret = string.Empty,
|
||||
ApiKey = "key",
|
||||
SetupCompleted = true
|
||||
});
|
||||
_usersContext.SaveChanges();
|
||||
|
||||
_sut = new LoginAttemptTracker(_usersContext, NullLogger<LoginAttemptTracker>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IncrementFailedAttempts_GrowsTheLockoutWindowWithEachAttempt()
|
||||
{
|
||||
(await _sut.IncrementFailedAttempts(_userId)).ShouldBe(2);
|
||||
(await _sut.IncrementFailedAttempts(_userId)).ShouldBe(4);
|
||||
(await _sut.IncrementFailedAttempts(_userId)).ShouldBe(6);
|
||||
|
||||
User user = await _usersContext.Users.FirstAsync(u => u.Id == _userId);
|
||||
user.FailedLoginAttempts.ShouldBe(3);
|
||||
user.LockoutEnd.ShouldNotBeNull();
|
||||
LoginAttemptTracker.GetLockoutSecondsRemaining(user).ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IncrementFailedAttempts_StopsGrowingAtTheMaximumWindow()
|
||||
{
|
||||
User seeded = await _usersContext.Users.FirstAsync(u => u.Id == _userId);
|
||||
seeded.FailedLoginAttempts = 148;
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
(await _sut.IncrementFailedAttempts(_userId)).ShouldBe(298);
|
||||
(await _sut.IncrementFailedAttempts(_userId)).ShouldBe(300);
|
||||
(await _sut.IncrementFailedAttempts(_userId)).ShouldBe(300);
|
||||
|
||||
User user = await _usersContext.Users.FirstAsync(u => u.Id == _userId);
|
||||
user.FailedLoginAttempts.ShouldBe(151);
|
||||
LoginAttemptTracker.GetLockoutSecondsRemaining(user)!.Value.ShouldBeLessThanOrEqualTo(300);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResetFailedAttempts_ClearsTheCounterAndLockout()
|
||||
{
|
||||
await _sut.IncrementFailedAttempts(_userId);
|
||||
await _sut.IncrementFailedAttempts(_userId);
|
||||
|
||||
await _sut.ResetFailedAttempts(_userId);
|
||||
|
||||
User user = await _usersContext.Users.FirstAsync(u => u.Id == _userId);
|
||||
user.FailedLoginAttempts.ShouldBe(0);
|
||||
user.LockoutEnd.ShouldBeNull();
|
||||
LoginAttemptTracker.GetLockoutSecondsRemaining(user).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLockoutSecondsRemaining_WithoutLockout_ReturnsNull()
|
||||
{
|
||||
User user = CreateUser(lockoutEnd: null);
|
||||
|
||||
LoginAttemptTracker.GetLockoutSecondsRemaining(user).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLockoutSecondsRemaining_WithExpiredLockout_ReturnsNull()
|
||||
{
|
||||
User user = CreateUser(DateTimeOffset.UtcNow.AddSeconds(-1));
|
||||
|
||||
LoginAttemptTracker.GetLockoutSecondsRemaining(user).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLockoutSecondsRemaining_WithActiveLockout_RoundsUpToWholeSeconds()
|
||||
{
|
||||
User user = CreateUser(DateTimeOffset.UtcNow.AddSeconds(9.9));
|
||||
|
||||
LoginAttemptTracker.GetLockoutSecondsRemaining(user).ShouldBe(10);
|
||||
}
|
||||
|
||||
private static User CreateUser(DateTimeOffset? lockoutEnd)
|
||||
{
|
||||
return new User
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Username = "admin",
|
||||
PasswordHash = "hash",
|
||||
TotpSecret = string.Empty,
|
||||
ApiKey = "key",
|
||||
LockoutEnd = lockoutEnd
|
||||
};
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_usersContext.Dispose();
|
||||
_connection.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using Cleanuparr.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
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()
|
||||
{
|
||||
// Set lockout state directly in the database to avoid timing sensitivity
|
||||
using (var scope = _factory.Services.CreateScope())
|
||||
{
|
||||
var context = scope.ServiceProvider.GetRequiredService<UsersContext>();
|
||||
var user = await context.Users.FirstAsync();
|
||||
user.FailedLoginAttempts = 5;
|
||||
user.LockoutEnd = DateTime.UtcNow.AddMinutes(5);
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
_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);
|
||||
|
||||
// Reset lockout for subsequent tests
|
||||
using (var scope = _factory.Services.CreateScope())
|
||||
{
|
||||
var context = scope.ServiceProvider.GetRequiredService<UsersContext>();
|
||||
var user = await context.Users.FirstAsync();
|
||||
user.FailedLoginAttempts = 0;
|
||||
user.LockoutEnd = null;
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[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,630 @@
|
||||
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);
|
||||
response.Content.Headers.ContentType!.MediaType.ShouldBe("application/problem+json");
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
body.GetProperty("detail").GetString()!.ShouldContain("OIDC is not enabled");
|
||||
body.GetProperty("traceId").GetString().ShouldNotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[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,62 @@
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Features.Auth;
|
||||
|
||||
internal static class TotpTestHelper
|
||||
{
|
||||
public static string GenerateTotpCode(string base32Secret)
|
||||
{
|
||||
var key = Base32Decode(base32Secret);
|
||||
var timestep = (long)(DateTime.UtcNow - DateTime.UnixEpoch).TotalSeconds / 30;
|
||||
var timestepBytes = BitConverter.GetBytes(timestep);
|
||||
|
||||
if (BitConverter.IsLittleEndian)
|
||||
{
|
||||
Array.Reverse(timestepBytes);
|
||||
}
|
||||
|
||||
using var hmac = new HMACSHA1(key);
|
||||
var hash = hmac.ComputeHash(timestepBytes);
|
||||
|
||||
var offset = hash[^1] & 0x0F;
|
||||
var binaryCode =
|
||||
((hash[offset] & 0x7F) << 24) |
|
||||
((hash[offset + 1] & 0xFF) << 16) |
|
||||
((hash[offset + 2] & 0xFF) << 8) |
|
||||
(hash[offset + 3] & 0xFF);
|
||||
|
||||
return (binaryCode % 1_000_000).ToString("D6");
|
||||
}
|
||||
|
||||
private static byte[] Base32Decode(string base32)
|
||||
{
|
||||
const string alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
||||
base32 = base32.ToUpperInvariant().TrimEnd('=');
|
||||
|
||||
var bits = new List<byte>();
|
||||
foreach (var c in base32)
|
||||
{
|
||||
var val = alphabet.IndexOf(c);
|
||||
if (val < 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
for (var i = 4; i >= 0; i--)
|
||||
{
|
||||
bits.Add((byte)((val >> i) & 1));
|
||||
}
|
||||
}
|
||||
|
||||
var bytes = new byte[bits.Count / 8];
|
||||
for (var i = 0; i < bytes.Length; i++)
|
||||
{
|
||||
for (var j = 0; j < 8; j++)
|
||||
{
|
||||
bytes[i] = (byte)((bytes[i] << 1) | bits[i * 8 + j]);
|
||||
}
|
||||
}
|
||||
|
||||
return bytes;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+174
@@ -0,0 +1,174 @@
|
||||
using Cleanuparr.Api.Features.BlacklistSync.Contracts.Requests;
|
||||
using Cleanuparr.Api.Features.BlacklistSync.Controllers;
|
||||
using Cleanuparr.Api.Tests.TestHelpers;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Services.Interfaces;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration.BlacklistSync;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Features.BlacklistSync;
|
||||
|
||||
public class BlacklistSyncConfigControllerTests : IDisposable
|
||||
{
|
||||
private readonly DataContext _dataContext;
|
||||
private readonly IJobManagementService _jobManagementService;
|
||||
private readonly BlacklistSyncConfigController _controller;
|
||||
|
||||
public BlacklistSyncConfigControllerTests()
|
||||
{
|
||||
_dataContext = ConfigControllerTestDataFactory.CreateDataContext();
|
||||
var logger = Substitute.For<ILogger<BlacklistSyncConfigController>>();
|
||||
_jobManagementService = Substitute.For<IJobManagementService>();
|
||||
_controller = new BlacklistSyncConfigController(logger, _dataContext, _jobManagementService);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_dataContext.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetBlacklistSyncConfig_ReturnsExistingConfig()
|
||||
{
|
||||
// Act
|
||||
var result = await _controller.GetBlacklistSyncConfig();
|
||||
|
||||
// Assert
|
||||
var ok = result.ShouldBeOfType<OkObjectResult>();
|
||||
ok.Value.ShouldBeOfType<BlacklistSyncConfig>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateBlacklistSyncConfig_BecameEnabled_StartsAndTriggersJob()
|
||||
{
|
||||
// Arrange — start disabled, enable with URL path
|
||||
var request = new UpdateBlacklistSyncConfigRequest
|
||||
{
|
||||
Enabled = true,
|
||||
BlacklistPath = "https://example.com/blacklist.txt",
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _controller.UpdateBlacklistSyncConfig(request);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeOfType<OkObjectResult>();
|
||||
await _jobManagementService.Received(1).StartJob(JobType.BlacklistSynchronizer, null, Arg.Any<string>());
|
||||
await _jobManagementService.Received(1).TriggerJobOnce(JobType.BlacklistSynchronizer);
|
||||
await _jobManagementService.DidNotReceive().StopJob(Arg.Any<JobType>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateBlacklistSyncConfig_BecameDisabled_StopsJob()
|
||||
{
|
||||
// Arrange — pre-enable so the toggle to disabled hits the stop path
|
||||
var existing = await _dataContext.BlacklistSyncConfigs.FirstAsync();
|
||||
existing.Enabled = true;
|
||||
existing.BlacklistPath = "https://example.com/blacklist.txt";
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
var request = new UpdateBlacklistSyncConfigRequest
|
||||
{
|
||||
Enabled = false,
|
||||
BlacklistPath = existing.BlacklistPath,
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _controller.UpdateBlacklistSyncConfig(request);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeOfType<OkObjectResult>();
|
||||
await _jobManagementService.Received(1).StopJob(JobType.BlacklistSynchronizer);
|
||||
await _jobManagementService.DidNotReceive().StartJob(Arg.Any<JobType>(), Arg.Any<Cleanuparr.Infrastructure.Models.JobSchedule?>(), Arg.Any<string?>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateBlacklistSyncConfig_StaysEnabledAndPathChanged_TriggersOnce()
|
||||
{
|
||||
// Arrange — pre-enable
|
||||
var existing = await _dataContext.BlacklistSyncConfigs.FirstAsync();
|
||||
existing.Enabled = true;
|
||||
existing.BlacklistPath = "https://example.com/old.txt";
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
var request = new UpdateBlacklistSyncConfigRequest
|
||||
{
|
||||
Enabled = true,
|
||||
BlacklistPath = "https://example.com/new.txt",
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _controller.UpdateBlacklistSyncConfig(request);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeOfType<OkObjectResult>();
|
||||
await _jobManagementService.Received(1).TriggerJobOnce(JobType.BlacklistSynchronizer);
|
||||
await _jobManagementService.DidNotReceive().StartJob(Arg.Any<JobType>(), Arg.Any<Cleanuparr.Infrastructure.Models.JobSchedule?>(), Arg.Any<string?>());
|
||||
await _jobManagementService.DidNotReceive().StopJob(Arg.Any<JobType>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateBlacklistSyncConfig_StaysEnabledNoPathChange_NoJobOps()
|
||||
{
|
||||
// Arrange — pre-enable, then resubmit identical values
|
||||
var existing = await _dataContext.BlacklistSyncConfigs.FirstAsync();
|
||||
existing.Enabled = true;
|
||||
existing.BlacklistPath = "https://example.com/list.txt";
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
var request = new UpdateBlacklistSyncConfigRequest
|
||||
{
|
||||
Enabled = true,
|
||||
BlacklistPath = existing.BlacklistPath,
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _controller.UpdateBlacklistSyncConfig(request);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeOfType<OkObjectResult>();
|
||||
await _jobManagementService.DidNotReceive().StartJob(Arg.Any<JobType>(), Arg.Any<Cleanuparr.Infrastructure.Models.JobSchedule?>(), Arg.Any<string?>());
|
||||
await _jobManagementService.DidNotReceive().StopJob(Arg.Any<JobType>());
|
||||
await _jobManagementService.DidNotReceive().TriggerJobOnce(Arg.Any<JobType>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateBlacklistSyncConfig_EnabledWithMissingPath_Throws()
|
||||
{
|
||||
// Arrange
|
||||
var request = new UpdateBlacklistSyncConfigRequest
|
||||
{
|
||||
Enabled = true,
|
||||
BlacklistPath = null,
|
||||
};
|
||||
|
||||
// Act / Assert — Validate throws and is rethrown
|
||||
await Should.ThrowAsync<Exception>(() => _controller.UpdateBlacklistSyncConfig(request));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateBlacklistSyncConfig_PersistsChangesToDatabase()
|
||||
{
|
||||
// Arrange
|
||||
var request = new UpdateBlacklistSyncConfigRequest
|
||||
{
|
||||
Enabled = true,
|
||||
BlacklistPath = "https://example.com/list.txt",
|
||||
};
|
||||
|
||||
// Act
|
||||
await _controller.UpdateBlacklistSyncConfig(request);
|
||||
|
||||
// Assert
|
||||
var saved = await _dataContext.BlacklistSyncConfigs.AsNoTracking().FirstAsync();
|
||||
saved.Enabled.ShouldBeTrue();
|
||||
saved.BlacklistPath.ShouldBe("https://example.com/list.txt");
|
||||
}
|
||||
}
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
using Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests;
|
||||
using Cleanuparr.Api.Features.DownloadCleaner.Contracts.Responses;
|
||||
using Cleanuparr.Api.Features.DownloadCleaner.Controllers;
|
||||
using Cleanuparr.Api.Tests.Features.DownloadCleaner.TestHelpers;
|
||||
using Cleanuparr.Api.Tests.TestHelpers;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using Shouldly;
|
||||
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Features.DownloadCleaner;
|
||||
|
||||
public class DeadTorrentConfigControllerTests : IDisposable
|
||||
{
|
||||
private readonly DataContext _dataContext;
|
||||
private readonly DeadTorrentConfigController _controller;
|
||||
|
||||
public DeadTorrentConfigControllerTests()
|
||||
{
|
||||
_dataContext = SeedingRulesTestDataFactory.CreateDataContext();
|
||||
var logger = Substitute.For<ILogger<DeadTorrentConfigController>>();
|
||||
_controller = new DeadTorrentConfigController(logger, _dataContext);
|
||||
ControllerTestContext.Attach(_controller);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_dataContext.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
private static DeadTorrentConfigRequest ValidRequest(
|
||||
bool enabled = true,
|
||||
string targetCategory = "cleanuparr-dead",
|
||||
bool useTag = false,
|
||||
ushort maxStrikes = 3,
|
||||
List<string>? categories = null)
|
||||
=> new()
|
||||
{
|
||||
Enabled = enabled,
|
||||
TargetCategory = targetCategory,
|
||||
UseTag = useTag,
|
||||
MaxStrikes = maxStrikes,
|
||||
Categories = categories ?? ["movies"],
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task Update_ValidRequest_PersistsConfig()
|
||||
{
|
||||
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
|
||||
|
||||
var result = await _controller.UpdateDeadTorrentConfig(client.Id, ValidRequest(maxStrikes: 5, categories: ["movies", "tv"]));
|
||||
|
||||
result.ShouldBeOfType<OkObjectResult>();
|
||||
var saved = await _dataContext.DeadTorrentConfigs.AsNoTracking().SingleAsync(d => d.DownloadClientConfigId == client.Id);
|
||||
saved.Enabled.ShouldBeTrue();
|
||||
saved.MaxStrikes.ShouldBe((ushort)5);
|
||||
saved.Categories.ShouldBe(new List<string> { "movies", "tv" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Update_ThenGet_RoundTrips()
|
||||
{
|
||||
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
|
||||
await _controller.UpdateDeadTorrentConfig(client.Id, ValidRequest(useTag: true, maxStrikes: 4));
|
||||
|
||||
var result = await _controller.GetDeadTorrentConfig(client.Id);
|
||||
|
||||
var ok = result.ShouldBeOfType<OkObjectResult>();
|
||||
var config = ok.Value.ShouldBeOfType<DeadTorrentConfigResponse>();
|
||||
config.UseTag.ShouldBeTrue();
|
||||
config.MaxStrikes.ShouldBe((ushort)4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Update_StrikesBelowMinimum_ThrowsValidationException()
|
||||
{
|
||||
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
|
||||
|
||||
await Should.ThrowAsync<ValidationException>(() => _controller.UpdateDeadTorrentConfig(client.Id, ValidRequest(maxStrikes: 2)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Update_EnabledForRTorrent_ReturnsBadRequest()
|
||||
{
|
||||
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext, DownloadClientTypeName.rTorrent, "Test rTorrent");
|
||||
|
||||
var result = await _controller.UpdateDeadTorrentConfig(client.Id, ValidRequest());
|
||||
|
||||
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(StatusCodes.Status400BadRequest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Update_NonExistentClient_ReturnsNotFound()
|
||||
{
|
||||
var result = await _controller.UpdateDeadTorrentConfig(Guid.NewGuid(), ValidRequest());
|
||||
|
||||
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(StatusCodes.Status404NotFound);
|
||||
}
|
||||
}
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
using Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests;
|
||||
using Cleanuparr.Api.Features.DownloadCleaner.Controllers;
|
||||
using Cleanuparr.Api.Tests.TestHelpers;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Services.Interfaces;
|
||||
using Cleanuparr.Persistence;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Features.DownloadCleaner;
|
||||
|
||||
public class DownloadCleanerConfigControllerTests : IDisposable
|
||||
{
|
||||
private readonly DataContext _dataContext;
|
||||
private readonly IJobManagementService _jobManagementService;
|
||||
private readonly DownloadCleanerConfigController _controller;
|
||||
|
||||
public DownloadCleanerConfigControllerTests()
|
||||
{
|
||||
_dataContext = ConfigControllerTestDataFactory.CreateDataContext();
|
||||
var logger = Substitute.For<ILogger<DownloadCleanerConfigController>>();
|
||||
_jobManagementService = Substitute.For<IJobManagementService>();
|
||||
_controller = new DownloadCleanerConfigController(logger, _dataContext, _jobManagementService);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_dataContext.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetDownloadCleanerConfig_NoClients_ReturnsConfigWithEmptyClientsList()
|
||||
{
|
||||
// Act
|
||||
var result = await _controller.GetDownloadCleanerConfig();
|
||||
|
||||
// Assert
|
||||
var ok = result.ShouldBeOfType<OkObjectResult>();
|
||||
ok.Value.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateDownloadCleanerConfig_Enabled_StartsJob()
|
||||
{
|
||||
// Arrange
|
||||
var request = new UpdateDownloadCleanerConfigRequest
|
||||
{
|
||||
Enabled = true,
|
||||
CronExpression = "0 0 * * * ?",
|
||||
IgnoredDownloads = new List<string>(),
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _controller.UpdateDownloadCleanerConfig(request);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeOfType<OkObjectResult>();
|
||||
await _jobManagementService.Received(1).StartJob(JobType.DownloadCleaner, null, "0 0 * * * ?");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateDownloadCleanerConfig_Disabled_StopsJob()
|
||||
{
|
||||
// Arrange — pre-enable
|
||||
var existing = await _dataContext.DownloadCleanerConfigs.FirstAsync();
|
||||
existing.Enabled = true;
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
var request = new UpdateDownloadCleanerConfigRequest
|
||||
{
|
||||
Enabled = false,
|
||||
CronExpression = "0 0 * * * ?",
|
||||
IgnoredDownloads = new List<string>(),
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _controller.UpdateDownloadCleanerConfig(request);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeOfType<OkObjectResult>();
|
||||
await _jobManagementService.Received(1).StopJob(JobType.DownloadCleaner);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateDownloadCleanerConfig_InvalidCron_PropagatesValidationException()
|
||||
{
|
||||
// Arrange — controller's catch only handles System.ComponentModel.DataAnnotations.ValidationException;
|
||||
// CronValidationHelper throws Cleanuparr.Domain.Exceptions.ValidationException which propagates.
|
||||
var request = new UpdateDownloadCleanerConfigRequest
|
||||
{
|
||||
Enabled = true,
|
||||
CronExpression = "not-a-cron",
|
||||
IgnoredDownloads = new List<string>(),
|
||||
};
|
||||
|
||||
// Act / Assert
|
||||
await Should.ThrowAsync<Cleanuparr.Domain.Exceptions.ValidationException>(
|
||||
() => _controller.UpdateDownloadCleanerConfig(request));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateDownloadCleanerConfig_PersistsChanges()
|
||||
{
|
||||
// Arrange
|
||||
var request = new UpdateDownloadCleanerConfigRequest
|
||||
{
|
||||
Enabled = true,
|
||||
CronExpression = "0 0/15 * * * ?",
|
||||
UseAdvancedScheduling = true,
|
||||
IgnoredDownloads = new List<string> { "skip-me" },
|
||||
};
|
||||
|
||||
// Act
|
||||
await _controller.UpdateDownloadCleanerConfig(request);
|
||||
|
||||
// Assert
|
||||
var saved = await _dataContext.DownloadCleanerConfigs.AsNoTracking().FirstAsync();
|
||||
saved.Enabled.ShouldBeTrue();
|
||||
saved.CronExpression.ShouldBe("0 0/15 * * * ?");
|
||||
saved.UseAdvancedScheduling.ShouldBeTrue();
|
||||
saved.IgnoredDownloads.ShouldContain("skip-me");
|
||||
}
|
||||
}
|
||||
+597
@@ -0,0 +1,597 @@
|
||||
using Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests;
|
||||
using Cleanuparr.Api.Features.DownloadCleaner.Contracts.Responses;
|
||||
using Cleanuparr.Api.Features.DownloadCleaner.Controllers;
|
||||
using Cleanuparr.Api.Tests.Features.DownloadCleaner.TestHelpers;
|
||||
using Cleanuparr.Api.Tests.TestHelpers;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Domain.Exceptions;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using Shouldly;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Features.DownloadCleaner;
|
||||
|
||||
public class SeedingRulesControllerTests : IDisposable
|
||||
{
|
||||
private readonly DataContext _dataContext;
|
||||
private readonly SeedingRulesController _controller;
|
||||
|
||||
public SeedingRulesControllerTests()
|
||||
{
|
||||
_dataContext = SeedingRulesTestDataFactory.CreateDataContext();
|
||||
var logger = Substitute.For<ILogger<SeedingRulesController>>();
|
||||
_controller = new SeedingRulesController(logger, _dataContext);
|
||||
ControllerTestContext.Attach(_controller);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_dataContext.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
private static SeedingRuleRequest CreateValidRequest(
|
||||
string name = "Test Rule",
|
||||
List<string>? categories = null,
|
||||
List<string>? trackerPatterns = null,
|
||||
List<string>? tagsAny = null,
|
||||
List<string>? tagsAll = null,
|
||||
int? priority = null,
|
||||
double maxRatio = 2.0,
|
||||
double minSeedTime = 0,
|
||||
double maxSeedTime = -1,
|
||||
int minSeeders = 0,
|
||||
double maxInactiveDays = -1,
|
||||
bool deleteSourceFiles = true,
|
||||
SeedingRuleAction action = SeedingRuleAction.Delete)
|
||||
{
|
||||
return new SeedingRuleRequest
|
||||
{
|
||||
Name = name,
|
||||
Categories = categories ?? ["movies"],
|
||||
TrackerPatterns = trackerPatterns ?? [],
|
||||
TagsAny = tagsAny ?? [],
|
||||
TagsAll = tagsAll ?? [],
|
||||
Priority = priority,
|
||||
PrivacyType = TorrentPrivacyType.Both,
|
||||
MaxRatio = maxRatio,
|
||||
MinSeedTime = minSeedTime,
|
||||
MaxSeedTime = maxSeedTime,
|
||||
MinSeeders = minSeeders,
|
||||
MaxInactiveDays = maxInactiveDays,
|
||||
DeleteSourceFiles = deleteSourceFiles,
|
||||
Action = action,
|
||||
};
|
||||
}
|
||||
|
||||
private static List<SeedingRuleResponse> GetRulesFromOk(IActionResult result)
|
||||
{
|
||||
var okResult = result.ShouldBeOfType<OkObjectResult>();
|
||||
IEnumerable<SeedingRuleResponse> rules = okResult.Value.ShouldBeAssignableTo<IEnumerable<SeedingRuleResponse>>()!;
|
||||
return rules.ToList();
|
||||
}
|
||||
|
||||
private static T GetCreatedRule<T>(IActionResult result) where T : ISeedingRule
|
||||
{
|
||||
var createdResult = result.ShouldBeOfType<CreatedAtActionResult>();
|
||||
return createdResult.Value.ShouldBeOfType<T>();
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// GetSeedingRules
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task GetSeedingRules_EmptyRules_ReturnsEmptyList()
|
||||
{
|
||||
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
|
||||
|
||||
var result = await _controller.GetSeedingRules(client.Id);
|
||||
|
||||
GetRulesFromOk(result).ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSeedingRules_ReturnsRulesOrderedByPriority()
|
||||
{
|
||||
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
|
||||
SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id, name: "Rule C", priority: 3);
|
||||
SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id, name: "Rule A", priority: 1);
|
||||
SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id, name: "Rule B", priority: 2);
|
||||
|
||||
var result = await _controller.GetSeedingRules(client.Id);
|
||||
|
||||
List<SeedingRuleResponse> rules = GetRulesFromOk(result);
|
||||
rules.Count.ShouldBe(3);
|
||||
rules[0].Name.ShouldBe("Rule A");
|
||||
rules[1].Name.ShouldBe("Rule B");
|
||||
rules[2].Name.ShouldBe("Rule C");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSeedingRules_NonExistentClient_ReturnsNotFound()
|
||||
{
|
||||
var result = await _controller.GetSeedingRules(Guid.NewGuid());
|
||||
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(StatusCodes.Status404NotFound);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSeedingRules_QBitClient_ReturnsTagFields()
|
||||
{
|
||||
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
|
||||
SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id,
|
||||
tagsAny: ["hd", "private"], tagsAll: ["required"]);
|
||||
|
||||
var result = await _controller.GetSeedingRules(client.Id);
|
||||
|
||||
SeedingRuleResponse rule = GetRulesFromOk(result).Single();
|
||||
rule.TagsAny.ShouldBe(new List<string> { "hd", "private" });
|
||||
rule.TagsAll.ShouldBe(new List<string> { "required" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSeedingRules_ReturnsMinSeeders()
|
||||
{
|
||||
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
|
||||
SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id, minSeeders: 5);
|
||||
|
||||
var result = await _controller.GetSeedingRules(client.Id);
|
||||
|
||||
SeedingRuleResponse rule = GetRulesFromOk(result).Single();
|
||||
rule.MinSeeders.ShouldBe(5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSeedingRules_DelugeClient_ReturnsEmptyTagFields()
|
||||
{
|
||||
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext, DownloadClientTypeName.Deluge, "Test Deluge");
|
||||
SeedingRulesTestDataFactory.AddDelugeSeedingRule(_dataContext, client.Id);
|
||||
|
||||
var result = await _controller.GetSeedingRules(client.Id);
|
||||
|
||||
SeedingRuleResponse rule = GetRulesFromOk(result).Single();
|
||||
rule.TagsAny.ShouldBeEmpty();
|
||||
rule.TagsAll.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// CreateSeedingRule
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task CreateSeedingRule_ValidRequest_ReturnsCreated()
|
||||
{
|
||||
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
|
||||
var request = CreateValidRequest(name: "Movies Rule", categories: ["movies", "films"]);
|
||||
|
||||
var result = await _controller.CreateSeedingRule(client.Id, request);
|
||||
|
||||
var createdResult = result.ShouldBeOfType<CreatedAtActionResult>();
|
||||
createdResult.StatusCode.ShouldBe(201);
|
||||
|
||||
QBitSeedingRule rule = GetCreatedRule<QBitSeedingRule>(result);
|
||||
rule.Name.ShouldBe("Movies Rule");
|
||||
rule.Categories.ShouldBe(new List<string> { "movies", "films" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateSeedingRule_AutoAssignsPriority_WhenNotProvided()
|
||||
{
|
||||
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
|
||||
var request = CreateValidRequest();
|
||||
|
||||
var result = await _controller.CreateSeedingRule(client.Id, request);
|
||||
|
||||
GetCreatedRule<QBitSeedingRule>(result).Priority.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateSeedingRule_SetsMinSeeders()
|
||||
{
|
||||
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
|
||||
var request = CreateValidRequest(minSeeders: 5);
|
||||
|
||||
var result = await _controller.CreateSeedingRule(client.Id, request);
|
||||
|
||||
GetCreatedRule<QBitSeedingRule>(result).MinSeeders.ShouldBe(5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateSeedingRule_SetsMaxInactiveDays()
|
||||
{
|
||||
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
|
||||
var request = CreateValidRequest(maxInactiveDays: 30);
|
||||
|
||||
var result = await _controller.CreateSeedingRule(client.Id, request);
|
||||
|
||||
GetCreatedRule<QBitSeedingRule>(result).MaxInactiveDays.ShouldBe(30);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateSeedingRule_AutoAssignsSequentialPriority()
|
||||
{
|
||||
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
|
||||
SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id, priority: 1);
|
||||
|
||||
var request = CreateValidRequest(name: "Second Rule", categories: ["tv"]);
|
||||
|
||||
var result = await _controller.CreateSeedingRule(client.Id, request);
|
||||
|
||||
GetCreatedRule<QBitSeedingRule>(result).Priority.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateSeedingRule_DuplicatePriority_ReturnsBadRequest()
|
||||
{
|
||||
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
|
||||
SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id, priority: 1);
|
||||
|
||||
var request = CreateValidRequest(priority: 1);
|
||||
|
||||
var result = await _controller.CreateSeedingRule(client.Id, request);
|
||||
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(StatusCodes.Status400BadRequest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateSeedingRule_NonExistentClient_ReturnsNotFound()
|
||||
{
|
||||
var request = CreateValidRequest();
|
||||
|
||||
var result = await _controller.CreateSeedingRule(Guid.NewGuid(), request);
|
||||
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(StatusCodes.Status404NotFound);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateSeedingRule_EmptyCategories_ReturnsBadRequest()
|
||||
{
|
||||
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
|
||||
var request = CreateValidRequest(categories: []);
|
||||
|
||||
await Should.ThrowAsync<ValidationException>(() => _controller.CreateSeedingRule(client.Id, request));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateSeedingRule_SanitizesWhitespaceInLists()
|
||||
{
|
||||
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
|
||||
var request = CreateValidRequest(
|
||||
trackerPatterns: ["", " ", "valid.com", " trimmed.com "]);
|
||||
|
||||
var result = await _controller.CreateSeedingRule(client.Id, request);
|
||||
|
||||
QBitSeedingRule rule = GetCreatedRule<QBitSeedingRule>(result);
|
||||
rule.TrackerPatterns.ShouldBe(new List<string> { "valid.com", "trimmed.com" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateSeedingRule_ForTransmission_CreatesTransmissionRule()
|
||||
{
|
||||
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext,
|
||||
DownloadClientTypeName.Transmission, "Test Transmission");
|
||||
var request = CreateValidRequest(tagsAny: ["tag1"]);
|
||||
|
||||
var result = await _controller.CreateSeedingRule(client.Id, request);
|
||||
|
||||
GetCreatedRule<TransmissionSeedingRule>(result).TagsAny.ShouldBe(new List<string> { "tag1" });
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(DownloadClientTypeName.qBittorrent)]
|
||||
[InlineData(DownloadClientTypeName.Transmission)]
|
||||
[InlineData(DownloadClientTypeName.Deluge)]
|
||||
[InlineData(DownloadClientTypeName.uTorrent)]
|
||||
[InlineData(DownloadClientTypeName.rTorrent)]
|
||||
public async Task CreateSeedingRule_WithStopAction_ReturnsStopAction(DownloadClientTypeName typeName)
|
||||
{
|
||||
DownloadClientConfig client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext, typeName, $"Test {typeName}");
|
||||
SeedingRuleRequest request = CreateValidRequest(action: SeedingRuleAction.Stop);
|
||||
|
||||
IActionResult result = await _controller.CreateSeedingRule(client.Id, request);
|
||||
|
||||
result.ShouldBeOfType<CreatedAtActionResult>();
|
||||
SeedingRuleResponse rule = GetRulesFromOk(await _controller.GetSeedingRules(client.Id)).Single();
|
||||
rule.Action.ShouldBe(SeedingRuleAction.Stop);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateSeedingRule_ActionOmitted_DefaultsToDelete()
|
||||
{
|
||||
DownloadClientConfig client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
|
||||
SeedingRuleRequest request = new()
|
||||
{
|
||||
Name = "Rule without an action",
|
||||
Categories = ["movies"],
|
||||
MaxRatio = 2.0,
|
||||
};
|
||||
|
||||
IActionResult result = await _controller.CreateSeedingRule(client.Id, request);
|
||||
|
||||
GetCreatedRule<QBitSeedingRule>(result).Action.ShouldBe(SeedingRuleAction.Delete);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateSeedingRule_UnknownAction_ThrowsValidationException()
|
||||
{
|
||||
DownloadClientConfig client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
|
||||
SeedingRuleRequest request = CreateValidRequest(action: SeedingRuleAction.Unknown);
|
||||
|
||||
await Should.ThrowAsync<ValidationException>(() => _controller.CreateSeedingRule(client.Id, request));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateSeedingRule_UndefinedAction_ThrowsValidationException()
|
||||
{
|
||||
DownloadClientConfig client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
|
||||
SeedingRuleRequest request = CreateValidRequest(action: (SeedingRuleAction)5);
|
||||
|
||||
await Should.ThrowAsync<ValidationException>(() => _controller.CreateSeedingRule(client.Id, request));
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// UpdateSeedingRule
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSeedingRule_ValidRequest_ReturnsOk()
|
||||
{
|
||||
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
|
||||
var rule = SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id);
|
||||
|
||||
var request = CreateValidRequest(name: "Updated Name", categories: ["tv", "anime"]);
|
||||
|
||||
var result = await _controller.UpdateSeedingRule(rule.Id, request);
|
||||
|
||||
var okResult = result.ShouldBeOfType<OkObjectResult>();
|
||||
var updated = okResult.Value.ShouldBeOfType<QBitSeedingRule>();
|
||||
updated.Name.ShouldBe("Updated Name");
|
||||
updated.Categories.ShouldBe(new List<string> { "tv", "anime" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSeedingRule_DoesNotChangePriority()
|
||||
{
|
||||
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
|
||||
var rule = SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id, priority: 5);
|
||||
|
||||
var request = CreateValidRequest(priority: 1);
|
||||
|
||||
var result = await _controller.UpdateSeedingRule(rule.Id, request);
|
||||
|
||||
var okResult = result.ShouldBeOfType<OkObjectResult>();
|
||||
var updated = okResult.Value.ShouldBeOfType<QBitSeedingRule>();
|
||||
updated.Priority.ShouldBe(5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSeedingRule_UpdatesTagsForTagFilterableClient()
|
||||
{
|
||||
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
|
||||
var rule = SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id);
|
||||
|
||||
var request = CreateValidRequest(tagsAny: ["new-tag"], tagsAll: ["must-have"]);
|
||||
|
||||
var result = await _controller.UpdateSeedingRule(rule.Id, request);
|
||||
|
||||
var okResult = result.ShouldBeOfType<OkObjectResult>();
|
||||
var updated = okResult.Value.ShouldBeOfType<QBitSeedingRule>();
|
||||
updated.TagsAny.ShouldBe(new List<string> { "new-tag" });
|
||||
updated.TagsAll.ShouldBe(new List<string> { "must-have" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSeedingRule_UpdatesMinSeeders()
|
||||
{
|
||||
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
|
||||
var rule = SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id);
|
||||
|
||||
var request = CreateValidRequest(minSeeders: 5);
|
||||
|
||||
var result = await _controller.UpdateSeedingRule(rule.Id, request);
|
||||
|
||||
var okResult = result.ShouldBeOfType<OkObjectResult>();
|
||||
var updated = okResult.Value.ShouldBeOfType<QBitSeedingRule>();
|
||||
updated.MinSeeders.ShouldBe(5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSeedingRule_UpdatesMaxInactiveDays()
|
||||
{
|
||||
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
|
||||
var rule = SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id);
|
||||
|
||||
var request = CreateValidRequest(maxInactiveDays: 30);
|
||||
|
||||
var result = await _controller.UpdateSeedingRule(rule.Id, request);
|
||||
|
||||
var okResult = result.ShouldBeOfType<OkObjectResult>();
|
||||
var updated = okResult.Value.ShouldBeOfType<QBitSeedingRule>();
|
||||
updated.MaxInactiveDays.ShouldBe(30);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSeedingRule_NonExistentRule_ReturnsNotFound()
|
||||
{
|
||||
var request = CreateValidRequest();
|
||||
|
||||
var result = await _controller.UpdateSeedingRule(Guid.NewGuid(), request);
|
||||
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(StatusCodes.Status404NotFound);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSeedingRule_ValidationFailure_ReturnsBadRequest()
|
||||
{
|
||||
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
|
||||
var rule = SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id);
|
||||
|
||||
// Both maxRatio and maxSeedTime negative → validation failure
|
||||
var request = CreateValidRequest(maxRatio: -1, maxSeedTime: -1);
|
||||
|
||||
await Should.ThrowAsync<ValidationException>(() => _controller.UpdateSeedingRule(rule.Id, request));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(DownloadClientTypeName.qBittorrent)]
|
||||
[InlineData(DownloadClientTypeName.Transmission)]
|
||||
[InlineData(DownloadClientTypeName.Deluge)]
|
||||
[InlineData(DownloadClientTypeName.uTorrent)]
|
||||
[InlineData(DownloadClientTypeName.rTorrent)]
|
||||
public async Task UpdateSeedingRule_ChangedToStopAction_ReturnsStopAction(DownloadClientTypeName typeName)
|
||||
{
|
||||
DownloadClientConfig client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext, typeName, $"Test {typeName}");
|
||||
await _controller.CreateSeedingRule(client.Id, CreateValidRequest());
|
||||
Guid ruleId = GetRulesFromOk(await _controller.GetSeedingRules(client.Id)).Single().Id;
|
||||
|
||||
IActionResult result = await _controller.UpdateSeedingRule(ruleId, CreateValidRequest(action: SeedingRuleAction.Stop));
|
||||
|
||||
result.ShouldBeOfType<OkObjectResult>();
|
||||
SeedingRuleResponse rule = GetRulesFromOk(await _controller.GetSeedingRules(client.Id)).Single();
|
||||
rule.Action.ShouldBe(SeedingRuleAction.Stop);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSeedingRule_UnknownAction_ThrowsValidationException()
|
||||
{
|
||||
DownloadClientConfig client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
|
||||
QBitSeedingRule rule = SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id);
|
||||
|
||||
SeedingRuleRequest request = CreateValidRequest(action: SeedingRuleAction.Unknown);
|
||||
|
||||
await Should.ThrowAsync<ValidationException>(() => _controller.UpdateSeedingRule(rule.Id, request));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSeedingRule_UndefinedAction_ThrowsValidationException()
|
||||
{
|
||||
DownloadClientConfig client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
|
||||
QBitSeedingRule rule = SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id);
|
||||
|
||||
SeedingRuleRequest request = CreateValidRequest(action: (SeedingRuleAction)5);
|
||||
|
||||
await Should.ThrowAsync<ValidationException>(() => _controller.UpdateSeedingRule(rule.Id, request));
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// ReorderSeedingRules
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task ReorderSeedingRules_ValidRequest_ReturnsNoContent()
|
||||
{
|
||||
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
|
||||
var rule1 = SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id, name: "A", priority: 1);
|
||||
var rule2 = SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id, name: "B", priority: 2);
|
||||
|
||||
var request = new ReorderSeedingRulesRequest { OrderedIds = [rule2.Id, rule1.Id] };
|
||||
|
||||
var result = await _controller.ReorderSeedingRules(client.Id, request);
|
||||
result.ShouldBeOfType<NoContentResult>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReorderSeedingRules_AssignsSequentialPriorities()
|
||||
{
|
||||
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
|
||||
var rule1 = SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id, name: "A", priority: 1);
|
||||
var rule2 = SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id, name: "B", priority: 2);
|
||||
var rule3 = SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id, name: "C", priority: 3);
|
||||
|
||||
// Reverse order
|
||||
var request = new ReorderSeedingRulesRequest { OrderedIds = [rule3.Id, rule2.Id, rule1.Id] };
|
||||
await _controller.ReorderSeedingRules(client.Id, request);
|
||||
|
||||
List<SeedingRuleResponse> rules = GetRulesFromOk(await _controller.GetSeedingRules(client.Id));
|
||||
|
||||
rules[0].Name.ShouldBe("C");
|
||||
rules[0].Priority.ShouldBe(1);
|
||||
rules[1].Name.ShouldBe("B");
|
||||
rules[1].Priority.ShouldBe(2);
|
||||
rules[2].Name.ShouldBe("A");
|
||||
rules[2].Priority.ShouldBe(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReorderSeedingRules_NonExistentClient_ReturnsNotFound()
|
||||
{
|
||||
var request = new ReorderSeedingRulesRequest { OrderedIds = [Guid.NewGuid()] };
|
||||
|
||||
var result = await _controller.ReorderSeedingRules(Guid.NewGuid(), request);
|
||||
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(StatusCodes.Status404NotFound);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReorderSeedingRules_DuplicateIds_ReturnsBadRequest()
|
||||
{
|
||||
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
|
||||
var rule1 = SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id, name: "A", priority: 1);
|
||||
var rule2 = SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id, name: "B", priority: 2);
|
||||
|
||||
var request = new ReorderSeedingRulesRequest { OrderedIds = [rule1.Id, rule1.Id] };
|
||||
|
||||
var result = await _controller.ReorderSeedingRules(client.Id, request);
|
||||
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(StatusCodes.Status400BadRequest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReorderSeedingRules_WrongCount_ReturnsBadRequest()
|
||||
{
|
||||
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
|
||||
var rule1 = SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id, name: "A", priority: 1);
|
||||
SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id, name: "B", priority: 2);
|
||||
|
||||
// Only send 1 of 2 IDs
|
||||
var request = new ReorderSeedingRulesRequest { OrderedIds = [rule1.Id] };
|
||||
|
||||
var result = await _controller.ReorderSeedingRules(client.Id, request);
|
||||
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(StatusCodes.Status400BadRequest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReorderSeedingRules_UnknownRuleId_ReturnsBadRequest()
|
||||
{
|
||||
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
|
||||
var rule1 = SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id, name: "A", priority: 1);
|
||||
SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id, name: "B", priority: 2);
|
||||
|
||||
var request = new ReorderSeedingRulesRequest { OrderedIds = [rule1.Id, Guid.NewGuid()] };
|
||||
|
||||
var result = await _controller.ReorderSeedingRules(client.Id, request);
|
||||
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(StatusCodes.Status400BadRequest);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// DeleteSeedingRule
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteSeedingRule_ExistingRule_ReturnsNoContent()
|
||||
{
|
||||
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
|
||||
var rule = SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id);
|
||||
|
||||
var result = await _controller.DeleteSeedingRule(rule.Id);
|
||||
result.ShouldBeOfType<NoContentResult>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteSeedingRule_VerifiesRuleRemoved()
|
||||
{
|
||||
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
|
||||
var rule = SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id);
|
||||
|
||||
await _controller.DeleteSeedingRule(rule.Id);
|
||||
|
||||
GetRulesFromOk(await _controller.GetSeedingRules(client.Id)).ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteSeedingRule_NonExistentRule_ReturnsNotFound()
|
||||
{
|
||||
var result = await _controller.DeleteSeedingRule(Guid.NewGuid());
|
||||
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(StatusCodes.Status404NotFound);
|
||||
}
|
||||
}
|
||||
+213
@@ -0,0 +1,213 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
|
||||
using Cleanuparr.Persistence.Models.Configuration.General;
|
||||
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
|
||||
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Seeker;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Features.DownloadCleaner.TestHelpers;
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating SQLite in-memory contexts for SeedingRulesController tests
|
||||
/// </summary>
|
||||
public static class SeedingRulesTestDataFactory
|
||||
{
|
||||
public static DataContext CreateDataContext()
|
||||
{
|
||||
var connection = new SqliteConnection("DataSource=:memory:");
|
||||
connection.Open();
|
||||
|
||||
var options = new DbContextOptionsBuilder<DataContext>()
|
||||
.UseSqlite(connection)
|
||||
.Options;
|
||||
|
||||
var context = new DataContext(options);
|
||||
context.Database.EnsureCreated();
|
||||
|
||||
SeedDefaultData(context);
|
||||
return context;
|
||||
}
|
||||
|
||||
private static void SeedDefaultData(DataContext context)
|
||||
{
|
||||
context.GeneralConfigs.Add(new GeneralConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
DryRun = false,
|
||||
IgnoredDownloads = [],
|
||||
Log = new LoggingConfig()
|
||||
});
|
||||
|
||||
context.ArrConfigs.AddRange(
|
||||
new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Sonarr, Instances = [], FailedImportMaxStrikes = 3 },
|
||||
new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Radarr, Instances = [], FailedImportMaxStrikes = 3 },
|
||||
new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Lidarr, Instances = [], FailedImportMaxStrikes = 3 },
|
||||
new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Readarr, Instances = [], FailedImportMaxStrikes = 3 },
|
||||
new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Whisparr, Instances = [], FailedImportMaxStrikes = 3 },
|
||||
new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Sportarr, Instances = [], FailedImportMaxStrikes = 3 }
|
||||
);
|
||||
|
||||
context.QueueCleanerConfigs.Add(new QueueCleanerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
IgnoredDownloads = [],
|
||||
FailedImport = new FailedImportConfig()
|
||||
});
|
||||
|
||||
context.ContentBlockerConfigs.Add(new ContentBlockerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
IgnoredDownloads = [],
|
||||
DeletePrivate = false,
|
||||
Sonarr = new BlocklistSettings { Enabled = false },
|
||||
Radarr = new BlocklistSettings { Enabled = false },
|
||||
Lidarr = new BlocklistSettings { Enabled = false },
|
||||
Readarr = new BlocklistSettings { Enabled = false },
|
||||
Whisparr = new BlocklistSettings { Enabled = false },
|
||||
Sportarr = new BlocklistSettings { Enabled = false }
|
||||
});
|
||||
|
||||
context.DownloadCleanerConfigs.Add(new DownloadCleanerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
IgnoredDownloads = []
|
||||
});
|
||||
|
||||
context.SeekerConfigs.Add(new SeekerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
SearchEnabled = true,
|
||||
ProactiveSearchEnabled = false
|
||||
});
|
||||
|
||||
context.SaveChanges();
|
||||
}
|
||||
|
||||
public static DownloadClientConfig AddDownloadClient(
|
||||
DataContext context,
|
||||
DownloadClientTypeName typeName = DownloadClientTypeName.qBittorrent,
|
||||
string name = "Test qBittorrent")
|
||||
{
|
||||
var config = new DownloadClientConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = name,
|
||||
TypeName = typeName,
|
||||
Type = DownloadClientType.Torrent,
|
||||
Enabled = true,
|
||||
Host = new Uri("http://localhost:8080"),
|
||||
Username = "admin",
|
||||
Password = "admin"
|
||||
};
|
||||
|
||||
context.DownloadClients.Add(config);
|
||||
context.SaveChanges();
|
||||
return config;
|
||||
}
|
||||
|
||||
public static QBitSeedingRule AddQBitSeedingRule(
|
||||
DataContext context,
|
||||
Guid downloadClientId,
|
||||
string name = "Test Rule",
|
||||
int priority = 1,
|
||||
List<string>? categories = null,
|
||||
List<string>? trackerPatterns = null,
|
||||
List<string>? tagsAny = null,
|
||||
List<string>? tagsAll = null,
|
||||
double maxRatio = 2.0,
|
||||
double minSeedTime = 0,
|
||||
double maxSeedTime = -1,
|
||||
int minSeeders = 0)
|
||||
{
|
||||
var rule = new QBitSeedingRule
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
DownloadClientConfigId = downloadClientId,
|
||||
Name = name,
|
||||
Priority = priority,
|
||||
Categories = categories ?? ["movies"],
|
||||
TrackerPatterns = trackerPatterns ?? [],
|
||||
TagsAny = tagsAny ?? [],
|
||||
TagsAll = tagsAll ?? [],
|
||||
PrivacyType = TorrentPrivacyType.Both,
|
||||
MaxRatio = maxRatio,
|
||||
MinSeedTime = minSeedTime,
|
||||
MaxSeedTime = maxSeedTime,
|
||||
MinSeeders = minSeeders,
|
||||
DeleteSourceFiles = true,
|
||||
};
|
||||
|
||||
context.QBitSeedingRules.Add(rule);
|
||||
context.SaveChanges();
|
||||
return rule;
|
||||
}
|
||||
|
||||
public static DelugeSeedingRule AddDelugeSeedingRule(
|
||||
DataContext context,
|
||||
Guid downloadClientId,
|
||||
string name = "Test Rule",
|
||||
int priority = 1,
|
||||
List<string>? categories = null,
|
||||
double maxRatio = 2.0,
|
||||
double maxSeedTime = -1,
|
||||
int minSeeders = 0)
|
||||
{
|
||||
var rule = new DelugeSeedingRule
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
DownloadClientConfigId = downloadClientId,
|
||||
Name = name,
|
||||
Priority = priority,
|
||||
Categories = categories ?? ["movies"],
|
||||
TrackerPatterns = [],
|
||||
PrivacyType = TorrentPrivacyType.Both,
|
||||
MaxRatio = maxRatio,
|
||||
MinSeedTime = 0,
|
||||
MaxSeedTime = maxSeedTime,
|
||||
MinSeeders = minSeeders,
|
||||
DeleteSourceFiles = true,
|
||||
};
|
||||
|
||||
context.DelugeSeedingRules.Add(rule);
|
||||
context.SaveChanges();
|
||||
return rule;
|
||||
}
|
||||
|
||||
public static TransmissionSeedingRule AddTransmissionSeedingRule(
|
||||
DataContext context,
|
||||
Guid downloadClientId,
|
||||
string name = "Test Rule",
|
||||
int priority = 1,
|
||||
List<string>? categories = null,
|
||||
double maxRatio = 2.0,
|
||||
double maxSeedTime = -1,
|
||||
int minSeeders = 0)
|
||||
{
|
||||
var rule = new TransmissionSeedingRule
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
DownloadClientConfigId = downloadClientId,
|
||||
Name = name,
|
||||
Priority = priority,
|
||||
Categories = categories ?? ["movies"],
|
||||
TrackerPatterns = [],
|
||||
TagsAny = [],
|
||||
TagsAll = [],
|
||||
PrivacyType = TorrentPrivacyType.Both,
|
||||
MaxRatio = maxRatio,
|
||||
MinSeedTime = 0,
|
||||
MaxSeedTime = maxSeedTime,
|
||||
MinSeeders = minSeeders,
|
||||
DeleteSourceFiles = true,
|
||||
};
|
||||
|
||||
context.TransmissionSeedingRules.Add(rule);
|
||||
context.SaveChanges();
|
||||
return rule;
|
||||
}
|
||||
}
|
||||
+200
@@ -0,0 +1,200 @@
|
||||
using Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests;
|
||||
using Cleanuparr.Api.Features.DownloadCleaner.Contracts.Responses;
|
||||
using Cleanuparr.Api.Features.DownloadCleaner.Controllers;
|
||||
using Cleanuparr.Domain.Exceptions;
|
||||
using Cleanuparr.Api.Tests.TestHelpers;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Features.DownloadCleaner;
|
||||
|
||||
public class UnlinkedConfigControllerTests : IDisposable
|
||||
{
|
||||
private readonly DataContext _dataContext;
|
||||
private readonly UnlinkedConfigController _controller;
|
||||
|
||||
public UnlinkedConfigControllerTests()
|
||||
{
|
||||
_dataContext = ConfigControllerTestDataFactory.CreateDataContext();
|
||||
var logger = Substitute.For<ILogger<UnlinkedConfigController>>();
|
||||
_controller = new UnlinkedConfigController(logger, _dataContext);
|
||||
ConfigControllerTestDataFactory.ConfigureProblemDetails(_controller);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_dataContext.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetUnlinkedConfig_ClientNotFound_ReturnsNotFound()
|
||||
{
|
||||
// Act
|
||||
var result = await _controller.GetUnlinkedConfig(Guid.NewGuid());
|
||||
|
||||
// Assert
|
||||
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(404);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetUnlinkedConfig_ClientWithoutConfig_ReturnsOkWithNull()
|
||||
{
|
||||
// Arrange — add a client but no UnlinkedConfig
|
||||
var client = AddDownloadClient();
|
||||
|
||||
// Act
|
||||
var result = await _controller.GetUnlinkedConfig(client.Id);
|
||||
|
||||
// Assert
|
||||
var ok = result.ShouldBeOfType<OkObjectResult>();
|
||||
ok.Value.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetUnlinkedConfig_ClientWithConfig_ReturnsConfig()
|
||||
{
|
||||
// Arrange
|
||||
var client = AddDownloadClient();
|
||||
_dataContext.UnlinkedConfigs.Add(new UnlinkedConfig
|
||||
{
|
||||
DownloadClientConfigId = client.Id,
|
||||
Enabled = true,
|
||||
TargetCategory = "unlinked-cat",
|
||||
Categories = new List<string> { "regular" },
|
||||
});
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
// Act
|
||||
var result = await _controller.GetUnlinkedConfig(client.Id);
|
||||
|
||||
// Assert
|
||||
var ok = result.ShouldBeOfType<OkObjectResult>();
|
||||
var config = ok.Value.ShouldBeOfType<UnlinkedConfigResponse>();
|
||||
config.Enabled.ShouldBeTrue();
|
||||
config.TargetCategory.ShouldBe("unlinked-cat");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateUnlinkedConfig_ClientNotFound_ReturnsNotFound()
|
||||
{
|
||||
// Arrange
|
||||
var dto = new UnlinkedConfigRequest { Enabled = false };
|
||||
|
||||
// Act
|
||||
var result = await _controller.UpdateUnlinkedConfig(Guid.NewGuid(), dto);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(404);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateUnlinkedConfig_NewConfig_CreatesAndReturnsIt()
|
||||
{
|
||||
// Arrange
|
||||
var client = AddDownloadClient();
|
||||
var dto = new UnlinkedConfigRequest
|
||||
{
|
||||
Enabled = false,
|
||||
TargetCategory = "unlinked-cat",
|
||||
Categories = new List<string> { "movies" },
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _controller.UpdateUnlinkedConfig(client.Id, dto);
|
||||
|
||||
// Assert
|
||||
var ok = result.ShouldBeOfType<OkObjectResult>();
|
||||
var saved = await _dataContext.UnlinkedConfigs.AsNoTracking()
|
||||
.FirstAsync(u => u.DownloadClientConfigId == client.Id);
|
||||
saved.TargetCategory.ShouldBe("unlinked-cat");
|
||||
saved.Categories.ShouldContain("movies");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateUnlinkedConfig_ExistingConfig_UpdatesInPlace()
|
||||
{
|
||||
// Arrange
|
||||
var client = AddDownloadClient();
|
||||
_dataContext.UnlinkedConfigs.Add(new UnlinkedConfig
|
||||
{
|
||||
DownloadClientConfigId = client.Id,
|
||||
Enabled = false,
|
||||
TargetCategory = "old-cat",
|
||||
});
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
var dto = new UnlinkedConfigRequest
|
||||
{
|
||||
Enabled = false,
|
||||
TargetCategory = "new-cat",
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _controller.UpdateUnlinkedConfig(client.Id, dto);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeOfType<OkObjectResult>();
|
||||
var rows = await _dataContext.UnlinkedConfigs
|
||||
.Where(u => u.DownloadClientConfigId == client.Id)
|
||||
.ToListAsync();
|
||||
rows.Count.ShouldBe(1);
|
||||
rows[0].TargetCategory.ShouldBe("new-cat");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateUnlinkedConfig_EnabledButNoCategories_ReturnsBadRequest()
|
||||
{
|
||||
// Arrange — enabled requires at least one category per Validate()
|
||||
var client = AddDownloadClient();
|
||||
var dto = new UnlinkedConfigRequest
|
||||
{
|
||||
Enabled = true,
|
||||
TargetCategory = "unlinked-cat",
|
||||
Categories = new List<string>(),
|
||||
};
|
||||
|
||||
// Act + Assert
|
||||
await Should.ThrowAsync<ValidationException>(
|
||||
() => _controller.UpdateUnlinkedConfig(client.Id, dto));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateUnlinkedConfig_TargetInCategories_ReturnsBadRequest()
|
||||
{
|
||||
// Arrange — TargetCategory must not appear in Categories
|
||||
var client = AddDownloadClient();
|
||||
var dto = new UnlinkedConfigRequest
|
||||
{
|
||||
Enabled = true,
|
||||
TargetCategory = "unlinked-cat",
|
||||
Categories = new List<string> { "movies", "unlinked-cat" },
|
||||
};
|
||||
|
||||
// Act + Assert
|
||||
await Should.ThrowAsync<ValidationException>(
|
||||
() => _controller.UpdateUnlinkedConfig(client.Id, dto));
|
||||
}
|
||||
|
||||
private DownloadClientConfig AddDownloadClient()
|
||||
{
|
||||
var client = new DownloadClientConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "test-client",
|
||||
TypeName = DownloadClientTypeName.qBittorrent,
|
||||
Type = DownloadClientType.Torrent,
|
||||
};
|
||||
_dataContext.DownloadClients.Add(client);
|
||||
_dataContext.SaveChanges();
|
||||
return client;
|
||||
}
|
||||
}
|
||||
+275
@@ -0,0 +1,275 @@
|
||||
using Cleanuparr.Api.Features.DownloadClient.Contracts.Requests;
|
||||
using Cleanuparr.Api.Features.DownloadClient.Controllers;
|
||||
using Cleanuparr.Api.Tests.TestHelpers;
|
||||
using Cleanuparr.Domain.Entities.HealthCheck;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient;
|
||||
using Cleanuparr.Infrastructure.Http.DynamicHttpClientSystem;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Features.DownloadClient;
|
||||
|
||||
public class DownloadClientControllerTests : IDisposable
|
||||
{
|
||||
private readonly DataContext _dataContext;
|
||||
private readonly IDynamicHttpClientFactory _dynamicHttpClientFactory;
|
||||
private readonly IDownloadServiceFactory _downloadServiceFactory;
|
||||
private readonly DownloadClientController _controller;
|
||||
|
||||
public DownloadClientControllerTests()
|
||||
{
|
||||
_dataContext = ConfigControllerTestDataFactory.CreateDataContext();
|
||||
var logger = Substitute.For<ILogger<DownloadClientController>>();
|
||||
_dynamicHttpClientFactory = Substitute.For<IDynamicHttpClientFactory>();
|
||||
_downloadServiceFactory = Substitute.For<IDownloadServiceFactory>();
|
||||
_controller = new DownloadClientController(logger, _dataContext, _dynamicHttpClientFactory, _downloadServiceFactory);
|
||||
ConfigControllerTestDataFactory.ConfigureProblemDetails(_controller);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_dataContext.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetDownloadClientConfig_EmptyDatabase_ReturnsOkWithEmptyClients()
|
||||
{
|
||||
// Act
|
||||
var result = await _controller.GetDownloadClientConfig();
|
||||
|
||||
// Assert
|
||||
var ok = result.ShouldBeOfType<OkObjectResult>();
|
||||
ok.Value.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetDownloadClientConfig_OrdersByTypeThenName()
|
||||
{
|
||||
// Arrange — add 3 clients out of order
|
||||
_dataContext.DownloadClients.AddRange(
|
||||
NewClient("z-client", DownloadClientTypeName.qBittorrent),
|
||||
NewClient("a-client", DownloadClientTypeName.qBittorrent),
|
||||
NewClient("b-client", DownloadClientTypeName.Deluge)
|
||||
);
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
// Act
|
||||
var result = await _controller.GetDownloadClientConfig();
|
||||
|
||||
// Assert
|
||||
var ok = result.ShouldBeOfType<OkObjectResult>();
|
||||
var dict = ok.Value!.GetType().GetProperty("clients")!.GetValue(ok.Value) as List<DownloadClientConfig>;
|
||||
dict.ShouldNotBeNull();
|
||||
dict!.Count.ShouldBe(3);
|
||||
// qBittorrent (0) comes before Deluge (1) by enum value, then alphabetical within type
|
||||
dict![0].Name.ShouldBe("a-client");
|
||||
dict![1].Name.ShouldBe("z-client");
|
||||
dict![2].Name.ShouldBe("b-client");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateDownloadClientConfig_ValidRequest_ReturnsCreated()
|
||||
{
|
||||
// Arrange
|
||||
var request = new CreateDownloadClientRequest
|
||||
{
|
||||
Enabled = true,
|
||||
Name = "my-client",
|
||||
TypeName = DownloadClientTypeName.qBittorrent,
|
||||
Type = DownloadClientType.Torrent,
|
||||
Host = "http://localhost:8080",
|
||||
Username = "user",
|
||||
Password = "pass",
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _controller.CreateDownloadClientConfig(request);
|
||||
|
||||
// Assert
|
||||
var created = result.ShouldBeOfType<CreatedAtActionResult>();
|
||||
var entity = created.Value.ShouldBeOfType<DownloadClientConfig>();
|
||||
entity.Name.ShouldBe("my-client");
|
||||
(await _dataContext.DownloadClients.CountAsync()).ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateDownloadClientConfig_InvalidHost_PropagatesValidationException()
|
||||
{
|
||||
// Arrange
|
||||
var request = new CreateDownloadClientRequest
|
||||
{
|
||||
Name = "x",
|
||||
Host = string.Empty,
|
||||
TypeName = DownloadClientTypeName.qBittorrent,
|
||||
Type = DownloadClientType.Torrent,
|
||||
};
|
||||
|
||||
// Act / Assert — Validate throws, controller's generic catch logs and re-throws
|
||||
await Should.ThrowAsync<Cleanuparr.Domain.Exceptions.ValidationException>(
|
||||
() => _controller.CreateDownloadClientConfig(request));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateDownloadClientConfig_UnknownId_ReturnsNotFound()
|
||||
{
|
||||
// Arrange
|
||||
var request = new UpdateDownloadClientRequest
|
||||
{
|
||||
Name = "x",
|
||||
Host = "http://localhost:8080",
|
||||
TypeName = DownloadClientTypeName.qBittorrent,
|
||||
Type = DownloadClientType.Torrent,
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _controller.UpdateDownloadClientConfig(Guid.NewGuid(), request);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(404);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateDownloadClientConfig_ExistingClient_PersistsChanges()
|
||||
{
|
||||
// Arrange
|
||||
var client = NewClient("orig", DownloadClientTypeName.qBittorrent);
|
||||
_dataContext.DownloadClients.Add(client);
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
var request = new UpdateDownloadClientRequest
|
||||
{
|
||||
Enabled = true,
|
||||
Name = "renamed",
|
||||
Host = "http://newhost:9090",
|
||||
TypeName = DownloadClientTypeName.qBittorrent,
|
||||
Type = DownloadClientType.Torrent,
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _controller.UpdateDownloadClientConfig(client.Id, request);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeOfType<OkObjectResult>();
|
||||
var saved = await _dataContext.DownloadClients.AsNoTracking().FirstAsync(c => c.Id == client.Id);
|
||||
saved.Name.ShouldBe("renamed");
|
||||
saved.Host!.ToString().ShouldContain("newhost");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteDownloadClientConfig_ExistingClient_RemovesAndUnregistersHttpClient()
|
||||
{
|
||||
// Arrange
|
||||
var client = NewClient("doomed", DownloadClientTypeName.qBittorrent);
|
||||
_dataContext.DownloadClients.Add(client);
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
// Act
|
||||
var result = await _controller.DeleteDownloadClientConfig(client.Id);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeOfType<NoContentResult>();
|
||||
(await _dataContext.DownloadClients.CountAsync()).ShouldBe(0);
|
||||
_dynamicHttpClientFactory.Received(1).UnregisterConfiguration($"DownloadClient_{client.Id}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteDownloadClientConfig_UnknownId_ReturnsNotFound()
|
||||
{
|
||||
// Act
|
||||
var result = await _controller.DeleteDownloadClientConfig(Guid.NewGuid());
|
||||
|
||||
// Assert
|
||||
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(404);
|
||||
_dynamicHttpClientFactory.DidNotReceive().UnregisterConfiguration(Arg.Any<string>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TestDownloadClient_Healthy_ReturnsOkWithResponseTime()
|
||||
{
|
||||
// Arrange
|
||||
var downloadService = Substitute.For<IDownloadService>();
|
||||
downloadService.HealthCheckAsync().Returns(new HealthCheckResult
|
||||
{
|
||||
IsHealthy = true,
|
||||
ResponseTime = TimeSpan.FromMilliseconds(123),
|
||||
});
|
||||
_downloadServiceFactory.GetDownloadService(Arg.Any<DownloadClientConfig>()).Returns(downloadService);
|
||||
|
||||
var request = new TestDownloadClientRequest
|
||||
{
|
||||
TypeName = DownloadClientTypeName.qBittorrent,
|
||||
Type = DownloadClientType.Torrent,
|
||||
Host = "http://localhost:8080",
|
||||
Password = "pass",
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _controller.TestDownloadClient(request);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeOfType<OkObjectResult>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TestDownloadClient_Unhealthy_ReturnsBadRequestWithMessage()
|
||||
{
|
||||
// Arrange
|
||||
var downloadService = Substitute.For<IDownloadService>();
|
||||
downloadService.HealthCheckAsync().Returns(new HealthCheckResult
|
||||
{
|
||||
IsHealthy = false,
|
||||
ErrorMessage = "connection refused",
|
||||
});
|
||||
_downloadServiceFactory.GetDownloadService(Arg.Any<DownloadClientConfig>()).Returns(downloadService);
|
||||
|
||||
var request = new TestDownloadClientRequest
|
||||
{
|
||||
TypeName = DownloadClientTypeName.qBittorrent,
|
||||
Type = DownloadClientType.Torrent,
|
||||
Host = "http://localhost:8080",
|
||||
Password = "pass",
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _controller.TestDownloadClient(request);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(400);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TestDownloadClient_InvalidHost_ReturnsBadRequest()
|
||||
{
|
||||
// Arrange — empty host fails Validate; the controller wraps the exception in BadRequest
|
||||
var request = new TestDownloadClientRequest
|
||||
{
|
||||
TypeName = DownloadClientTypeName.qBittorrent,
|
||||
Type = DownloadClientType.Torrent,
|
||||
Host = string.Empty,
|
||||
Password = "pass",
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _controller.TestDownloadClient(request);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(400);
|
||||
}
|
||||
|
||||
private static DownloadClientConfig NewClient(string name, DownloadClientTypeName typeName) => new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = name,
|
||||
TypeName = typeName,
|
||||
Type = DownloadClientType.Torrent,
|
||||
Host = new Uri("http://localhost:8080"),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
using Cleanuparr.Api.Features.General.Contracts.Requests;
|
||||
using Cleanuparr.Api.Features.General.Controllers;
|
||||
using Cleanuparr.Api.Tests.TestHelpers;
|
||||
using Cleanuparr.Infrastructure.Http.DynamicHttpClientSystem;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration.General;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Features.General;
|
||||
|
||||
public class GeneralConfigControllerTests : IDisposable
|
||||
{
|
||||
private readonly DataContext _dataContext;
|
||||
private readonly EventsContext _eventsContext;
|
||||
private readonly IDynamicHttpClientFactory _dynamicHttpClientFactory;
|
||||
private readonly GeneralConfigController _controller;
|
||||
|
||||
public GeneralConfigControllerTests()
|
||||
{
|
||||
_dataContext = ConfigControllerTestDataFactory.CreateDataContext();
|
||||
_eventsContext = ConfigControllerTestDataFactory.CreateEventsContext();
|
||||
_dynamicHttpClientFactory = Substitute.For<IDynamicHttpClientFactory>();
|
||||
|
||||
var logger = Substitute.For<ILogger<GeneralConfigController>>();
|
||||
_controller = new GeneralConfigController(logger, _dataContext);
|
||||
|
||||
// Mount a DefaultHttpContext with a ServiceProvider that resolves IDynamicHttpClientFactory
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton(_dynamicHttpClientFactory);
|
||||
_controller.ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = new DefaultHttpContext { RequestServices = services.BuildServiceProvider() },
|
||||
};
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_dataContext.Dispose();
|
||||
_eventsContext.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetGeneralConfig_ReturnsExistingConfig()
|
||||
{
|
||||
// Act
|
||||
var result = await _controller.GetGeneralConfig();
|
||||
|
||||
// Assert
|
||||
var ok = result.ShouldBeOfType<OkObjectResult>();
|
||||
ok.Value.ShouldBeOfType<GeneralConfig>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateGeneralConfig_PersistsChangesAndUpdatesHttpClients()
|
||||
{
|
||||
// Arrange — keep Log defaults matching DB so loggingChanged=false (avoid LoggingConfigManager statics)
|
||||
var existing = await _dataContext.GeneralConfigs.AsNoTracking().FirstAsync();
|
||||
var request = new UpdateGeneralConfigRequest
|
||||
{
|
||||
DisplaySupportBanner = false,
|
||||
DryRun = false,
|
||||
HttpMaxRetries = 5,
|
||||
HttpTimeout = 60,
|
||||
HttpSendUserAgent = true,
|
||||
StatusCheckEnabled = false,
|
||||
EncryptionKey = existing.EncryptionKey,
|
||||
IgnoredDownloads = new List<string> { "ignored-item" },
|
||||
StrikeInactivityWindowHours = 48,
|
||||
Log = MatchingLogRequest(existing.Log),
|
||||
Auth = new UpdateAuthConfigRequest(),
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _controller.UpdateGeneralConfig(request, _eventsContext);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeOfType<OkObjectResult>();
|
||||
_dynamicHttpClientFactory.Received(1).UpdateAllClientsFromGeneralConfig(Arg.Any<GeneralConfig>());
|
||||
|
||||
var saved = await _dataContext.GeneralConfigs.AsNoTracking().FirstAsync();
|
||||
saved.DisplaySupportBanner.ShouldBeFalse();
|
||||
saved.HttpMaxRetries.ShouldBe((ushort)5);
|
||||
saved.HttpTimeout.ShouldBe((ushort)60);
|
||||
saved.HttpSendUserAgent.ShouldBeTrue();
|
||||
saved.StrikeInactivityWindowHours.ShouldBe((ushort)48);
|
||||
saved.IgnoredDownloads.ShouldContain("ignored-item");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateGeneralConfig_InvalidHttpTimeout_Throws()
|
||||
{
|
||||
// Arrange — HttpTimeout=0 fails validation
|
||||
var existing = await _dataContext.GeneralConfigs.AsNoTracking().FirstAsync();
|
||||
var request = new UpdateGeneralConfigRequest
|
||||
{
|
||||
HttpTimeout = 0,
|
||||
EncryptionKey = existing.EncryptionKey,
|
||||
StrikeInactivityWindowHours = 24,
|
||||
Log = MatchingLogRequest(existing.Log),
|
||||
Auth = new UpdateAuthConfigRequest(),
|
||||
};
|
||||
|
||||
// Act / Assert
|
||||
await Should.ThrowAsync<Exception>(() => _controller.UpdateGeneralConfig(request, _eventsContext));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateGeneralConfig_InvalidStrikeWindow_Throws()
|
||||
{
|
||||
// Arrange — StrikeInactivityWindowHours > 168 fails validation
|
||||
var existing = await _dataContext.GeneralConfigs.AsNoTracking().FirstAsync();
|
||||
var request = new UpdateGeneralConfigRequest
|
||||
{
|
||||
HttpTimeout = 60,
|
||||
EncryptionKey = existing.EncryptionKey,
|
||||
StrikeInactivityWindowHours = 200,
|
||||
Log = MatchingLogRequest(existing.Log),
|
||||
Auth = new UpdateAuthConfigRequest(),
|
||||
};
|
||||
|
||||
// Act / Assert
|
||||
await Should.ThrowAsync<Exception>(() => _controller.UpdateGeneralConfig(request, _eventsContext));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateGeneralConfig_ConnectivityCheckEnabledWithNoUrls_Throws()
|
||||
{
|
||||
// Arrange — connectivity check enabled but no URLs fails validation
|
||||
var existing = await _dataContext.GeneralConfigs.AsNoTracking().FirstAsync();
|
||||
var request = new UpdateGeneralConfigRequest
|
||||
{
|
||||
HttpTimeout = 60,
|
||||
EncryptionKey = existing.EncryptionKey,
|
||||
StrikeInactivityWindowHours = 24,
|
||||
ConnectivityCheckEnabled = true,
|
||||
ConnectivityCheckUrls = new List<string>(),
|
||||
Log = MatchingLogRequest(existing.Log),
|
||||
Auth = new UpdateAuthConfigRequest(),
|
||||
};
|
||||
|
||||
// Act / Assert
|
||||
await Should.ThrowAsync<Exception>(() => _controller.UpdateGeneralConfig(request, _eventsContext));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PurgeAllStrikes_ReturnsDeletedCounts()
|
||||
{
|
||||
// Act
|
||||
var result = await _controller.PurgeAllStrikes(_eventsContext);
|
||||
|
||||
// Assert — initially empty, but the endpoint still succeeds with zero counts
|
||||
var ok = result.ShouldBeOfType<OkObjectResult>();
|
||||
ok.Value.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
private static UpdateLoggingConfigRequest MatchingLogRequest(LoggingConfig existing) => new()
|
||||
{
|
||||
Level = existing.Level,
|
||||
RollingSizeMB = existing.RollingSizeMB,
|
||||
RetainedFileCount = existing.RetainedFileCount,
|
||||
TimeLimitHours = existing.TimeLimitHours,
|
||||
ArchiveEnabled = existing.ArchiveEnabled,
|
||||
ArchiveRetainedCount = existing.ArchiveRetainedCount,
|
||||
ArchiveTimeLimitHours = existing.ArchiveTimeLimitHours,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
using Shouldly;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Features;
|
||||
|
||||
public class LogSanitizerTests
|
||||
{
|
||||
[Fact]
|
||||
public void SanitizeForLog_LeavesAPlainValueUntouched()
|
||||
{
|
||||
"admin".SanitizeForLog().ShouldBe("admin");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SanitizeForLog_KeepsPrintableSymbolsAndAccents()
|
||||
{
|
||||
"admin.user+1_ó".SanitizeForLog().ShouldBe("admin.user+1_ó");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
public void SanitizeForLog_MapsMissingValuesToEmpty(string? value)
|
||||
{
|
||||
value.SanitizeForLog().ShouldBe(string.Empty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SanitizeForLog_StripsTheLineBreaksUsedToForgeAnEntry()
|
||||
{
|
||||
string forged = "admin\n2026-01-01 00:00:00.000 [ERR] Injected entry";
|
||||
|
||||
forged.SanitizeForLog().ShouldBe("admin2026-01-01 00:00:00.000 [ERR] Injected entry");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SanitizeForLog_StripsCarriageReturns()
|
||||
{
|
||||
"admin\r\nsecond line".SanitizeForLog().ShouldBe("adminsecond line");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SanitizeForLog_StripsEscapeSequencesAndOtherControlCharacters()
|
||||
{
|
||||
"adm\u001b[31min\tuser\0".SanitizeForLog().ShouldBe("adm[31minuser");
|
||||
}
|
||||
}
|
||||
+155
@@ -0,0 +1,155 @@
|
||||
using Cleanuparr.Api.Features.MalwareBlocker.Contracts.Requests;
|
||||
using Cleanuparr.Api.Features.MalwareBlocker.Controllers;
|
||||
using Cleanuparr.Api.Tests.TestHelpers;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Services.Interfaces;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Features.MalwareBlocker;
|
||||
|
||||
public class MalwareBlockerConfigControllerTests : IDisposable
|
||||
{
|
||||
private readonly DataContext _dataContext;
|
||||
private readonly IJobManagementService _jobManagementService;
|
||||
private readonly MalwareBlockerConfigController _controller;
|
||||
|
||||
public MalwareBlockerConfigControllerTests()
|
||||
{
|
||||
_dataContext = ConfigControllerTestDataFactory.CreateDataContext();
|
||||
var logger = Substitute.For<ILogger<MalwareBlockerConfigController>>();
|
||||
_jobManagementService = Substitute.For<IJobManagementService>();
|
||||
_controller = new MalwareBlockerConfigController(logger, _dataContext, _jobManagementService);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_dataContext.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetMalwareBlockerConfig_ReturnsExistingConfig()
|
||||
{
|
||||
// Act
|
||||
var result = await _controller.GetMalwareBlockerConfig();
|
||||
|
||||
// Assert
|
||||
var ok = result.ShouldBeOfType<OkObjectResult>();
|
||||
ok.Value.ShouldBeOfType<ContentBlockerConfig>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateMalwareBlockerConfig_Enabled_StartsJobWithCron()
|
||||
{
|
||||
// Arrange — Enabled requires at least one blocklist with a valid path
|
||||
var request = new UpdateMalwareBlockerConfigRequest
|
||||
{
|
||||
Enabled = true,
|
||||
CronExpression = "0/5 * * * * ?",
|
||||
Sonarr = new BlocklistSettings { Enabled = true, BlocklistPath = "https://example.com/blocklist.txt" },
|
||||
Radarr = new BlocklistSettings { Enabled = false },
|
||||
Lidarr = new BlocklistSettings { Enabled = false },
|
||||
Readarr = new BlocklistSettings { Enabled = false },
|
||||
Whisparr = new BlocklistSettings { Enabled = false },
|
||||
Sportarr = new BlocklistSettings { Enabled = false },
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _controller.UpdateMalwareBlockerConfig(request);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeOfType<OkObjectResult>();
|
||||
await _jobManagementService.Received(1).StartJob(JobType.MalwareBlocker, null, "0/5 * * * * ?");
|
||||
await _jobManagementService.DidNotReceive().StopJob(Arg.Any<JobType>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateMalwareBlockerConfig_Disabled_StopsJob()
|
||||
{
|
||||
// Arrange — pre-enable
|
||||
var existing = await _dataContext.ContentBlockerConfigs.FirstAsync();
|
||||
existing.Enabled = true;
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
var request = new UpdateMalwareBlockerConfigRequest
|
||||
{
|
||||
Enabled = false,
|
||||
CronExpression = "0/5 * * * * ?",
|
||||
Sonarr = new BlocklistSettings { Enabled = false },
|
||||
Radarr = new BlocklistSettings { Enabled = false },
|
||||
Lidarr = new BlocklistSettings { Enabled = false },
|
||||
Readarr = new BlocklistSettings { Enabled = false },
|
||||
Whisparr = new BlocklistSettings { Enabled = false },
|
||||
Sportarr = new BlocklistSettings { Enabled = false },
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _controller.UpdateMalwareBlockerConfig(request);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeOfType<OkObjectResult>();
|
||||
await _jobManagementService.Received(1).StopJob(JobType.MalwareBlocker);
|
||||
await _jobManagementService.DidNotReceive().StartJob(Arg.Any<JobType>(), Arg.Any<Cleanuparr.Infrastructure.Models.JobSchedule?>(), Arg.Any<string?>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateMalwareBlockerConfig_InvalidCronExpression_PropagatesValidationException()
|
||||
{
|
||||
// Arrange — CronValidationHelper throws Cleanuparr.Domain.Exceptions.ValidationException,
|
||||
// which the controller's catch (System.ComponentModel.DataAnnotations.ValidationException) does NOT match.
|
||||
var request = new UpdateMalwareBlockerConfigRequest
|
||||
{
|
||||
Enabled = true,
|
||||
CronExpression = "definitely-not-a-cron",
|
||||
Sonarr = new BlocklistSettings { Enabled = false },
|
||||
Radarr = new BlocklistSettings { Enabled = false },
|
||||
Lidarr = new BlocklistSettings { Enabled = false },
|
||||
Readarr = new BlocklistSettings { Enabled = false },
|
||||
Whisparr = new BlocklistSettings { Enabled = false },
|
||||
Sportarr = new BlocklistSettings { Enabled = false },
|
||||
};
|
||||
|
||||
// Act / Assert
|
||||
await Should.ThrowAsync<Cleanuparr.Domain.Exceptions.ValidationException>(
|
||||
() => _controller.UpdateMalwareBlockerConfig(request));
|
||||
await _jobManagementService.DidNotReceive().StartJob(Arg.Any<JobType>(), Arg.Any<Cleanuparr.Infrastructure.Models.JobSchedule?>(), Arg.Any<string?>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateMalwareBlockerConfig_PersistsChanges()
|
||||
{
|
||||
// Arrange
|
||||
var request = new UpdateMalwareBlockerConfigRequest
|
||||
{
|
||||
Enabled = true,
|
||||
CronExpression = "0/10 * * * * ?",
|
||||
IgnorePrivate = true,
|
||||
DeletePrivate = false,
|
||||
Sonarr = new BlocklistSettings { Enabled = true, BlocklistPath = "https://example.com/list.txt" },
|
||||
Radarr = new BlocklistSettings { Enabled = false },
|
||||
Lidarr = new BlocklistSettings { Enabled = false },
|
||||
Readarr = new BlocklistSettings { Enabled = false },
|
||||
Whisparr = new BlocklistSettings { Enabled = false },
|
||||
Sportarr = new BlocklistSettings { Enabled = false },
|
||||
IgnoredDownloads = new List<string> { "foo" },
|
||||
};
|
||||
|
||||
// Act
|
||||
await _controller.UpdateMalwareBlockerConfig(request);
|
||||
|
||||
// Assert
|
||||
var saved = await _dataContext.ContentBlockerConfigs.AsNoTracking().FirstAsync();
|
||||
saved.Enabled.ShouldBeTrue();
|
||||
saved.CronExpression.ShouldBe("0/10 * * * * ?");
|
||||
saved.IgnorePrivate.ShouldBeTrue();
|
||||
saved.Sonarr.Enabled.ShouldBeTrue();
|
||||
saved.IgnoredDownloads.ShouldContain("foo");
|
||||
}
|
||||
}
|
||||
+395
@@ -0,0 +1,395 @@
|
||||
using Cleanuparr.Api.Features.Notifications.Contracts.Requests;
|
||||
using Cleanuparr.Api.Features.Notifications.Contracts.Responses;
|
||||
using Cleanuparr.Api.Features.Notifications.Controllers;
|
||||
using Cleanuparr.Api.Tests.TestHelpers;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Apprise;
|
||||
using Cleanuparr.Persistence;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Features.Notifications;
|
||||
|
||||
public class NotificationProvidersControllerTests : IDisposable
|
||||
{
|
||||
private readonly DataContext _dataContext;
|
||||
private readonly NotificationProvidersController _controller;
|
||||
|
||||
public NotificationProvidersControllerTests()
|
||||
{
|
||||
_dataContext = ConfigControllerTestDataFactory.CreateDataContext();
|
||||
|
||||
INotificationConfigurationService configurationService =
|
||||
Substitute.For<INotificationConfigurationService>();
|
||||
|
||||
// NotificationService is sealed; the endpoints under test never reach it.
|
||||
NotificationService notificationService = new(
|
||||
Substitute.For<ILogger<NotificationService>>(),
|
||||
configurationService,
|
||||
Substitute.For<INotificationProviderFactory>());
|
||||
|
||||
_controller = new NotificationProvidersController(
|
||||
Substitute.For<ILogger<NotificationProvidersController>>(),
|
||||
_dataContext,
|
||||
configurationService,
|
||||
notificationService,
|
||||
Substitute.For<IAppriseCliDetector>());
|
||||
|
||||
ConfigControllerTestDataFactory.ConfigureProblemDetails(_controller);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_dataContext.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
private static NotificationProviderResponse Created(IActionResult result) =>
|
||||
result.ShouldBeOfType<CreatedAtActionResult>().Value.ShouldBeOfType<NotificationProviderResponse>();
|
||||
|
||||
private static NotificationProviderResponse Updated(IActionResult result) =>
|
||||
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBeOfType<NotificationProviderResponse>();
|
||||
|
||||
#region Notifiarr
|
||||
|
||||
[Fact]
|
||||
public async Task CreateNotifiarrProvider_PersistsTheDownloadStoppedEvent()
|
||||
{
|
||||
CreateNotifiarrProviderRequest request = new()
|
||||
{
|
||||
Name = "Notifiarr",
|
||||
ApiKey = "0123456789abcdef",
|
||||
ChannelId = "123456789",
|
||||
OnDownloadStopped = true,
|
||||
};
|
||||
|
||||
NotificationProviderResponse provider = Created(await _controller.CreateNotifiarrProvider(request));
|
||||
|
||||
provider.Type.ShouldBe(NotificationProviderType.Notifiarr);
|
||||
provider.Events.OnDownloadStopped.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateNotifiarrProvider_ChangesTheDownloadStoppedEvent()
|
||||
{
|
||||
Guid id = Created(await _controller.CreateNotifiarrProvider(new CreateNotifiarrProviderRequest
|
||||
{
|
||||
Name = "Notifiarr",
|
||||
ApiKey = "0123456789abcdef",
|
||||
ChannelId = "123456789",
|
||||
OnDownloadStopped = false,
|
||||
})).Id;
|
||||
|
||||
NotificationProviderResponse provider = Updated(await _controller.UpdateNotifiarrProvider(id,
|
||||
new UpdateNotifiarrProviderRequest
|
||||
{
|
||||
Name = "Notifiarr",
|
||||
ApiKey = "0123456789abcdef",
|
||||
ChannelId = "123456789",
|
||||
OnDownloadStopped = true,
|
||||
}));
|
||||
|
||||
provider.Events.OnDownloadStopped.ShouldBeTrue();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Apprise
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAppriseProvider_PersistsTheDownloadStoppedEvent()
|
||||
{
|
||||
CreateAppriseProviderRequest request = new()
|
||||
{
|
||||
Name = "Apprise",
|
||||
Mode = AppriseMode.Api,
|
||||
Url = "https://apprise.example.com",
|
||||
Key = "config-key",
|
||||
OnDownloadStopped = true,
|
||||
};
|
||||
|
||||
NotificationProviderResponse provider = Created(await _controller.CreateAppriseProvider(request));
|
||||
|
||||
provider.Type.ShouldBe(NotificationProviderType.Apprise);
|
||||
provider.Events.OnDownloadStopped.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAppriseProvider_ChangesTheDownloadStoppedEvent()
|
||||
{
|
||||
Guid id = Created(await _controller.CreateAppriseProvider(new CreateAppriseProviderRequest
|
||||
{
|
||||
Name = "Apprise",
|
||||
Mode = AppriseMode.Api,
|
||||
Url = "https://apprise.example.com",
|
||||
Key = "config-key",
|
||||
OnDownloadStopped = false,
|
||||
})).Id;
|
||||
|
||||
NotificationProviderResponse provider = Updated(await _controller.UpdateAppriseProvider(id,
|
||||
new UpdateAppriseProviderRequest
|
||||
{
|
||||
Name = "Apprise",
|
||||
Mode = AppriseMode.Api,
|
||||
Url = "https://apprise.example.com",
|
||||
Key = "config-key",
|
||||
OnDownloadStopped = true,
|
||||
}));
|
||||
|
||||
provider.Events.OnDownloadStopped.ShouldBeTrue();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Ntfy
|
||||
|
||||
[Fact]
|
||||
public async Task CreateNtfyProvider_PersistsTheDownloadStoppedEvent()
|
||||
{
|
||||
CreateNtfyProviderRequest request = new()
|
||||
{
|
||||
Name = "Ntfy",
|
||||
ServerUrl = "https://ntfy.sh",
|
||||
Topics = ["cleanuparr"],
|
||||
OnDownloadStopped = true,
|
||||
};
|
||||
|
||||
NotificationProviderResponse provider = Created(await _controller.CreateNtfyProvider(request));
|
||||
|
||||
provider.Type.ShouldBe(NotificationProviderType.Ntfy);
|
||||
provider.Events.OnDownloadStopped.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateNtfyProvider_ChangesTheDownloadStoppedEvent()
|
||||
{
|
||||
Guid id = Created(await _controller.CreateNtfyProvider(new CreateNtfyProviderRequest
|
||||
{
|
||||
Name = "Ntfy",
|
||||
ServerUrl = "https://ntfy.sh",
|
||||
Topics = ["cleanuparr"],
|
||||
OnDownloadStopped = false,
|
||||
})).Id;
|
||||
|
||||
NotificationProviderResponse provider = Updated(await _controller.UpdateNtfyProvider(id,
|
||||
new UpdateNtfyProviderRequest
|
||||
{
|
||||
Name = "Ntfy",
|
||||
ServerUrl = "https://ntfy.sh",
|
||||
Topics = ["cleanuparr"],
|
||||
OnDownloadStopped = true,
|
||||
}));
|
||||
|
||||
provider.Events.OnDownloadStopped.ShouldBeTrue();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Telegram
|
||||
|
||||
[Fact]
|
||||
public async Task CreateTelegramProvider_PersistsTheDownloadStoppedEvent()
|
||||
{
|
||||
CreateTelegramProviderRequest request = new()
|
||||
{
|
||||
Name = "Telegram",
|
||||
BotToken = "0123456789:token",
|
||||
ChatId = "-1001234567890",
|
||||
OnDownloadStopped = true,
|
||||
};
|
||||
|
||||
NotificationProviderResponse provider = Created(await _controller.CreateTelegramProvider(request));
|
||||
|
||||
provider.Type.ShouldBe(NotificationProviderType.Telegram);
|
||||
provider.Events.OnDownloadStopped.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateTelegramProvider_ChangesTheDownloadStoppedEvent()
|
||||
{
|
||||
Guid id = Created(await _controller.CreateTelegramProvider(new CreateTelegramProviderRequest
|
||||
{
|
||||
Name = "Telegram",
|
||||
BotToken = "0123456789:token",
|
||||
ChatId = "-1001234567890",
|
||||
OnDownloadStopped = false,
|
||||
})).Id;
|
||||
|
||||
NotificationProviderResponse provider = Updated(await _controller.UpdateTelegramProvider(id,
|
||||
new UpdateTelegramProviderRequest
|
||||
{
|
||||
Name = "Telegram",
|
||||
BotToken = "0123456789:token",
|
||||
ChatId = "-1001234567890",
|
||||
OnDownloadStopped = true,
|
||||
}));
|
||||
|
||||
provider.Events.OnDownloadStopped.ShouldBeTrue();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Discord
|
||||
|
||||
[Fact]
|
||||
public async Task CreateDiscordProvider_PersistsTheDownloadStoppedEvent()
|
||||
{
|
||||
CreateDiscordProviderRequest request = new()
|
||||
{
|
||||
Name = "Discord",
|
||||
WebhookUrl = "https://discord.com/api/webhooks/1/token",
|
||||
OnDownloadStopped = true,
|
||||
};
|
||||
|
||||
NotificationProviderResponse provider = Created(await _controller.CreateDiscordProvider(request));
|
||||
|
||||
provider.Type.ShouldBe(NotificationProviderType.Discord);
|
||||
provider.Events.OnDownloadStopped.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateDiscordProvider_ChangesTheDownloadStoppedEvent()
|
||||
{
|
||||
Guid id = Created(await _controller.CreateDiscordProvider(new CreateDiscordProviderRequest
|
||||
{
|
||||
Name = "Discord",
|
||||
WebhookUrl = "https://discord.com/api/webhooks/1/token",
|
||||
OnDownloadStopped = false,
|
||||
})).Id;
|
||||
|
||||
NotificationProviderResponse provider = Updated(await _controller.UpdateDiscordProvider(id,
|
||||
new UpdateDiscordProviderRequest
|
||||
{
|
||||
Name = "Discord",
|
||||
WebhookUrl = "https://discord.com/api/webhooks/1/token",
|
||||
OnDownloadStopped = true,
|
||||
}));
|
||||
|
||||
provider.Events.OnDownloadStopped.ShouldBeTrue();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Pushover
|
||||
|
||||
[Fact]
|
||||
public async Task CreatePushoverProvider_PersistsTheDownloadStoppedEvent()
|
||||
{
|
||||
CreatePushoverProviderRequest request = new()
|
||||
{
|
||||
Name = "Pushover",
|
||||
ApiToken = "api-token",
|
||||
UserKey = "user-key",
|
||||
OnDownloadStopped = true,
|
||||
};
|
||||
|
||||
NotificationProviderResponse provider = Created(await _controller.CreatePushoverProvider(request));
|
||||
|
||||
provider.Type.ShouldBe(NotificationProviderType.Pushover);
|
||||
provider.Events.OnDownloadStopped.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdatePushoverProvider_ChangesTheDownloadStoppedEvent()
|
||||
{
|
||||
Guid id = Created(await _controller.CreatePushoverProvider(new CreatePushoverProviderRequest
|
||||
{
|
||||
Name = "Pushover",
|
||||
ApiToken = "api-token",
|
||||
UserKey = "user-key",
|
||||
OnDownloadStopped = false,
|
||||
})).Id;
|
||||
|
||||
NotificationProviderResponse provider = Updated(await _controller.UpdatePushoverProvider(id,
|
||||
new UpdatePushoverProviderRequest
|
||||
{
|
||||
Name = "Pushover",
|
||||
ApiToken = "api-token",
|
||||
UserKey = "user-key",
|
||||
OnDownloadStopped = true,
|
||||
}));
|
||||
|
||||
provider.Events.OnDownloadStopped.ShouldBeTrue();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Gotify
|
||||
|
||||
[Fact]
|
||||
public async Task CreateGotifyProvider_PersistsTheDownloadStoppedEvent()
|
||||
{
|
||||
CreateGotifyProviderRequest request = new()
|
||||
{
|
||||
Name = "Gotify",
|
||||
ServerUrl = "https://gotify.example.com",
|
||||
ApplicationToken = "app-token",
|
||||
OnDownloadStopped = true,
|
||||
};
|
||||
|
||||
NotificationProviderResponse provider = Created(await _controller.CreateGotifyProvider(request));
|
||||
|
||||
provider.Type.ShouldBe(NotificationProviderType.Gotify);
|
||||
provider.Events.OnDownloadStopped.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateGotifyProvider_ChangesTheDownloadStoppedEvent()
|
||||
{
|
||||
Guid id = Created(await _controller.CreateGotifyProvider(new CreateGotifyProviderRequest
|
||||
{
|
||||
Name = "Gotify",
|
||||
ServerUrl = "https://gotify.example.com",
|
||||
ApplicationToken = "app-token",
|
||||
OnDownloadStopped = false,
|
||||
})).Id;
|
||||
|
||||
NotificationProviderResponse provider = Updated(await _controller.UpdateGotifyProvider(id,
|
||||
new UpdateGotifyProviderRequest
|
||||
{
|
||||
Name = "Gotify",
|
||||
ServerUrl = "https://gotify.example.com",
|
||||
ApplicationToken = "app-token",
|
||||
OnDownloadStopped = true,
|
||||
}));
|
||||
|
||||
provider.Events.OnDownloadStopped.ShouldBeTrue();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
[Fact]
|
||||
public async Task GetNotificationProviders_ReturnsTheDownloadStoppedEvent()
|
||||
{
|
||||
await _controller.CreateGotifyProvider(new CreateGotifyProviderRequest
|
||||
{
|
||||
Name = "Gotify",
|
||||
ServerUrl = "https://gotify.example.com",
|
||||
ApplicationToken = "app-token",
|
||||
OnDownloadStopped = true,
|
||||
});
|
||||
|
||||
NotificationProvidersResponse response = (await _controller.GetNotificationProviders())
|
||||
.ShouldBeOfType<OkObjectResult>().Value.ShouldBeOfType<NotificationProvidersResponse>();
|
||||
|
||||
response.Providers.ShouldHaveSingleItem().Events.OnDownloadStopped.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateGotifyProvider_WithoutEvents_LeavesDownloadStoppedDisabled()
|
||||
{
|
||||
NotificationProviderResponse provider = Created(await _controller.CreateGotifyProvider(
|
||||
new CreateGotifyProviderRequest
|
||||
{
|
||||
Name = "Gotify",
|
||||
ServerUrl = "https://gotify.example.com",
|
||||
ApplicationToken = "app-token",
|
||||
}));
|
||||
|
||||
provider.Events.OnDownloadStopped.ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
using Cleanuparr.Api.Features.QueueCleaner.Contracts.Requests;
|
||||
using Cleanuparr.Api.Features.QueueCleaner.Controllers;
|
||||
using Cleanuparr.Api.Tests.TestHelpers;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Services.Interfaces;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Features.QueueCleaner;
|
||||
|
||||
public class QueueCleanerConfigControllerTests : IDisposable
|
||||
{
|
||||
private readonly DataContext _dataContext;
|
||||
private readonly IJobManagementService _jobManagementService;
|
||||
private readonly QueueCleanerConfigController _controller;
|
||||
|
||||
public QueueCleanerConfigControllerTests()
|
||||
{
|
||||
_dataContext = ConfigControllerTestDataFactory.CreateDataContext();
|
||||
var logger = Substitute.For<ILogger<QueueCleanerConfigController>>();
|
||||
_jobManagementService = Substitute.For<IJobManagementService>();
|
||||
_controller = new QueueCleanerConfigController(logger, _dataContext, _jobManagementService);
|
||||
ConfigControllerTestDataFactory.ConfigureProblemDetails(_controller);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_dataContext.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetQueueCleanerConfig_ReturnsExistingConfig()
|
||||
{
|
||||
// Act
|
||||
var result = await _controller.GetQueueCleanerConfig();
|
||||
|
||||
// Assert
|
||||
var ok = result.ShouldBeOfType<OkObjectResult>();
|
||||
ok.Value.ShouldBeOfType<QueueCleanerConfig>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateQueueCleanerConfig_Enabled_StartsJob()
|
||||
{
|
||||
// Arrange
|
||||
var request = new UpdateQueueCleanerConfigRequest
|
||||
{
|
||||
Enabled = true,
|
||||
CronExpression = "0 0/5 * * * ?",
|
||||
FailedImport = new FailedImportConfig(),
|
||||
IgnoredDownloads = new List<string>(),
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _controller.UpdateQueueCleanerConfig(request);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeOfType<OkObjectResult>();
|
||||
await _jobManagementService.Received(1).StartJob(JobType.QueueCleaner, null, "0 0/5 * * * ?");
|
||||
await _jobManagementService.DidNotReceive().StopJob(Arg.Any<JobType>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateQueueCleanerConfig_Disabled_StopsJob()
|
||||
{
|
||||
// Arrange — pre-enable
|
||||
var existing = await _dataContext.QueueCleanerConfigs.FirstAsync();
|
||||
existing.Enabled = true;
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
var request = new UpdateQueueCleanerConfigRequest
|
||||
{
|
||||
Enabled = false,
|
||||
CronExpression = "0 0/5 * * * ?",
|
||||
FailedImport = new FailedImportConfig(),
|
||||
IgnoredDownloads = new List<string>(),
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _controller.UpdateQueueCleanerConfig(request);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeOfType<OkObjectResult>();
|
||||
await _jobManagementService.Received(1).StopJob(JobType.QueueCleaner);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateQueueCleanerConfig_InvalidCronExpression_PropagatesValidationException()
|
||||
{
|
||||
// Arrange — CronValidationHelper throws Cleanuparr.Domain.Exceptions.ValidationException,
|
||||
// which the controller's catch (System.ComponentModel.DataAnnotations.ValidationException) does NOT match.
|
||||
var request = new UpdateQueueCleanerConfigRequest
|
||||
{
|
||||
Enabled = true,
|
||||
CronExpression = "not-a-cron",
|
||||
FailedImport = new FailedImportConfig(),
|
||||
IgnoredDownloads = new List<string>(),
|
||||
};
|
||||
|
||||
// Act / Assert
|
||||
await Should.ThrowAsync<Cleanuparr.Domain.Exceptions.ValidationException>(
|
||||
() => _controller.UpdateQueueCleanerConfig(request));
|
||||
await _jobManagementService.DidNotReceive().StartJob(Arg.Any<JobType>(), Arg.Any<Cleanuparr.Infrastructure.Models.JobSchedule?>(), Arg.Any<string?>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateQueueCleanerConfig_ConfigValidationFails_ReturnsBadRequest()
|
||||
{
|
||||
// Arrange — DownloadingMetadataMaxStrikes < 3 (and > 0) triggers Validate() exception
|
||||
var request = new UpdateQueueCleanerConfigRequest
|
||||
{
|
||||
Enabled = true,
|
||||
CronExpression = "0 0/5 * * * ?",
|
||||
FailedImport = new FailedImportConfig(),
|
||||
DownloadingMetadataMaxStrikes = 2,
|
||||
IgnoredDownloads = new List<string>(),
|
||||
};
|
||||
|
||||
// Act + Assert
|
||||
await Should.ThrowAsync<System.ComponentModel.DataAnnotations.ValidationException>(
|
||||
() => _controller.UpdateQueueCleanerConfig(request));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateQueueCleanerConfig_PersistsChanges()
|
||||
{
|
||||
// Arrange
|
||||
var request = new UpdateQueueCleanerConfigRequest
|
||||
{
|
||||
Enabled = true,
|
||||
CronExpression = "0 0/10 * * * ?",
|
||||
FailedImport = new FailedImportConfig(),
|
||||
DownloadingMetadataMaxStrikes = 5,
|
||||
ProcessNoContentId = true,
|
||||
IgnoredDownloads = new List<string> { "ignored" },
|
||||
};
|
||||
|
||||
// Act
|
||||
await _controller.UpdateQueueCleanerConfig(request);
|
||||
|
||||
// Assert
|
||||
var saved = await _dataContext.QueueCleanerConfigs.AsNoTracking().FirstAsync();
|
||||
saved.Enabled.ShouldBeTrue();
|
||||
saved.CronExpression.ShouldBe("0 0/10 * * * ?");
|
||||
saved.DownloadingMetadataMaxStrikes.ShouldBe((ushort)5);
|
||||
saved.ProcessNoContentId.ShouldBeTrue();
|
||||
saved.IgnoredDownloads.ShouldContain("ignored");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
using Cleanuparr.Api.Features.QueueCleaner.Contracts.Requests;
|
||||
using Cleanuparr.Api.Features.QueueCleaner.Controllers;
|
||||
using Cleanuparr.Api.Tests.TestHelpers;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Models;
|
||||
using Cleanuparr.Infrastructure.Services.Interfaces;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Features.QueueCleaner;
|
||||
|
||||
public class QueueRulesControllerTests : IDisposable
|
||||
{
|
||||
private readonly DataContext _dataContext;
|
||||
private readonly IRuleIntervalValidator _validator;
|
||||
private readonly QueueRulesController _controller;
|
||||
|
||||
public QueueRulesControllerTests()
|
||||
{
|
||||
_dataContext = ConfigControllerTestDataFactory.CreateDataContext();
|
||||
var logger = Substitute.For<ILogger<QueueRulesController>>();
|
||||
_validator = Substitute.For<IRuleIntervalValidator>();
|
||||
_validator.ValidateStallRuleIntervals(Arg.Any<StallRule>(), Arg.Any<List<StallRule>>()).Returns(ValidationResult.Success());
|
||||
_validator.ValidateSlowRuleIntervals(Arg.Any<SlowRule>(), Arg.Any<List<SlowRule>>()).Returns(ValidationResult.Success());
|
||||
_controller = new QueueRulesController(logger, _dataContext, _validator);
|
||||
ConfigControllerTestDataFactory.ConfigureProblemDetails(_controller);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_dataContext.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
#region Stall Rules
|
||||
|
||||
[Fact]
|
||||
public async Task GetStallRules_EmptyDatabase_ReturnsEmptyList()
|
||||
{
|
||||
// Act
|
||||
var result = await _controller.GetStallRules();
|
||||
|
||||
// Assert
|
||||
var ok = result.ShouldBeOfType<OkObjectResult>();
|
||||
var rules = ok.Value.ShouldBeOfType<List<StallRule>>();
|
||||
rules.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateStallRule_NewName_ReturnsCreatedWithRule()
|
||||
{
|
||||
// Arrange
|
||||
var dto = NewStallDto(name: "default");
|
||||
|
||||
// Act
|
||||
var result = await _controller.CreateStallRule(dto);
|
||||
|
||||
// Assert
|
||||
var created = result.ShouldBeOfType<CreatedAtActionResult>();
|
||||
var rule = created.Value.ShouldBeOfType<StallRule>();
|
||||
rule.Name.ShouldBe("default");
|
||||
rule.Id.ShouldNotBe(Guid.Empty);
|
||||
(await _dataContext.StallRules.CountAsync()).ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateStallRule_DuplicateName_ReturnsBadRequest()
|
||||
{
|
||||
// Arrange — seed existing rule
|
||||
await _controller.CreateStallRule(NewStallDto(name: "rule-a"));
|
||||
|
||||
// Act — try to create another with same name (case-insensitive)
|
||||
var result = await _controller.CreateStallRule(NewStallDto(name: "RULE-A"));
|
||||
|
||||
// Assert
|
||||
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(400);
|
||||
(await _dataContext.StallRules.CountAsync()).ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateStallRule_IntervalValidatorFails_ReturnsBadRequest()
|
||||
{
|
||||
// Arrange
|
||||
_validator.ValidateStallRuleIntervals(Arg.Any<StallRule>(), Arg.Any<List<StallRule>>())
|
||||
.Returns(ValidationResult.Failure("overlaps with existing rule"));
|
||||
|
||||
// Act
|
||||
var result = await _controller.CreateStallRule(NewStallDto(name: "x"));
|
||||
|
||||
// Assert
|
||||
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(400);
|
||||
(await _dataContext.StallRules.CountAsync()).ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateStallRule_NonExistentId_ReturnsNotFound()
|
||||
{
|
||||
// Act
|
||||
var result = await _controller.UpdateStallRule(Guid.NewGuid(), NewStallDto("x"));
|
||||
|
||||
// Assert
|
||||
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(404);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateStallRule_ExistingRule_PersistsChanges()
|
||||
{
|
||||
// Arrange
|
||||
var create = (CreatedAtActionResult)await _controller.CreateStallRule(NewStallDto(name: "orig"));
|
||||
var id = ((StallRule)create.Value!).Id;
|
||||
|
||||
// Act
|
||||
var result = await _controller.UpdateStallRule(id, NewStallDto(name: "renamed"));
|
||||
|
||||
// Assert
|
||||
var ok = result.ShouldBeOfType<OkObjectResult>();
|
||||
var rule = ok.Value.ShouldBeOfType<StallRule>();
|
||||
rule.Name.ShouldBe("renamed");
|
||||
var saved = await _dataContext.StallRules.AsNoTracking().FirstAsync(r => r.Id == id);
|
||||
saved.Name.ShouldBe("renamed");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateStallRule_DuplicateNameOtherRule_ReturnsBadRequest()
|
||||
{
|
||||
// Arrange
|
||||
await _controller.CreateStallRule(NewStallDto(name: "alpha"));
|
||||
var betaCreate = (CreatedAtActionResult)await _controller.CreateStallRule(NewStallDto(name: "beta"));
|
||||
var betaId = ((StallRule)betaCreate.Value!).Id;
|
||||
|
||||
// Act — try to rename beta → alpha
|
||||
var result = await _controller.UpdateStallRule(betaId, NewStallDto(name: "alpha"));
|
||||
|
||||
// Assert
|
||||
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(400);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteStallRule_ExistingRule_ReturnsNoContent()
|
||||
{
|
||||
// Arrange
|
||||
var create = (CreatedAtActionResult)await _controller.CreateStallRule(NewStallDto(name: "doomed"));
|
||||
var id = ((StallRule)create.Value!).Id;
|
||||
|
||||
// Act
|
||||
var result = await _controller.DeleteStallRule(id);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeOfType<NoContentResult>();
|
||||
(await _dataContext.StallRules.CountAsync()).ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteStallRule_NonExistent_ReturnsNotFound()
|
||||
{
|
||||
// Act
|
||||
var result = await _controller.DeleteStallRule(Guid.NewGuid());
|
||||
|
||||
// Assert
|
||||
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(404);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Slow Rules
|
||||
|
||||
[Fact]
|
||||
public async Task GetSlowRules_EmptyDatabase_ReturnsEmptyList()
|
||||
{
|
||||
// Act
|
||||
var result = await _controller.GetSlowRules();
|
||||
|
||||
// Assert
|
||||
var ok = result.ShouldBeOfType<OkObjectResult>();
|
||||
var rules = ok.Value.ShouldBeOfType<List<SlowRule>>();
|
||||
rules.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateSlowRule_NewName_ReturnsCreated()
|
||||
{
|
||||
// Arrange
|
||||
var dto = NewSlowDto(name: "slow-default");
|
||||
|
||||
// Act
|
||||
var result = await _controller.CreateSlowRule(dto);
|
||||
|
||||
// Assert
|
||||
var created = result.ShouldBeOfType<CreatedAtActionResult>();
|
||||
var rule = created.Value.ShouldBeOfType<SlowRule>();
|
||||
rule.Name.ShouldBe("slow-default");
|
||||
(await _dataContext.SlowRules.CountAsync()).ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateSlowRule_DuplicateName_ReturnsBadRequest()
|
||||
{
|
||||
// Arrange
|
||||
await _controller.CreateSlowRule(NewSlowDto(name: "x"));
|
||||
|
||||
// Act
|
||||
var result = await _controller.CreateSlowRule(NewSlowDto(name: "X"));
|
||||
|
||||
// Assert
|
||||
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(400);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSlowRule_NonExistentId_ReturnsNotFound()
|
||||
{
|
||||
// Act
|
||||
var result = await _controller.UpdateSlowRule(Guid.NewGuid(), NewSlowDto("x"));
|
||||
|
||||
// Assert
|
||||
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(404);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteSlowRule_NonExistent_ReturnsNotFound()
|
||||
{
|
||||
// Act
|
||||
var result = await _controller.DeleteSlowRule(Guid.NewGuid());
|
||||
|
||||
// Assert
|
||||
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(404);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private static StallRuleDto NewStallDto(string name) => new()
|
||||
{
|
||||
Name = name,
|
||||
Enabled = true,
|
||||
MaxStrikes = 3,
|
||||
PrivacyType = TorrentPrivacyType.Public,
|
||||
MinCompletionPercentage = 0,
|
||||
MaxCompletionPercentage = 100,
|
||||
ResetStrikesOnProgress = true,
|
||||
};
|
||||
|
||||
private static SlowRuleDto NewSlowDto(string name) => new()
|
||||
{
|
||||
Name = name,
|
||||
Enabled = true,
|
||||
MaxStrikes = 3,
|
||||
PrivacyType = TorrentPrivacyType.Public,
|
||||
MinCompletionPercentage = 0,
|
||||
MaxCompletionPercentage = 100,
|
||||
ResetStrikesOnProgress = true,
|
||||
MinSpeed = "1MB",
|
||||
};
|
||||
}
|
||||
+556
@@ -0,0 +1,556 @@
|
||||
using System.Text.Json;
|
||||
using Cleanuparr.Api.Features.Seeker.Contracts.Responses;
|
||||
using Cleanuparr.Api.Features.Seeker.Controllers;
|
||||
using Cleanuparr.Api.Tests.Features.Seeker.TestHelpers;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.State;
|
||||
using Cleanuparr.Persistence.Providers;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Shouldly;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Features.Seeker;
|
||||
|
||||
public class CustomFormatScoreControllerTests : IDisposable
|
||||
{
|
||||
private readonly DataContext _dataContext;
|
||||
private readonly EventsContext _eventsContext;
|
||||
private readonly CustomFormatScoreController _controller;
|
||||
|
||||
public CustomFormatScoreControllerTests()
|
||||
{
|
||||
_dataContext = SeekerTestDataFactory.CreateDataContext();
|
||||
_eventsContext = SeekerTestDataFactory.CreateEventsContext();
|
||||
_controller = new CustomFormatScoreController(_dataContext, _eventsContext, new SqliteDatabaseProvider());
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_dataContext.Dispose();
|
||||
_eventsContext.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
private static JsonElement GetResponseBody(IActionResult result)
|
||||
{
|
||||
var okResult = result.ShouldBeOfType<OkObjectResult>();
|
||||
var json = JsonSerializer.Serialize(okResult.Value);
|
||||
return JsonDocument.Parse(json).RootElement;
|
||||
}
|
||||
|
||||
#region GetCustomFormatScores Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetCustomFormatScores_WithPageBelowMinimum_ClampsToOne()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
AddScoreEntry(radarr.Id, 1, "Movie A", currentScore: 100, cutoffScore: 500);
|
||||
AddScoreEntry(radarr.Id, 2, "Movie B", currentScore: 200, cutoffScore: 500);
|
||||
|
||||
var result = await _controller.GetCustomFormatScores(page: -5, pageSize: 50);
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("Page").GetInt32().ShouldBe(1);
|
||||
body.GetProperty("Items").GetArrayLength().ShouldBe(2);
|
||||
}
|
||||
|
||||
// The sentinel is not a database value.
|
||||
[Fact]
|
||||
public async Task GetCustomFormatScores_WithTheUnknownItemType_IgnoresTheFilter()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
AddScoreEntry(radarr.Id, 1, "Movie A", currentScore: 100, cutoffScore: 500);
|
||||
|
||||
var result = await _controller.GetCustomFormatScores(itemType: InstanceType.Unknown);
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("Items").GetArrayLength().ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCustomFormatScores_WithPageSizeAboveMaximum_ClampsToHundred()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
AddScoreEntry(radarr.Id, 1, "Movie A", currentScore: 100, cutoffScore: 500);
|
||||
|
||||
var result = await _controller.GetCustomFormatScores(page: 1, pageSize: 999);
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("PageSize").GetInt32().ShouldBe(500);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCustomFormatScores_WithHideMetTrue_ExcludesItemsAtOrAboveCutoff()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
AddScoreEntry(radarr.Id, 1, "Below Cutoff", currentScore: 100, cutoffScore: 500);
|
||||
AddScoreEntry(radarr.Id, 2, "At Cutoff", currentScore: 500, cutoffScore: 500);
|
||||
AddScoreEntry(radarr.Id, 3, "Above Cutoff", currentScore: 600, cutoffScore: 500);
|
||||
|
||||
var result = await _controller.GetCustomFormatScores(cutoffFilter: CutoffFilter.Below);
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("TotalCount").GetInt32().ShouldBe(1);
|
||||
body.GetProperty("Items")[0].GetProperty("Title").GetString().ShouldBe("Below Cutoff");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCustomFormatScores_WithHideUnmonitoredTrue_ExcludesUnmonitoredItems()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
AddScoreEntry(radarr.Id, 1, "Monitored Movie", currentScore: 100, cutoffScore: 500, isMonitored: true);
|
||||
AddScoreEntry(radarr.Id, 2, "Unmonitored Movie", currentScore: 200, cutoffScore: 500, isMonitored: false);
|
||||
AddScoreEntry(radarr.Id, 3, "Another Monitored", currentScore: 300, cutoffScore: 500, isMonitored: true);
|
||||
|
||||
var result = await _controller.GetCustomFormatScores(monitoredFilter: MonitoredFilter.Monitored);
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("TotalCount").GetInt32().ShouldBe(2);
|
||||
var items = body.GetProperty("Items");
|
||||
items.GetArrayLength().ShouldBe(2);
|
||||
items[0].GetProperty("Title").GetString().ShouldBe("Another Monitored");
|
||||
items[1].GetProperty("Title").GetString().ShouldBe("Monitored Movie");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCustomFormatScores_WithSearchFilter_ReturnsMatchingTitlesOnly()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
AddScoreEntry(radarr.Id, 1, "The Matrix", currentScore: 100, cutoffScore: 500);
|
||||
AddScoreEntry(radarr.Id, 2, "Inception", currentScore: 200, cutoffScore: 500);
|
||||
AddScoreEntry(radarr.Id, 3, "The Matrix Reloaded", currentScore: 300, cutoffScore: 500);
|
||||
|
||||
var result = await _controller.GetCustomFormatScores(search: "matrix");
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("TotalCount").GetInt32().ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCustomFormatScores_WithSortByDate_OrdersByLastSyncedDescending()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
AddScoreEntry(radarr.Id, 1, "Older", currentScore: 100, cutoffScore: 500,
|
||||
lastSynced: DateTime.UtcNow.AddHours(-2));
|
||||
AddScoreEntry(radarr.Id, 2, "Newer", currentScore: 200, cutoffScore: 500,
|
||||
lastSynced: DateTime.UtcNow.AddHours(-1));
|
||||
|
||||
var result = await _controller.GetCustomFormatScores(sortBy: CfScoresSortBy.LastSyncedAt);
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("Items")[0].GetProperty("Title").GetString().ShouldBe("Newer");
|
||||
body.GetProperty("Items")[1].GetProperty("Title").GetString().ShouldBe("Older");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCustomFormatScores_WithInstanceIdFilter_ReturnsOnlyThatInstance()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
var sonarr = SeekerTestDataFactory.AddSonarrInstance(_dataContext);
|
||||
AddScoreEntry(radarr.Id, 1, "Movie", currentScore: 100, cutoffScore: 500);
|
||||
AddScoreEntry(sonarr.Id, 2, "Series", currentScore: 200, cutoffScore: 500,
|
||||
itemType: InstanceType.Sonarr);
|
||||
|
||||
var result = await _controller.GetCustomFormatScores(instanceId: radarr.Id);
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("TotalCount").GetInt32().ShouldBe(1);
|
||||
body.GetProperty("Items")[0].GetProperty("Title").GetString().ShouldBe("Movie");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCustomFormatScores_ReturnsCorrectTotalPagesCalculation()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
for (int i = 1; i <= 7; i++)
|
||||
{
|
||||
AddScoreEntry(radarr.Id, i, $"Movie {i}", currentScore: 100, cutoffScore: 500);
|
||||
}
|
||||
|
||||
var result = await _controller.GetCustomFormatScores(page: 1, pageSize: 3);
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("TotalCount").GetInt32().ShouldBe(7);
|
||||
body.GetProperty("TotalPages").GetInt32().ShouldBe(3); // ceil(7/3) = 3
|
||||
body.GetProperty("Items").GetArrayLength().ShouldBe(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCustomFormatScores_WithCutoffFilterMet_ExcludesBelowCutoff()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
AddScoreEntry(radarr.Id, 1, "Below", currentScore: 100, cutoffScore: 500);
|
||||
AddScoreEntry(radarr.Id, 2, "At", currentScore: 500, cutoffScore: 500);
|
||||
AddScoreEntry(radarr.Id, 3, "Above", currentScore: 600, cutoffScore: 500);
|
||||
|
||||
var result = await _controller.GetCustomFormatScores(cutoffFilter: CutoffFilter.Met);
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("TotalCount").GetInt32().ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCustomFormatScores_WithCutoffFilterAll_IncludesEverything()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
AddScoreEntry(radarr.Id, 1, "Below", currentScore: 100, cutoffScore: 500);
|
||||
AddScoreEntry(radarr.Id, 2, "Above", currentScore: 600, cutoffScore: 500);
|
||||
|
||||
var result = await _controller.GetCustomFormatScores(cutoffFilter: CutoffFilter.All);
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("TotalCount").GetInt32().ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCustomFormatScores_WithMonitoredFilterUnmonitored_ReturnsOnlyUnmonitored()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
AddScoreEntry(radarr.Id, 1, "A", currentScore: 100, cutoffScore: 500, isMonitored: true);
|
||||
AddScoreEntry(radarr.Id, 2, "B", currentScore: 100, cutoffScore: 500, isMonitored: false);
|
||||
|
||||
var result = await _controller.GetCustomFormatScores(monitoredFilter: MonitoredFilter.Unmonitored);
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("TotalCount").GetInt32().ShouldBe(1);
|
||||
body.GetProperty("Items")[0].GetProperty("Title").GetString().ShouldBe("B");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCustomFormatScores_WithQualityProfileFilter_ReturnsOnlyMatchingProfile()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
AddScoreEntry(radarr.Id, 1, "HD Movie", currentScore: 100, cutoffScore: 500, qualityProfileName: "HD");
|
||||
AddScoreEntry(radarr.Id, 2, "UHD Movie", currentScore: 200, cutoffScore: 500, qualityProfileName: "UHD");
|
||||
|
||||
var result = await _controller.GetCustomFormatScores(qualityProfile: "UHD");
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("TotalCount").GetInt32().ShouldBe(1);
|
||||
body.GetProperty("Items")[0].GetProperty("Title").GetString().ShouldBe("UHD Movie");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCustomFormatScores_WithExplicitSortDirectionAsc_OverridesDefault()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
AddScoreEntry(radarr.Id, 1, "A", currentScore: 100, cutoffScore: 500);
|
||||
AddScoreEntry(radarr.Id, 2, "B", currentScore: 300, cutoffScore: 500);
|
||||
|
||||
// CurrentScore default is descending; overriding with Asc should flip it.
|
||||
var result = await _controller.GetCustomFormatScores(
|
||||
sortBy: CfScoresSortBy.CurrentScore,
|
||||
sortDirection: Cleanuparr.Domain.Enums.SortDirection.Asc);
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
var items = body.GetProperty("Items");
|
||||
items[0].GetProperty("CurrentScore").GetInt32().ShouldBe(100);
|
||||
items[1].GetProperty("CurrentScore").GetInt32().ShouldBe(300);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCustomFormatScores_WithSortByTitleDescending_OrdersReverseAlphabetically()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
AddScoreEntry(radarr.Id, 1, "Apple", currentScore: 100, cutoffScore: 500);
|
||||
AddScoreEntry(radarr.Id, 2, "Banana", currentScore: 200, cutoffScore: 500);
|
||||
|
||||
var result = await _controller.GetCustomFormatScores(
|
||||
sortBy: CfScoresSortBy.Title,
|
||||
sortDirection: Cleanuparr.Domain.Enums.SortDirection.Desc);
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("Items")[0].GetProperty("Title").GetString().ShouldBe("Banana");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetRecentUpgrades Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetRecentUpgrades_WithNoHistory_ReturnsEmptyList()
|
||||
{
|
||||
var result = await _controller.GetRecentUpgrades();
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("TotalCount").GetInt32().ShouldBe(0);
|
||||
body.GetProperty("Items").GetArrayLength().ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetRecentUpgrades_WithSingleEntryPerItem_ReturnsNoUpgrades()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 100, recordedAt: DateTime.UtcNow.AddDays(-1));
|
||||
|
||||
var result = await _controller.GetRecentUpgrades();
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("TotalCount").GetInt32().ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetRecentUpgrades_WithScoreIncrease_DetectsUpgrade()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 100, recordedAt: DateTime.UtcNow.AddDays(-2));
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 250, recordedAt: DateTime.UtcNow.AddDays(-1));
|
||||
|
||||
var result = await _controller.GetRecentUpgrades();
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("TotalCount").GetInt32().ShouldBe(1);
|
||||
var upgrade = body.GetProperty("Items")[0];
|
||||
upgrade.GetProperty("PreviousScore").GetInt32().ShouldBe(100);
|
||||
upgrade.GetProperty("NewScore").GetInt32().ShouldBe(250);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetRecentUpgrades_WithScoreDecrease_DoesNotCountAsUpgrade()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 300, recordedAt: DateTime.UtcNow.AddDays(-2));
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 150, recordedAt: DateTime.UtcNow.AddDays(-1));
|
||||
|
||||
var result = await _controller.GetRecentUpgrades();
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("TotalCount").GetInt32().ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetRecentUpgrades_WithMultipleUpgradesInSameGroup_CountsEach()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
// 100 -> 200 -> 300 = two upgrades for the same item
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 100, recordedAt: DateTime.UtcNow.AddDays(-3));
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 200, recordedAt: DateTime.UtcNow.AddDays(-2));
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 300, recordedAt: DateTime.UtcNow.AddDays(-1));
|
||||
|
||||
var result = await _controller.GetRecentUpgrades();
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("TotalCount").GetInt32().ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetRecentUpgrades_WithDaysFilter_ExcludesOlderHistory()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
// Old upgrade (outside 7-day window)
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 100, recordedAt: DateTime.UtcNow.AddDays(-20));
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 250, recordedAt: DateTime.UtcNow.AddDays(-15));
|
||||
// Recent upgrade (inside 7-day window)
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 2, score: 100, recordedAt: DateTime.UtcNow.AddDays(-3));
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 2, score: 300, recordedAt: DateTime.UtcNow.AddDays(-1));
|
||||
|
||||
var result = await _controller.GetRecentUpgrades(days: 7);
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("TotalCount").GetInt32().ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetRecentUpgrades_WithUpgradeCrossingWindowBoundary_IsDetected()
|
||||
{
|
||||
// CR2: pre-window baseline must still participate so the first in-window
|
||||
// row can be recognised as an upgrade.
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 100, recordedAt: DateTime.UtcNow.AddDays(-10));
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 200, recordedAt: DateTime.UtcNow.AddDays(-3));
|
||||
|
||||
var result = await _controller.GetRecentUpgrades(days: 7);
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("TotalCount").GetInt32().ShouldBe(1);
|
||||
var upgrade = body.GetProperty("Items")[0];
|
||||
upgrade.GetProperty("PreviousScore").GetInt32().ShouldBe(100);
|
||||
upgrade.GetProperty("NewScore").GetInt32().ShouldBe(200);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetRecentUpgrades_WithSortByScoreDeltaDescending_OrdersByLargestDelta()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
// Item 1: +50
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 100, recordedAt: DateTime.UtcNow.AddDays(-3));
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 150, recordedAt: DateTime.UtcNow.AddDays(-2));
|
||||
// Item 2: +400
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 2, score: 100, recordedAt: DateTime.UtcNow.AddDays(-3));
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 2, score: 500, recordedAt: DateTime.UtcNow.AddDays(-2));
|
||||
|
||||
var result = await _controller.GetRecentUpgrades(sortBy: CfUpgradesSortBy.ScoreDelta);
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
var items = body.GetProperty("Items");
|
||||
items[0].GetProperty("NewScore").GetInt32().ShouldBe(500);
|
||||
items[1].GetProperty("NewScore").GetInt32().ShouldBe(150);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetRecentUpgrades_WithSortByTitleAscending_OrdersAlphabetically()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 2, score: 100, recordedAt: DateTime.UtcNow.AddDays(-3));
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 2, score: 200, recordedAt: DateTime.UtcNow.AddDays(-2));
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 100, recordedAt: DateTime.UtcNow.AddDays(-3));
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 200, recordedAt: DateTime.UtcNow.AddDays(-2));
|
||||
|
||||
var result = await _controller.GetRecentUpgrades(sortBy: CfUpgradesSortBy.Title);
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
var items = body.GetProperty("Items");
|
||||
items[0].GetProperty("Title").GetString().ShouldBe("Item 1");
|
||||
items[1].GetProperty("Title").GetString().ShouldBe("Item 2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetRecentUpgrades_WithSearchFilter_ReturnsMatchingTitlesOnly()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
// AddHistoryEntry titles as "Item {externalItemId}".
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 42, score: 100, recordedAt: DateTime.UtcNow.AddDays(-3));
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 42, score: 200, recordedAt: DateTime.UtcNow.AddDays(-2));
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 99, score: 100, recordedAt: DateTime.UtcNow.AddDays(-3));
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 99, score: 200, recordedAt: DateTime.UtcNow.AddDays(-2));
|
||||
|
||||
var result = await _controller.GetRecentUpgrades(search: "42");
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("TotalCount").GetInt32().ShouldBe(1);
|
||||
body.GetProperty("Items")[0].GetProperty("Title").GetString().ShouldBe("Item 42");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetRecentUpgrades_ReturnsSortedByMostRecentFirst()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
// Item 1: upgrade happened 5 days ago
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 100, recordedAt: DateTime.UtcNow.AddDays(-6));
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 200, recordedAt: DateTime.UtcNow.AddDays(-5));
|
||||
// Item 2: upgrade happened 1 day ago
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 2, score: 100, recordedAt: DateTime.UtcNow.AddDays(-2));
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 2, score: 300, recordedAt: DateTime.UtcNow.AddDays(-1));
|
||||
|
||||
var result = await _controller.GetRecentUpgrades();
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
var items = body.GetProperty("Items");
|
||||
items.GetArrayLength().ShouldBe(2);
|
||||
// Most recent upgrade (item 2) should be first
|
||||
items[0].GetProperty("NewScore").GetInt32().ShouldBe(300);
|
||||
items[1].GetProperty("NewScore").GetInt32().ShouldBe(200);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetStats Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetStats_WithNoEntries_ReturnsZeroes()
|
||||
{
|
||||
var result = await _controller.GetStats();
|
||||
var okResult = result.ShouldBeOfType<OkObjectResult>();
|
||||
var stats = okResult.Value.ShouldBeOfType<CustomFormatScoreStatsResponse>();
|
||||
|
||||
stats.TotalTracked.ShouldBe(0);
|
||||
stats.BelowCutoff.ShouldBe(0);
|
||||
stats.AtOrAboveCutoff.ShouldBe(0);
|
||||
stats.RecentUpgrades.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStats_CorrectlyCategorizesBelowAndAboveCutoff()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
AddScoreEntry(radarr.Id, 1, "Below", currentScore: 100, cutoffScore: 500);
|
||||
AddScoreEntry(radarr.Id, 2, "At", currentScore: 500, cutoffScore: 500);
|
||||
AddScoreEntry(radarr.Id, 3, "Above", currentScore: 600, cutoffScore: 500);
|
||||
|
||||
var result = await _controller.GetStats();
|
||||
var okResult = result.ShouldBeOfType<OkObjectResult>();
|
||||
var stats = okResult.Value.ShouldBeOfType<CustomFormatScoreStatsResponse>();
|
||||
|
||||
stats.TotalTracked.ShouldBe(3);
|
||||
stats.BelowCutoff.ShouldBe(1);
|
||||
stats.AtOrAboveCutoff.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStats_CountsRecentUpgradesFromLast7Days()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
AddScoreEntry(radarr.Id, 1, "Movie", currentScore: 300, cutoffScore: 500);
|
||||
|
||||
// Upgrade within 7 days
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 100, recordedAt: DateTime.UtcNow.AddDays(-3));
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 300, recordedAt: DateTime.UtcNow.AddDays(-1));
|
||||
|
||||
// Upgrade outside 7 days (should not be counted)
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 2, score: 50, recordedAt: DateTime.UtcNow.AddDays(-20));
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 2, score: 200, recordedAt: DateTime.UtcNow.AddDays(-15));
|
||||
|
||||
var result = await _controller.GetStats();
|
||||
var okResult = result.ShouldBeOfType<OkObjectResult>();
|
||||
var stats = okResult.Value.ShouldBeOfType<CustomFormatScoreStatsResponse>();
|
||||
|
||||
stats.RecentUpgrades.ShouldBe(1);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helpers
|
||||
|
||||
private void AddScoreEntry(
|
||||
Guid arrInstanceId,
|
||||
long externalItemId,
|
||||
string title,
|
||||
int currentScore,
|
||||
int cutoffScore,
|
||||
InstanceType itemType = InstanceType.Radarr,
|
||||
DateTime? lastSynced = null,
|
||||
bool isMonitored = true,
|
||||
string qualityProfileName = "HD")
|
||||
{
|
||||
_eventsContext.CustomFormatScoreEntries.Add(new CustomFormatScoreEntry
|
||||
{
|
||||
ArrInstanceId = arrInstanceId,
|
||||
ExternalItemId = externalItemId,
|
||||
EpisodeId = 0,
|
||||
ItemType = itemType,
|
||||
Title = title,
|
||||
FileId = externalItemId * 10,
|
||||
CurrentScore = currentScore,
|
||||
CutoffScore = cutoffScore,
|
||||
QualityProfileName = qualityProfileName,
|
||||
IsMonitored = isMonitored,
|
||||
LastSyncedAt = lastSynced ?? DateTime.UtcNow
|
||||
});
|
||||
_eventsContext.SaveChanges();
|
||||
}
|
||||
|
||||
private void AddHistoryEntry(
|
||||
Guid arrInstanceId,
|
||||
long externalItemId,
|
||||
int score,
|
||||
DateTime recordedAt,
|
||||
long episodeId = 0,
|
||||
int cutoffScore = 500,
|
||||
InstanceType itemType = InstanceType.Radarr)
|
||||
{
|
||||
_eventsContext.CustomFormatScoreHistory.Add(new CustomFormatScoreHistory
|
||||
{
|
||||
ArrInstanceId = arrInstanceId,
|
||||
ExternalItemId = externalItemId,
|
||||
EpisodeId = episodeId,
|
||||
ItemType = itemType,
|
||||
Title = $"Item {externalItemId}",
|
||||
Score = score,
|
||||
CutoffScore = cutoffScore,
|
||||
RecordedAt = recordedAt
|
||||
});
|
||||
_eventsContext.SaveChanges();
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
using System.Text.Json;
|
||||
using Cleanuparr.Api.Features.Seeker.Controllers;
|
||||
using Cleanuparr.Api.Tests.Features.Seeker.TestHelpers;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Events;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Shouldly;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Features.Seeker;
|
||||
|
||||
public class SearchStatsControllerTests : IDisposable
|
||||
{
|
||||
private readonly DataContext _dataContext;
|
||||
private readonly EventsContext _eventsContext;
|
||||
private readonly SearchStatsController _controller;
|
||||
|
||||
public SearchStatsControllerTests()
|
||||
{
|
||||
_dataContext = SeekerTestDataFactory.CreateDataContext();
|
||||
_eventsContext = SeekerTestDataFactory.CreateEventsContext();
|
||||
_controller = new SearchStatsController(_dataContext, _eventsContext);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_dataContext.Dispose();
|
||||
_eventsContext.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
private static JsonElement GetResponseBody(IActionResult result)
|
||||
{
|
||||
var okResult = result.ShouldBeOfType<OkObjectResult>();
|
||||
var json = JsonSerializer.Serialize(okResult.Value);
|
||||
return JsonDocument.Parse(json).RootElement;
|
||||
}
|
||||
|
||||
#region GetEvents with SearchEventData
|
||||
|
||||
[Fact]
|
||||
public async Task GetEvents_WithNoSearchEventData_ReturnsUnknownDefaults()
|
||||
{
|
||||
AddSearchEvent();
|
||||
|
||||
var result = await _controller.GetEvents();
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
var item = body.GetProperty("Items")[0];
|
||||
item.GetProperty("ItemTitle").GetString().ShouldBe("Unknown");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEvents_WithSearchEventData_ReturnsAllFields()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
|
||||
AddSearchEvent(
|
||||
arrInstanceId: radarr.Id,
|
||||
itemTitle: "Movie A",
|
||||
searchType: SeekerSearchType.Proactive,
|
||||
searchReason: SeekerSearchReason.Missing,
|
||||
grabbedItems: ["Movie A (2024)"]);
|
||||
|
||||
var result = await _controller.GetEvents();
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
var item = body.GetProperty("Items")[0];
|
||||
item.GetProperty("ArrInstanceId").GetString().ShouldBe(radarr.Id.ToString());
|
||||
item.GetProperty("InstanceType").GetString().ShouldBe(nameof(InstanceType.Radarr));
|
||||
item.GetProperty("ItemTitle").GetString().ShouldBe("Movie A");
|
||||
item.GetProperty("SearchType").GetString().ShouldBe(nameof(SeekerSearchType.Proactive));
|
||||
item.GetProperty("SearchReason").GetString().ShouldBe(nameof(SeekerSearchReason.Missing));
|
||||
item.GetProperty("GrabbedItems")[0].GetString().ShouldBe("Movie A (2024)");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEvents_WithReplacementSearchType_ParsesCorrectEnum()
|
||||
{
|
||||
AddSearchEvent(
|
||||
itemTitle: "Series A",
|
||||
searchType: SeekerSearchType.Replacement,
|
||||
searchReason: SeekerSearchReason.Replacement);
|
||||
|
||||
var result = await _controller.GetEvents();
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
var item = body.GetProperty("Items")[0];
|
||||
item.GetProperty("SearchType").GetString().ShouldBe(nameof(SeekerSearchType.Replacement));
|
||||
item.GetProperty("SearchReason").GetString().ShouldBe(nameof(SeekerSearchReason.Replacement));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetEvents Filtering
|
||||
|
||||
[Fact]
|
||||
public async Task GetEvents_WithInstanceIdFilter_FiltersByArrInstanceId()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
var sonarr = SeekerTestDataFactory.AddSonarrInstance(_dataContext);
|
||||
|
||||
AddSearchEvent(arrInstanceId: radarr.Id, itemTitle: "Radarr Movie");
|
||||
AddSearchEvent(arrInstanceId: sonarr.Id, itemTitle: "Sonarr Series");
|
||||
|
||||
var result = await _controller.GetEvents(instanceId: radarr.Id);
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("TotalCount").GetInt32().ShouldBe(1);
|
||||
body.GetProperty("Items")[0].GetProperty("ArrInstanceId").GetString().ShouldBe(radarr.Id.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEvents_WithCycleIdFilter_ReturnsOnlyMatchingCycle()
|
||||
{
|
||||
var cycleA = Guid.NewGuid();
|
||||
var cycleB = Guid.NewGuid();
|
||||
|
||||
AddSearchEvent(cycleId: cycleA, itemTitle: "Cycle A Movie");
|
||||
AddSearchEvent(cycleId: cycleB, itemTitle: "Cycle B Movie");
|
||||
|
||||
var result = await _controller.GetEvents(cycleId: cycleA);
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("TotalCount").GetInt32().ShouldBe(1);
|
||||
body.GetProperty("Items")[0].GetProperty("ItemTitle").GetString().ShouldBe("Cycle A Movie");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEvents_WithSearchFilter_FiltersOnItemTitle()
|
||||
{
|
||||
AddSearchEvent(itemTitle: "The Matrix");
|
||||
AddSearchEvent(itemTitle: "Breaking Bad");
|
||||
|
||||
var result = await _controller.GetEvents(search: "matrix");
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("TotalCount").GetInt32().ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEvents_WithPagination_ReturnsCorrectPageAndCount()
|
||||
{
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
AddSearchEvent(itemTitle: $"Event {i}");
|
||||
}
|
||||
|
||||
var result = await _controller.GetEvents(page: 2, pageSize: 2);
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("TotalCount").GetInt32().ShouldBe(5);
|
||||
body.GetProperty("TotalPages").GetInt32().ShouldBe(3);
|
||||
body.GetProperty("Page").GetInt32().ShouldBe(2);
|
||||
body.GetProperty("Items").GetArrayLength().ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEvents_WithSortByTitleAscending_OrdersAlphabetically()
|
||||
{
|
||||
AddSearchEvent(itemTitle: "Charlie");
|
||||
AddSearchEvent(itemTitle: "Alpha");
|
||||
AddSearchEvent(itemTitle: "Bravo");
|
||||
|
||||
var result = await _controller.GetEvents(
|
||||
sortBy: SearchEventsSortBy.Title,
|
||||
sortDirection: Cleanuparr.Domain.Enums.SortDirection.Asc);
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
var items = body.GetProperty("Items");
|
||||
items[0].GetProperty("ItemTitle").GetString().ShouldBe("Alpha");
|
||||
items[1].GetProperty("ItemTitle").GetString().ShouldBe("Bravo");
|
||||
items[2].GetProperty("ItemTitle").GetString().ShouldBe("Charlie");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEvents_WithSortByTimestampAscending_OldestFirst()
|
||||
{
|
||||
AddSearchEvent(itemTitle: "Newest", timestamp: DateTime.UtcNow);
|
||||
AddSearchEvent(itemTitle: "Oldest", timestamp: DateTime.UtcNow.AddHours(-2));
|
||||
AddSearchEvent(itemTitle: "Middle", timestamp: DateTime.UtcNow.AddHours(-1));
|
||||
|
||||
var result = await _controller.GetEvents(sortDirection: Cleanuparr.Domain.Enums.SortDirection.Asc);
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
var items = body.GetProperty("Items");
|
||||
items[0].GetProperty("ItemTitle").GetString().ShouldBe("Oldest");
|
||||
items[2].GetProperty("ItemTitle").GetString().ShouldBe("Newest");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEvents_WithSearchStatusFilter_ReturnsOnlyMatchingStatuses()
|
||||
{
|
||||
AddSearchEvent(itemTitle: "A", searchStatus: SearchCommandStatus.Completed);
|
||||
AddSearchEvent(itemTitle: "B", searchStatus: SearchCommandStatus.Failed);
|
||||
AddSearchEvent(itemTitle: "C", searchStatus: SearchCommandStatus.TimedOut);
|
||||
|
||||
var result = await _controller.GetEvents(
|
||||
searchStatus: [SearchCommandStatus.Completed, SearchCommandStatus.Failed]);
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("TotalCount").GetInt32().ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEvents_WithSearchTypeFilter_ReturnsOnlyMatchingType()
|
||||
{
|
||||
AddSearchEvent(itemTitle: "Proactive Movie", searchType: SeekerSearchType.Proactive);
|
||||
AddSearchEvent(itemTitle: "Replacement Movie", searchType: SeekerSearchType.Replacement);
|
||||
|
||||
var result = await _controller.GetEvents(searchType: SeekerSearchType.Replacement);
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("TotalCount").GetInt32().ShouldBe(1);
|
||||
body.GetProperty("Items")[0].GetProperty("ItemTitle").GetString().ShouldBe("Replacement Movie");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEvents_WithSearchReasonFilter_ReturnsOnlyMatchingReason()
|
||||
{
|
||||
AddSearchEvent(itemTitle: "Missing", searchReason: SeekerSearchReason.Missing);
|
||||
AddSearchEvent(itemTitle: "Cutoff", searchReason: SeekerSearchReason.QualityCutoffNotMet);
|
||||
|
||||
var result = await _controller.GetEvents(searchReason: SeekerSearchReason.QualityCutoffNotMet);
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("TotalCount").GetInt32().ShouldBe(1);
|
||||
body.GetProperty("Items")[0].GetProperty("ItemTitle").GetString().ShouldBe("Cutoff");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEvents_WithGrabbedTrue_KeepsOnlyEventsWithGrabbedItems()
|
||||
{
|
||||
AddSearchEvent(itemTitle: "With Grabs", grabbedItems: ["movie (2024)"]);
|
||||
AddSearchEvent(itemTitle: "No Grabs", grabbedItems: []);
|
||||
|
||||
var result = await _controller.GetEvents(grabbed: true);
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("TotalCount").GetInt32().ShouldBe(1);
|
||||
body.GetProperty("Items")[0].GetProperty("ItemTitle").GetString().ShouldBe("With Grabs");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEvents_WithGrabbedFalse_KeepsOnlyEventsWithoutGrabbedItems()
|
||||
{
|
||||
AddSearchEvent(itemTitle: "With Grabs", grabbedItems: ["movie (2024)"]);
|
||||
AddSearchEvent(itemTitle: "No Grabs", grabbedItems: []);
|
||||
|
||||
var result = await _controller.GetEvents(grabbed: false);
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("TotalCount").GetInt32().ShouldBe(1);
|
||||
body.GetProperty("Items")[0].GetProperty("ItemTitle").GetString().ShouldBe("No Grabs");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helpers
|
||||
|
||||
private void AddSearchEvent(
|
||||
string? itemTitle = null,
|
||||
SeekerSearchType searchType = SeekerSearchType.Proactive,
|
||||
SeekerSearchReason searchReason = SeekerSearchReason.Missing,
|
||||
List<string>? grabbedItems = null,
|
||||
Guid? arrInstanceId = null,
|
||||
Guid? cycleId = null,
|
||||
SearchCommandStatus? searchStatus = null,
|
||||
DateTime? timestamp = null)
|
||||
{
|
||||
var appEvent = new AppEvent
|
||||
{
|
||||
EventType = EventType.SearchTriggered,
|
||||
Message = "Search triggered",
|
||||
Severity = EventSeverity.Information,
|
||||
ArrInstanceId = arrInstanceId,
|
||||
CycleId = cycleId,
|
||||
SearchStatus = searchStatus,
|
||||
Timestamp = timestamp ?? DateTime.UtcNow
|
||||
};
|
||||
|
||||
if (itemTitle is not null)
|
||||
{
|
||||
appEvent.ItemTitle = itemTitle;
|
||||
appEvent.SearchType = searchType;
|
||||
appEvent.SearchReason = searchReason;
|
||||
appEvent.GrabbedItems = grabbedItems ?? [];
|
||||
}
|
||||
|
||||
_eventsContext.Events.Add(appEvent);
|
||||
_eventsContext.SaveChanges();
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
using Cleanuparr.Api.Features.Seeker.Contracts.Requests;
|
||||
using Cleanuparr.Api.Features.Seeker.Contracts.Responses;
|
||||
using Cleanuparr.Api.Features.Seeker.Controllers;
|
||||
using Cleanuparr.Api.Tests.Features.Seeker.TestHelpers;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Services.Interfaces;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Seeker;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using Shouldly;
|
||||
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Features.Seeker;
|
||||
|
||||
public class SeekerConfigControllerTests : IDisposable
|
||||
{
|
||||
private readonly DataContext _dataContext;
|
||||
private readonly ILogger<SeekerConfigController> _logger;
|
||||
private readonly IJobManagementService _jobManagementService;
|
||||
private readonly SeekerConfigController _controller;
|
||||
|
||||
public SeekerConfigControllerTests()
|
||||
{
|
||||
_dataContext = SeekerTestDataFactory.CreateDataContext();
|
||||
_logger = Substitute.For<ILogger<SeekerConfigController>>();
|
||||
_jobManagementService = Substitute.For<IJobManagementService>();
|
||||
_controller = new SeekerConfigController(_logger, _dataContext, _jobManagementService);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_dataContext.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
#region GetSeekerConfig Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetSeekerConfig_WithNoSeekerInstanceConfigs_ReturnsDefaults()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
|
||||
var result = await _controller.GetSeekerConfig();
|
||||
var okResult = result.ShouldBeOfType<OkObjectResult>();
|
||||
var response = okResult.Value.ShouldBeOfType<SeekerConfigResponse>();
|
||||
|
||||
var instance = response.Instances.ShouldHaveSingleItem();
|
||||
instance.ArrInstanceId.ShouldBe(radarr.Id);
|
||||
instance.Enabled.ShouldBeFalse();
|
||||
instance.SkipTags.ShouldBeEmpty();
|
||||
instance.ActiveDownloadLimit.ShouldBe(3);
|
||||
instance.IgnoreStruckDownloads.ShouldBeFalse();
|
||||
instance.MinCycleTimeDays.ShouldBe(7);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSeekerConfig_WithIgnoreStruckDownloadsEnabled_ReturnsEnabledValue()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
_dataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
|
||||
{
|
||||
ArrInstanceId = radarr.Id,
|
||||
Enabled = true,
|
||||
IgnoreStruckDownloads = true
|
||||
});
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
var result = await _controller.GetSeekerConfig();
|
||||
var okResult = result.ShouldBeOfType<OkObjectResult>();
|
||||
var response = okResult.Value.ShouldBeOfType<SeekerConfigResponse>();
|
||||
|
||||
response.Instances.ShouldHaveSingleItem().IgnoreStruckDownloads.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSeekerConfig_OnlyReturnsSonarrAndRadarrInstances()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
var sonarr = SeekerTestDataFactory.AddSonarrInstance(_dataContext);
|
||||
var lidarr = SeekerTestDataFactory.AddLidarrInstance(_dataContext);
|
||||
|
||||
var result = await _controller.GetSeekerConfig();
|
||||
var okResult = result.ShouldBeOfType<OkObjectResult>();
|
||||
var response = okResult.Value.ShouldBeOfType<SeekerConfigResponse>();
|
||||
|
||||
response.Instances.Count.ShouldBe(2);
|
||||
response.Instances.ShouldContain(i => i.ArrInstanceId == radarr.Id);
|
||||
response.Instances.ShouldContain(i => i.ArrInstanceId == sonarr.Id);
|
||||
response.Instances.ShouldNotContain(i => i.ArrInstanceId == lidarr.Id);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region UpdateSeekerConfig Tests
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSeekerConfig_WithProactiveEnabledAndNoInstancesEnabled_ThrowsValidationException()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
var request = new UpdateSeekerConfigRequest
|
||||
{
|
||||
SearchEnabled = true,
|
||||
SearchInterval = 5,
|
||||
ProactiveSearchEnabled = true,
|
||||
Instances =
|
||||
[
|
||||
new UpdateSeekerInstanceConfigRequest
|
||||
{
|
||||
ArrInstanceId = radarr.Id,
|
||||
Enabled = false // No instances enabled
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
await Should.ThrowAsync<ValidationException>(() => _controller.UpdateSeekerConfig(request));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSeekerConfig_WhenIntervalChanges_ReschedulesSeeker()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
_dataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
|
||||
{
|
||||
ArrInstanceId = radarr.Id,
|
||||
Enabled = true
|
||||
});
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
// Default interval is 3, change to 5
|
||||
var request = new UpdateSeekerConfigRequest
|
||||
{
|
||||
SearchEnabled = true,
|
||||
SearchInterval = 5,
|
||||
ProactiveSearchEnabled = true,
|
||||
Instances =
|
||||
[
|
||||
new UpdateSeekerInstanceConfigRequest { ArrInstanceId = radarr.Id, Enabled = true }
|
||||
]
|
||||
};
|
||||
|
||||
await _controller.UpdateSeekerConfig(request);
|
||||
|
||||
await _jobManagementService.Received(1)
|
||||
.StartJob(JobType.Seeker, null, Arg.Any<string>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSeekerConfig_WhenIntervalUnchanged_DoesNotReschedule()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
_dataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
|
||||
{
|
||||
ArrInstanceId = radarr.Id,
|
||||
Enabled = true
|
||||
});
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
// Keep interval at default (10)
|
||||
var request = new UpdateSeekerConfigRequest
|
||||
{
|
||||
SearchEnabled = true,
|
||||
SearchInterval = 10,
|
||||
ProactiveSearchEnabled = true,
|
||||
Instances =
|
||||
[
|
||||
new UpdateSeekerInstanceConfigRequest { ArrInstanceId = radarr.Id, Enabled = true }
|
||||
]
|
||||
};
|
||||
|
||||
await _controller.UpdateSeekerConfig(request);
|
||||
|
||||
await _jobManagementService.DidNotReceive()
|
||||
.StartJob(Arg.Any<JobType>(), null, Arg.Any<string>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSeekerConfig_WhenCustomFormatScoreEnabled_StartsAndTriggersSyncerJob()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
_dataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
|
||||
{
|
||||
ArrInstanceId = radarr.Id,
|
||||
Enabled = true
|
||||
});
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
// UseCustomFormatScore was false (default), now enable it on the instance
|
||||
var request = new UpdateSeekerConfigRequest
|
||||
{
|
||||
SearchEnabled = true,
|
||||
SearchInterval = 3,
|
||||
ProactiveSearchEnabled = true,
|
||||
Instances =
|
||||
[
|
||||
new UpdateSeekerInstanceConfigRequest { ArrInstanceId = radarr.Id, Enabled = true, UseCustomFormatScore = true }
|
||||
]
|
||||
};
|
||||
|
||||
await _controller.UpdateSeekerConfig(request);
|
||||
|
||||
await _jobManagementService.Received(1)
|
||||
.StartJob(JobType.CustomFormatScoreSyncer, null, Arg.Any<string>());
|
||||
await _jobManagementService.Received(1)
|
||||
.TriggerJobOnce(JobType.CustomFormatScoreSyncer);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSeekerConfig_WhenCustomFormatScoreDisabled_StopsSyncerJob()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
_dataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
|
||||
{
|
||||
ArrInstanceId = radarr.Id,
|
||||
Enabled = true,
|
||||
UseCustomFormatScore = true
|
||||
});
|
||||
|
||||
// Syncer was running: both proactive and CF score were enabled
|
||||
var config = await _dataContext.SeekerConfigs.FirstAsync();
|
||||
config.ProactiveSearchEnabled = true;
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
// Disable CF score — syncer conditions no longer met
|
||||
var request = new UpdateSeekerConfigRequest
|
||||
{
|
||||
SearchEnabled = true,
|
||||
SearchInterval = 3,
|
||||
ProactiveSearchEnabled = true,
|
||||
Instances =
|
||||
[
|
||||
new UpdateSeekerInstanceConfigRequest { ArrInstanceId = radarr.Id, Enabled = true, UseCustomFormatScore = false }
|
||||
]
|
||||
};
|
||||
|
||||
await _controller.UpdateSeekerConfig(request);
|
||||
|
||||
await _jobManagementService.Received(1)
|
||||
.StopJob(JobType.CustomFormatScoreSyncer);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSeekerConfig_WhenProactiveSearchDisabled_StopsSyncerJob()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
_dataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
|
||||
{
|
||||
ArrInstanceId = radarr.Id,
|
||||
Enabled = true,
|
||||
UseCustomFormatScore = true
|
||||
});
|
||||
|
||||
// Syncer was running: both proactive and CF score were enabled
|
||||
var config = await _dataContext.SeekerConfigs.FirstAsync();
|
||||
config.ProactiveSearchEnabled = true;
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
// Disable proactive search — syncer should stop even though CF score is still enabled
|
||||
var request = new UpdateSeekerConfigRequest
|
||||
{
|
||||
SearchEnabled = true,
|
||||
SearchInterval = 3,
|
||||
ProactiveSearchEnabled = false,
|
||||
Instances =
|
||||
[
|
||||
new UpdateSeekerInstanceConfigRequest { ArrInstanceId = radarr.Id, Enabled = true, UseCustomFormatScore = true }
|
||||
]
|
||||
};
|
||||
|
||||
await _controller.UpdateSeekerConfig(request);
|
||||
|
||||
await _jobManagementService.Received(1)
|
||||
.StopJob(JobType.CustomFormatScoreSyncer);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSeekerConfig_WhenProactiveSearchEnabled_WithCfScoreActive_StartsAndTriggersSyncer()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
_dataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
|
||||
{
|
||||
ArrInstanceId = radarr.Id,
|
||||
Enabled = true,
|
||||
UseCustomFormatScore = true
|
||||
});
|
||||
|
||||
// Syncer was NOT running: CF score enabled but proactive was off (default)
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
// Enable proactive search — syncer should start
|
||||
var request = new UpdateSeekerConfigRequest
|
||||
{
|
||||
SearchEnabled = true,
|
||||
SearchInterval = 3,
|
||||
ProactiveSearchEnabled = true,
|
||||
Instances =
|
||||
[
|
||||
new UpdateSeekerInstanceConfigRequest { ArrInstanceId = radarr.Id, Enabled = true, UseCustomFormatScore = true }
|
||||
]
|
||||
};
|
||||
|
||||
await _controller.UpdateSeekerConfig(request);
|
||||
|
||||
await _jobManagementService.Received(1)
|
||||
.StartJob(JobType.CustomFormatScoreSyncer, null, Arg.Any<string>());
|
||||
await _jobManagementService.Received(1)
|
||||
.TriggerJobOnce(JobType.CustomFormatScoreSyncer);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSeekerConfig_WhenCustomFormatScoreEnabledButProactiveDisabled_DoesNotStartSyncer()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
_dataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
|
||||
{
|
||||
ArrInstanceId = radarr.Id,
|
||||
Enabled = true,
|
||||
UseCustomFormatScore = false
|
||||
});
|
||||
|
||||
// ProactiveSearchEnabled stays false (default)
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
// Enable CF score but keep proactive disabled — syncer should NOT start
|
||||
var request = new UpdateSeekerConfigRequest
|
||||
{
|
||||
SearchEnabled = true,
|
||||
SearchInterval = 3,
|
||||
ProactiveSearchEnabled = false,
|
||||
Instances =
|
||||
[
|
||||
new UpdateSeekerInstanceConfigRequest { ArrInstanceId = radarr.Id, Enabled = true, UseCustomFormatScore = true }
|
||||
]
|
||||
};
|
||||
|
||||
await _controller.UpdateSeekerConfig(request);
|
||||
|
||||
await _jobManagementService.DidNotReceive()
|
||||
.StartJob(JobType.CustomFormatScoreSyncer, null, Arg.Any<string>());
|
||||
await _jobManagementService.DidNotReceive()
|
||||
.TriggerJobOnce(JobType.CustomFormatScoreSyncer);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSeekerConfig_SyncsExistingAndCreatesNewInstanceConfigs()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
var sonarr = SeekerTestDataFactory.AddSonarrInstance(_dataContext);
|
||||
|
||||
// Radarr already has a config
|
||||
_dataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
|
||||
{
|
||||
ArrInstanceId = radarr.Id,
|
||||
Enabled = false,
|
||||
SkipTags = ["old-tag"],
|
||||
ActiveDownloadLimit = 2,
|
||||
MinCycleTimeDays = 5
|
||||
});
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
var request = new UpdateSeekerConfigRequest
|
||||
{
|
||||
SearchEnabled = true,
|
||||
SearchInterval = 3,
|
||||
ProactiveSearchEnabled = true,
|
||||
Instances =
|
||||
[
|
||||
// Update existing radarr config
|
||||
new UpdateSeekerInstanceConfigRequest
|
||||
{
|
||||
ArrInstanceId = radarr.Id,
|
||||
Enabled = true,
|
||||
SkipTags = ["new-tag"],
|
||||
ActiveDownloadLimit = 5,
|
||||
IgnoreStruckDownloads = true,
|
||||
MinCycleTimeDays = 14
|
||||
},
|
||||
// Create new sonarr config
|
||||
new UpdateSeekerInstanceConfigRequest
|
||||
{
|
||||
ArrInstanceId = sonarr.Id,
|
||||
Enabled = true,
|
||||
SkipTags = ["sonarr-tag"],
|
||||
ActiveDownloadLimit = 3,
|
||||
MinCycleTimeDays = 7
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
await _controller.UpdateSeekerConfig(request);
|
||||
|
||||
var configs = await _dataContext.SeekerInstanceConfigs.ToListAsync();
|
||||
configs.Count.ShouldBe(2);
|
||||
|
||||
var radarrConfig = configs.First(c => c.ArrInstanceId == radarr.Id);
|
||||
radarrConfig.Enabled.ShouldBeTrue();
|
||||
radarrConfig.SkipTags.ShouldContain("new-tag");
|
||||
radarrConfig.ActiveDownloadLimit.ShouldBe(5);
|
||||
radarrConfig.IgnoreStruckDownloads.ShouldBeTrue();
|
||||
radarrConfig.MinCycleTimeDays.ShouldBe(14);
|
||||
|
||||
var sonarrConfig = configs.First(c => c.ArrInstanceId == sonarr.Id);
|
||||
sonarrConfig.Enabled.ShouldBeTrue();
|
||||
sonarrConfig.SkipTags.ShouldContain("sonarr-tag");
|
||||
sonarrConfig.IgnoreStruckDownloads.ShouldBeFalse();
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
+168
@@ -0,0 +1,168 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
|
||||
using Cleanuparr.Persistence.Models.Configuration.General;
|
||||
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
|
||||
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Seeker;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Features.Seeker.TestHelpers;
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating SQLite in-memory contexts for Seeker controller tests
|
||||
/// </summary>
|
||||
public static class SeekerTestDataFactory
|
||||
{
|
||||
public static DataContext CreateDataContext()
|
||||
{
|
||||
var connection = new SqliteConnection("DataSource=:memory:");
|
||||
connection.Open();
|
||||
|
||||
var options = new DbContextOptionsBuilder<DataContext>()
|
||||
.UseSqlite(connection)
|
||||
.UseLowerCaseNamingConvention()
|
||||
.UseSnakeCaseNamingConvention()
|
||||
.Options;
|
||||
|
||||
var context = new DataContext(options);
|
||||
context.Database.EnsureCreated();
|
||||
|
||||
SeedDefaultData(context);
|
||||
return context;
|
||||
}
|
||||
|
||||
public static EventsContext CreateEventsContext()
|
||||
{
|
||||
var connection = new SqliteConnection("DataSource=:memory:");
|
||||
connection.Open();
|
||||
|
||||
var options = new DbContextOptionsBuilder<EventsContext>()
|
||||
.UseSqlite(connection)
|
||||
.UseLowerCaseNamingConvention()
|
||||
.UseSnakeCaseNamingConvention()
|
||||
.Options;
|
||||
|
||||
var context = new EventsContext(options);
|
||||
context.Database.EnsureCreated();
|
||||
return context;
|
||||
}
|
||||
|
||||
private static void SeedDefaultData(DataContext context)
|
||||
{
|
||||
context.GeneralConfigs.Add(new GeneralConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
DryRun = false,
|
||||
IgnoredDownloads = [],
|
||||
Log = new LoggingConfig()
|
||||
});
|
||||
|
||||
context.ArrConfigs.AddRange(
|
||||
new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Sonarr, Instances = [], FailedImportMaxStrikes = 3 },
|
||||
new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Radarr, Instances = [], FailedImportMaxStrikes = 3 },
|
||||
new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Lidarr, Instances = [], FailedImportMaxStrikes = 3 },
|
||||
new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Readarr, Instances = [], FailedImportMaxStrikes = 3 },
|
||||
new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Whisparr, Instances = [], FailedImportMaxStrikes = 3 },
|
||||
new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Sportarr, Instances = [], FailedImportMaxStrikes = 3 }
|
||||
);
|
||||
|
||||
context.QueueCleanerConfigs.Add(new QueueCleanerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
IgnoredDownloads = [],
|
||||
FailedImport = new FailedImportConfig()
|
||||
});
|
||||
|
||||
context.ContentBlockerConfigs.Add(new ContentBlockerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
IgnoredDownloads = [],
|
||||
DeletePrivate = false,
|
||||
Sonarr = new BlocklistSettings { Enabled = false },
|
||||
Radarr = new BlocklistSettings { Enabled = false },
|
||||
Lidarr = new BlocklistSettings { Enabled = false },
|
||||
Readarr = new BlocklistSettings { Enabled = false },
|
||||
Whisparr = new BlocklistSettings { Enabled = false },
|
||||
Sportarr = new BlocklistSettings { Enabled = false }
|
||||
});
|
||||
|
||||
context.DownloadCleanerConfigs.Add(new DownloadCleanerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
IgnoredDownloads = []
|
||||
});
|
||||
|
||||
context.SeekerConfigs.Add(new SeekerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
SearchEnabled = true,
|
||||
ProactiveSearchEnabled = false
|
||||
});
|
||||
|
||||
context.SaveChanges();
|
||||
}
|
||||
|
||||
public static ArrInstance AddSonarrInstance(DataContext context, bool enabled = true)
|
||||
{
|
||||
var arrConfig = context.ArrConfigs.First(x => x.Type == InstanceType.Sonarr);
|
||||
var instance = new ArrInstance
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Test Sonarr",
|
||||
Url = new Uri("http://sonarr:8989"),
|
||||
ApiKey = "test-api-key",
|
||||
Enabled = enabled,
|
||||
ArrConfigId = arrConfig.Id,
|
||||
ArrConfig = arrConfig
|
||||
};
|
||||
|
||||
arrConfig.Instances.Add(instance);
|
||||
context.ArrInstances.Add(instance);
|
||||
context.SaveChanges();
|
||||
return instance;
|
||||
}
|
||||
|
||||
public static ArrInstance AddRadarrInstance(DataContext context, bool enabled = true)
|
||||
{
|
||||
var arrConfig = context.ArrConfigs.First(x => x.Type == InstanceType.Radarr);
|
||||
var instance = new ArrInstance
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Test Radarr",
|
||||
Url = new Uri("http://radarr:7878"),
|
||||
ApiKey = "test-api-key",
|
||||
Enabled = enabled,
|
||||
ArrConfigId = arrConfig.Id,
|
||||
ArrConfig = arrConfig
|
||||
};
|
||||
|
||||
arrConfig.Instances.Add(instance);
|
||||
context.ArrInstances.Add(instance);
|
||||
context.SaveChanges();
|
||||
return instance;
|
||||
}
|
||||
|
||||
public static ArrInstance AddLidarrInstance(DataContext context, bool enabled = true)
|
||||
{
|
||||
var arrConfig = context.ArrConfigs.First(x => x.Type == InstanceType.Lidarr);
|
||||
var instance = new ArrInstance
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Test Lidarr",
|
||||
Url = new Uri("http://lidarr:8686"),
|
||||
ApiKey = "test-api-key",
|
||||
Enabled = enabled,
|
||||
ArrConfigId = arrConfig.Id,
|
||||
ArrConfig = arrConfig
|
||||
};
|
||||
|
||||
arrConfig.Instances.Add(instance);
|
||||
context.ArrInstances.Add(instance);
|
||||
context.SaveChanges();
|
||||
return instance;
|
||||
}
|
||||
}
|
||||
@@ -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,63 @@
|
||||
using System.Text.Json;
|
||||
using Cleanuparr.Api.Json;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Json;
|
||||
|
||||
public class InboundNullHandlingTests
|
||||
{
|
||||
private static readonly JsonSerializerOptions Options = CreateOptions();
|
||||
|
||||
private static JsonSerializerOptions CreateOptions()
|
||||
{
|
||||
JsonSerializerOptions options = new();
|
||||
CleanuparrJsonConfiguration.ConfigureApiInbound(options);
|
||||
return options;
|
||||
}
|
||||
|
||||
private sealed class Model
|
||||
{
|
||||
public List<string> Items { get; set; } = ["default"];
|
||||
|
||||
public string Name { get; set; } = "default-name";
|
||||
|
||||
public string? Optional { get; set; } = "opt";
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExplicitNull_OnNonNullableProperty_KeepsDefault()
|
||||
{
|
||||
Model result = JsonSerializer.Deserialize<Model>("""{"items": null, "name": null}""", Options)!;
|
||||
|
||||
result.Items.ShouldBe(["default"]);
|
||||
result.Name.ShouldBe("default-name");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExplicitNull_MatchesAbsent()
|
||||
{
|
||||
Model withNull = JsonSerializer.Deserialize<Model>("""{"items": null, "name": null}""", Options)!;
|
||||
Model absent = JsonSerializer.Deserialize<Model>("{}", Options)!;
|
||||
|
||||
withNull.Items.ShouldBe(absent.Items);
|
||||
withNull.Name.ShouldBe(absent.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExplicitNull_OnNullableProperty_SetsNull()
|
||||
{
|
||||
Model result = JsonSerializer.Deserialize<Model>("""{"optional": null}""", Options)!;
|
||||
|
||||
result.Optional.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NonNullValue_StillDeserializes()
|
||||
{
|
||||
Model result = JsonSerializer.Deserialize<Model>("""{"items": ["a","b"], "name": "x"}""", Options)!;
|
||||
|
||||
result.Items.ShouldBe(["a", "b"]);
|
||||
result.Name.ShouldBe("x");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
using System.Text.Json;
|
||||
using Cleanuparr.Api.Json;
|
||||
using Cleanuparr.Domain.Entities.Arr;
|
||||
using Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadRemover.Models;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Json;
|
||||
|
||||
/// <summary>
|
||||
/// The in-memory bus serializes with these options, so a broken discriminator
|
||||
/// only shows up at runtime.
|
||||
/// </summary>
|
||||
public class RemovalRequestSerializationTests
|
||||
{
|
||||
private static readonly JsonSerializerOptions Options = CreateOptions();
|
||||
|
||||
private static JsonSerializerOptions CreateOptions()
|
||||
{
|
||||
JsonSerializerOptions options = new();
|
||||
CleanuparrJsonConfiguration.ConfigureCore(options);
|
||||
return options;
|
||||
}
|
||||
|
||||
private static QueueItemRemoveRequest CreateRequest(SearchItem searchItem)
|
||||
{
|
||||
return new QueueItemRemoveRequest
|
||||
{
|
||||
Instance = new ArrInstance
|
||||
{
|
||||
Name = "Test Instance",
|
||||
Url = new Uri("http://sonarr.local"),
|
||||
ApiKey = "test-api-key",
|
||||
ArrConfig = new ArrConfig { Type = InstanceType.Sonarr },
|
||||
},
|
||||
Target = new ArrRemovalTarget
|
||||
{
|
||||
Record = new QueueRecord
|
||||
{
|
||||
Id = 1,
|
||||
Title = "Test Record",
|
||||
Protocol = "torrent",
|
||||
DownloadId = "ABC123",
|
||||
},
|
||||
SearchItem = searchItem,
|
||||
RemoveFromClient = true,
|
||||
ChangeCategory = false,
|
||||
},
|
||||
DeleteReason = DeleteReason.Stalled,
|
||||
JobRunId = Guid.NewGuid(),
|
||||
};
|
||||
}
|
||||
|
||||
private static QueueItemRemoveRequest RoundTrip(QueueItemRemoveRequest request)
|
||||
{
|
||||
string json = JsonSerializer.Serialize(request, Options);
|
||||
return JsonSerializer.Deserialize<QueueItemRemoveRequest>(json, Options)!;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ArrTarget_SurvivesRoundTrip()
|
||||
{
|
||||
QueueItemRemoveRequest result = RoundTrip(CreateRequest(new SearchItem { Id = 42 }));
|
||||
|
||||
ArrRemovalTarget target = result.Target.ShouldBeOfType<ArrRemovalTarget>();
|
||||
target.Record.DownloadId.ShouldBe("ABC123");
|
||||
target.RemoveFromClient.ShouldBeTrue();
|
||||
result.DeleteReason.ShouldBe(DeleteReason.Stalled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BaseSearchItem_SurvivesRoundTrip()
|
||||
{
|
||||
QueueItemRemoveRequest result = RoundTrip(CreateRequest(new SearchItem { Id = 42 }));
|
||||
|
||||
SearchItem item = ArrTargetOf(result).SearchItem;
|
||||
item.ShouldBeOfType<SearchItem>();
|
||||
item.Id.ShouldBe(42);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SeriesSearchItem_KeepsItsDerivedType()
|
||||
{
|
||||
QueueItemRemoveRequest result = RoundTrip(CreateRequest(new SeriesSearchItem
|
||||
{
|
||||
Id = 100,
|
||||
SeriesId = 10,
|
||||
SearchType = SeriesSearchType.Episode,
|
||||
}));
|
||||
|
||||
SeriesSearchItem item = ArrTargetOf(result).SearchItem.ShouldBeOfType<SeriesSearchItem>();
|
||||
item.Id.ShouldBe(100);
|
||||
item.SeriesId.ShouldBe(10);
|
||||
item.SearchType.ShouldBe(SeriesSearchType.Episode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Target_CarriesTheDiscriminator_AndOmitsDerivedMembers()
|
||||
{
|
||||
string json = JsonSerializer.Serialize(CreateRequest(new SearchItem { Id = 42 }), Options);
|
||||
|
||||
using JsonDocument document = JsonDocument.Parse(json);
|
||||
JsonElement target = document.RootElement.GetProperty("Target");
|
||||
|
||||
target.GetProperty("$target").GetString().ShouldBe("arr");
|
||||
target.TryGetProperty("DownloadId", out _).ShouldBeFalse();
|
||||
target.TryGetProperty("Title", out _).ShouldBeFalse();
|
||||
}
|
||||
|
||||
private static ArrRemovalTarget ArrTargetOf(QueueItemRemoveRequest request) =>
|
||||
(ArrRemovalTarget)request.Target;
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
using Cleanuparr.Api.DependencyInjection;
|
||||
using Cleanuparr.Api.Middleware;
|
||||
using Cleanuparr.Domain.Exceptions;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Infrastructure;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NSubstitute;
|
||||
using Shouldly;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Middleware;
|
||||
|
||||
public class GlobalExceptionHandlerTests
|
||||
{
|
||||
private static readonly ProblemDetailsFactory ProblemDetailsFactory = BuildProblemDetailsFactory();
|
||||
|
||||
private static ProblemDetailsFactory BuildProblemDetailsFactory()
|
||||
{
|
||||
ServiceCollection services = new();
|
||||
services.AddLogging();
|
||||
services.AddControllers();
|
||||
services.AddCleanuparrProblemDetails();
|
||||
return services.BuildServiceProvider().GetRequiredService<ProblemDetailsFactory>();
|
||||
}
|
||||
|
||||
private static async Task<(bool handled, HttpContext context, ProblemDetails problemDetails)> Handle(Exception exception)
|
||||
{
|
||||
IProblemDetailsService problemDetailsService = Substitute.For<IProblemDetailsService>();
|
||||
problemDetailsService
|
||||
.TryWriteAsync(Arg.Any<ProblemDetailsContext>())
|
||||
.Returns(callInfo => ValueTask.FromResult(true));
|
||||
|
||||
DefaultHttpContext context = new();
|
||||
GlobalExceptionHandler handler = new(problemDetailsService, ProblemDetailsFactory, NullLogger<GlobalExceptionHandler>.Instance);
|
||||
|
||||
bool handled = await handler.TryHandleAsync(context, exception, CancellationToken.None);
|
||||
|
||||
ProblemDetailsContext captured = (ProblemDetailsContext)problemDetailsService
|
||||
.ReceivedCalls()
|
||||
.Single()
|
||||
.GetArguments()[0]!;
|
||||
|
||||
return (handled, context, captured.ProblemDetails);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidationException_MapsTo400_WithMessageAsDetail()
|
||||
{
|
||||
(bool handled, HttpContext context, ProblemDetails problemDetails) = await Handle(new ValidationException("Name is required"));
|
||||
|
||||
handled.ShouldBeTrue();
|
||||
context.Response.StatusCode.ShouldBe(StatusCodes.Status400BadRequest);
|
||||
problemDetails.Status.ShouldBe(StatusCodes.Status400BadRequest);
|
||||
problemDetails.Title.ShouldBe("Validation failed");
|
||||
problemDetails.Detail.ShouldBe("Name is required");
|
||||
problemDetails.Type.ShouldNotBeNullOrEmpty();
|
||||
problemDetails.Extensions.ShouldContainKey("traceId");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NotificationTestException_MapsTo400()
|
||||
{
|
||||
(bool handled, HttpContext context, ProblemDetails problemDetails) = await Handle(new NotificationTestException("Test failed: connection refused"));
|
||||
|
||||
handled.ShouldBeTrue();
|
||||
context.Response.StatusCode.ShouldBe(StatusCodes.Status400BadRequest);
|
||||
problemDetails.Status.ShouldBe(StatusCodes.Status400BadRequest);
|
||||
problemDetails.Title.ShouldBe("Notification test failed");
|
||||
problemDetails.Detail.ShouldBe("Test failed: connection refused");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RateLimitException_MapsTo429_WithRetryAfterExtensionAndHeader()
|
||||
{
|
||||
(bool handled, HttpContext context, ProblemDetails problemDetails) = await Handle(new RateLimitException("Account is locked", 30));
|
||||
|
||||
handled.ShouldBeTrue();
|
||||
context.Response.StatusCode.ShouldBe(StatusCodes.Status429TooManyRequests);
|
||||
problemDetails.Status.ShouldBe(StatusCodes.Status429TooManyRequests);
|
||||
problemDetails.Title.ShouldBe("Too many requests");
|
||||
problemDetails.Extensions["retryAfterSeconds"].ShouldBe(30);
|
||||
context.Response.Headers.RetryAfter.ToString().ShouldBe("30");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RateLimitException_WithZeroRetry_MapsTo429_WithoutRetryAfter()
|
||||
{
|
||||
(bool handled, HttpContext context, ProblemDetails problemDetails) = await Handle(new RateLimitException("Too many pending OIDC flows", 0));
|
||||
|
||||
handled.ShouldBeTrue();
|
||||
context.Response.StatusCode.ShouldBe(StatusCodes.Status429TooManyRequests);
|
||||
problemDetails.Extensions.ShouldNotContainKey("retryAfterSeconds");
|
||||
context.Response.Headers.RetryAfter.ToString().ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnknownException_MapsTo500_WithGenericDetail_AndDoesNotLeakMessage()
|
||||
{
|
||||
(bool handled, HttpContext context, ProblemDetails problemDetails) = await Handle(new InvalidOperationException("internal connection string leaked"));
|
||||
|
||||
handled.ShouldBeTrue();
|
||||
context.Response.StatusCode.ShouldBe(StatusCodes.Status500InternalServerError);
|
||||
problemDetails.Status.ShouldBe(StatusCodes.Status500InternalServerError);
|
||||
problemDetails.Detail.ShouldBe("An unexpected error occurred");
|
||||
problemDetails.Detail.ShouldNotContain("connection string");
|
||||
}
|
||||
}
|
||||
+226
@@ -0,0 +1,226 @@
|
||||
using System.Net;
|
||||
using Cleanuparr.Api.Middleware;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Shouldly;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Middleware;
|
||||
|
||||
public class TrustedForwardedHeadersMiddlewareTests
|
||||
{
|
||||
private static HttpContext NewContext(IPAddress peer, Action<HttpContext>? configure = null)
|
||||
{
|
||||
var ctx = new DefaultHttpContext();
|
||||
ctx.Connection.RemoteIpAddress = peer;
|
||||
ctx.Request.Scheme = "http";
|
||||
ctx.Request.Host = new HostString("backend.local");
|
||||
configure?.Invoke(ctx);
|
||||
return ctx;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Untrusted_direct_peer_leaves_everything_alone()
|
||||
{
|
||||
var ctx = NewContext(IPAddress.Parse("203.0.113.1"), c =>
|
||||
{
|
||||
c.Request.Headers["X-Forwarded-For"] = "10.0.0.5";
|
||||
c.Request.Headers["X-Forwarded-Proto"] = "https";
|
||||
c.Request.Headers["X-Forwarded-Host"] = "spoofed.example.com";
|
||||
});
|
||||
|
||||
TrustedForwardedHeadersMiddleware.ApplyForwardedHeaders(ctx, new List<string>());
|
||||
|
||||
ctx.Connection.RemoteIpAddress.ShouldBe(IPAddress.Parse("203.0.113.1"));
|
||||
ctx.Request.Scheme.ShouldBe("http");
|
||||
ctx.Request.Host.Value.ShouldBe("backend.local");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Local_peer_no_xff_is_a_no_op()
|
||||
{
|
||||
var ctx = NewContext(IPAddress.Loopback);
|
||||
|
||||
TrustedForwardedHeadersMiddleware.ApplyForwardedHeaders(ctx, new List<string>());
|
||||
|
||||
ctx.Connection.RemoteIpAddress.ShouldBe(IPAddress.Loopback);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Spoofed_local_xff_with_appended_attacker_promotes_attacker_ip_only()
|
||||
{
|
||||
var ctx = NewContext(IPAddress.Loopback, c =>
|
||||
c.Request.Headers["X-Forwarded-For"] = "10.0.0.5, 99.99.99.99");
|
||||
|
||||
TrustedForwardedHeadersMiddleware.ApplyForwardedHeaders(ctx, new List<string>());
|
||||
|
||||
ctx.Connection.RemoteIpAddress.ShouldBe(IPAddress.Parse("99.99.99.99"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Single_xff_entry_from_overwrite_mode_proxy_becomes_client_ip()
|
||||
{
|
||||
var ctx = NewContext(IPAddress.Loopback, c => c.Request.Headers["X-Forwarded-For"] = "203.0.113.45");
|
||||
|
||||
TrustedForwardedHeadersMiddleware.ApplyForwardedHeaders(ctx, new List<string>());
|
||||
|
||||
ctx.Connection.RemoteIpAddress.ShouldBe(IPAddress.Parse("203.0.113.45"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Legitimate_lan_client_through_local_proxy_resolves_to_lan_ip()
|
||||
{
|
||||
var ctx = NewContext(IPAddress.Loopback, c => c.Request.Headers["X-Forwarded-For"] = "192.168.1.50");
|
||||
|
||||
TrustedForwardedHeadersMiddleware.ApplyForwardedHeaders(ctx, new List<string>());
|
||||
|
||||
ctx.Connection.RemoteIpAddress.ShouldBe(IPAddress.Parse("192.168.1.50"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Custom_trusted_network_pops_through_to_real_client()
|
||||
{
|
||||
var ctx = NewContext(IPAddress.Loopback, c => c.Request.Headers["X-Forwarded-For"] = "100.64.1.5, 100.64.0.7");
|
||||
|
||||
TrustedForwardedHeadersMiddleware.ApplyForwardedHeaders(ctx, new List<string> { "100.64.0.0/10" });
|
||||
|
||||
ctx.Connection.RemoteIpAddress.ShouldBe(IPAddress.Parse("100.64.1.5"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Forwarded_proto_and_host_applied_when_chain_consumed()
|
||||
{
|
||||
var ctx = NewContext(IPAddress.Loopback, c =>
|
||||
{
|
||||
c.Request.Headers["X-Forwarded-For"] = "203.0.113.45";
|
||||
c.Request.Headers["X-Forwarded-Proto"] = "https";
|
||||
c.Request.Headers["X-Forwarded-Host"] = "cleanuparr.example.com";
|
||||
});
|
||||
|
||||
TrustedForwardedHeadersMiddleware.ApplyForwardedHeaders(ctx, new List<string>());
|
||||
|
||||
ctx.Request.Scheme.ShouldBe("https");
|
||||
ctx.Request.Host.Value.ShouldBe("cleanuparr.example.com");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Forwarded_proto_not_applied_when_peer_untrusted()
|
||||
{
|
||||
var ctx = NewContext(IPAddress.Parse("203.0.113.1"), c => c.Request.Headers["X-Forwarded-Proto"] = "https");
|
||||
|
||||
TrustedForwardedHeadersMiddleware.ApplyForwardedHeaders(ctx, new List<string>());
|
||||
|
||||
ctx.Request.Scheme.ShouldBe("http");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void X_real_ip_is_ignored()
|
||||
{
|
||||
var ctx = NewContext(IPAddress.Loopback, c => c.Request.Headers["X-Real-IP"] = "10.0.0.5");
|
||||
|
||||
TrustedForwardedHeadersMiddleware.ApplyForwardedHeaders(ctx, new List<string>());
|
||||
|
||||
ctx.Connection.RemoteIpAddress.ShouldBe(IPAddress.Loopback);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Malformed_xff_entry_fails_closed()
|
||||
{
|
||||
var ctx = NewContext(IPAddress.Loopback, c =>
|
||||
{
|
||||
c.Request.Headers["X-Forwarded-For"] = "10.0.0.5, not-an-ip";
|
||||
c.Request.Headers["X-Forwarded-Proto"] = "https";
|
||||
});
|
||||
|
||||
TrustedForwardedHeadersMiddleware.ApplyForwardedHeaders(ctx, new List<string>());
|
||||
|
||||
ctx.Connection.RemoteIpAddress.ShouldBe(IPAddress.Loopback);
|
||||
ctx.Request.Scheme.ShouldBe("http");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Empty_entries_in_xff_are_skipped()
|
||||
{
|
||||
// nginx with `proxy_set_header X-Forwarded-For "$http_x_forwarded_for, 1.2.3.4"`
|
||||
// produces a leading empty entry when the input header was absent.
|
||||
var ctx = NewContext(IPAddress.Loopback, c => c.Request.Headers["X-Forwarded-For"] = ", 99.99.99.99");
|
||||
|
||||
TrustedForwardedHeadersMiddleware.ApplyForwardedHeaders(ctx, new List<string>());
|
||||
|
||||
ctx.Connection.RemoteIpAddress.ShouldBe(IPAddress.Parse("99.99.99.99"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ipv4_mapped_ipv6_loopback_is_treated_as_trusted()
|
||||
{
|
||||
// Kestrel may surface "::ffff:127.0.0.1" as the peer.
|
||||
var mapped = IPAddress.Parse("::ffff:127.0.0.1");
|
||||
var ctx = NewContext(mapped, c => c.Request.Headers["X-Forwarded-For"] = "203.0.113.45");
|
||||
|
||||
TrustedForwardedHeadersMiddleware.ApplyForwardedHeaders(ctx, new List<string>());
|
||||
|
||||
ctx.Connection.RemoteIpAddress.ShouldBe(IPAddress.Parse("203.0.113.45"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Forwarded_proto_with_multiple_values_uses_only_first_token()
|
||||
{
|
||||
// Chained proxies that append (rather than overwrite) X-Forwarded-Proto
|
||||
// produce comma-separated values like "https, http". Only the leftmost
|
||||
// hop's value should be applied — matching how XFF is handled.
|
||||
var ctx = NewContext(IPAddress.Loopback, c =>
|
||||
{
|
||||
c.Request.Headers["X-Forwarded-For"] = "203.0.113.45";
|
||||
c.Request.Headers["X-Forwarded-Proto"] = "https, http";
|
||||
});
|
||||
|
||||
TrustedForwardedHeadersMiddleware.ApplyForwardedHeaders(ctx, new List<string>());
|
||||
|
||||
ctx.Request.Scheme.ShouldBe("https");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Forwarded_proto_with_unknown_scheme_is_ignored()
|
||||
{
|
||||
// Anything outside the http/https allowlist is dropped to keep
|
||||
// arbitrary values (e.g. "javascript:") from flowing into URLs.
|
||||
var ctx = NewContext(IPAddress.Loopback, c =>
|
||||
{
|
||||
c.Request.Headers["X-Forwarded-For"] = "203.0.113.45";
|
||||
c.Request.Headers["X-Forwarded-Proto"] = "javascript:";
|
||||
});
|
||||
|
||||
TrustedForwardedHeadersMiddleware.ApplyForwardedHeaders(ctx, new List<string>());
|
||||
|
||||
ctx.Request.Scheme.ShouldBe("http");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Forwarded_host_with_multiple_values_uses_only_first_token()
|
||||
{
|
||||
// Same multi-hop concern as X-Forwarded-Proto — the host string must
|
||||
// not end up as "a.example, b.example".
|
||||
var ctx = NewContext(IPAddress.Loopback, c =>
|
||||
{
|
||||
c.Request.Headers["X-Forwarded-For"] = "203.0.113.45";
|
||||
c.Request.Headers["X-Forwarded-Host"] = "cleanuparr.example.com, attacker.example.com";
|
||||
});
|
||||
|
||||
TrustedForwardedHeadersMiddleware.ApplyForwardedHeaders(ctx, new List<string>());
|
||||
|
||||
ctx.Request.Host.Value.ShouldBe("cleanuparr.example.com");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Malformed_entry_mid_chain_does_not_partially_mutate_remote_ip()
|
||||
{
|
||||
// Walk right-to-left: 10.0.0.5 (trusted) is popped first, then
|
||||
// "not-an-ip" fails. The pre-fix middleware committed mutation eagerly,
|
||||
// leaving RemoteIpAddress = 10.0.0.5. Fix: validate-then-commit, so the
|
||||
// original peer is preserved when any chain entry is malformed.
|
||||
var ctx = NewContext(IPAddress.Loopback, c =>
|
||||
c.Request.Headers["X-Forwarded-For"] = "not-an-ip, 10.0.0.5");
|
||||
|
||||
TrustedForwardedHeadersMiddleware.ApplyForwardedHeaders(ctx, new List<string>());
|
||||
|
||||
ctx.Connection.RemoteIpAddress.ShouldBe(IPAddress.Loopback);
|
||||
}
|
||||
}
|
||||
@@ -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,140 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Cleanuparr.Persistence.Models.Configuration.BlacklistSync;
|
||||
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
|
||||
using Cleanuparr.Persistence.Models.Configuration.General;
|
||||
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
|
||||
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Infrastructure;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NSubstitute;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.TestHelpers;
|
||||
|
||||
/// <summary>
|
||||
/// Shared SQLite in-memory factory for controller tests that need a populated DataContext.
|
||||
/// Seeds one row per config table so first-or-default reads succeed.
|
||||
/// </summary>
|
||||
public static class ConfigControllerTestDataFactory
|
||||
{
|
||||
public static DataContext CreateDataContext()
|
||||
{
|
||||
var connection = new SqliteConnection("DataSource=:memory:");
|
||||
connection.Open();
|
||||
|
||||
var options = new DbContextOptionsBuilder<DataContext>()
|
||||
.UseSqlite(connection)
|
||||
.UseLowerCaseNamingConvention()
|
||||
.UseSnakeCaseNamingConvention()
|
||||
.Options;
|
||||
|
||||
var context = new DataContext(options);
|
||||
context.Database.EnsureCreated();
|
||||
|
||||
SeedDefaults(context);
|
||||
return context;
|
||||
}
|
||||
|
||||
public static EventsContext CreateEventsContext()
|
||||
{
|
||||
var connection = new SqliteConnection("DataSource=:memory:");
|
||||
connection.Open();
|
||||
|
||||
var options = new DbContextOptionsBuilder<EventsContext>()
|
||||
.UseSqlite(connection)
|
||||
.UseLowerCaseNamingConvention()
|
||||
.UseSnakeCaseNamingConvention()
|
||||
.Options;
|
||||
|
||||
var context = new EventsContext(options);
|
||||
context.Database.EnsureCreated();
|
||||
return context;
|
||||
}
|
||||
|
||||
public static void ConfigureProblemDetails(ControllerBase controller)
|
||||
{
|
||||
ProblemDetailsFactory factory = Substitute.For<ProblemDetailsFactory>();
|
||||
factory
|
||||
.CreateProblemDetails(
|
||||
Arg.Any<HttpContext>(),
|
||||
Arg.Any<int?>(),
|
||||
Arg.Any<string?>(),
|
||||
Arg.Any<string?>(),
|
||||
Arg.Any<string?>(),
|
||||
Arg.Any<string?>())
|
||||
.Returns(call => new ProblemDetails
|
||||
{
|
||||
Status = call.ArgAt<int?>(1),
|
||||
Title = call.ArgAt<string?>(2),
|
||||
Detail = call.ArgAt<string?>(4),
|
||||
});
|
||||
|
||||
controller.ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = new DefaultHttpContext(),
|
||||
};
|
||||
controller.ProblemDetailsFactory = factory;
|
||||
}
|
||||
|
||||
private static void SeedDefaults(DataContext context)
|
||||
{
|
||||
context.GeneralConfigs.Add(new GeneralConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
DryRun = false,
|
||||
IgnoredDownloads = [],
|
||||
Log = new LoggingConfig(),
|
||||
});
|
||||
|
||||
context.ArrConfigs.AddRange(
|
||||
new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Sonarr, Instances = [], FailedImportMaxStrikes = 3 },
|
||||
new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Radarr, Instances = [], FailedImportMaxStrikes = 3 },
|
||||
new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Lidarr, Instances = [], FailedImportMaxStrikes = 3 },
|
||||
new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Readarr, Instances = [], FailedImportMaxStrikes = 3 },
|
||||
new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Whisparr, Instances = [], FailedImportMaxStrikes = 3 },
|
||||
new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Sportarr, Instances = [], FailedImportMaxStrikes = 3 },
|
||||
new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.LazyLibrarian, Instances = [], FailedImportMaxStrikes = 3 }
|
||||
);
|
||||
|
||||
context.QueueCleanerConfigs.Add(new QueueCleanerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
IgnoredDownloads = [],
|
||||
FailedImport = new FailedImportConfig(),
|
||||
});
|
||||
|
||||
context.ContentBlockerConfigs.Add(new ContentBlockerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
IgnoredDownloads = [],
|
||||
DeletePrivate = false,
|
||||
Sonarr = new BlocklistSettings { Enabled = false },
|
||||
Radarr = new BlocklistSettings { Enabled = false },
|
||||
Lidarr = new BlocklistSettings { Enabled = false },
|
||||
Readarr = new BlocklistSettings { Enabled = false },
|
||||
Whisparr = new BlocklistSettings { Enabled = false },
|
||||
Sportarr = new BlocklistSettings { Enabled = false },
|
||||
LazyLibrarian = new BlocklistSettings { Enabled = false },
|
||||
});
|
||||
|
||||
context.DownloadCleanerConfigs.Add(new DownloadCleanerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
IgnoredDownloads = [],
|
||||
});
|
||||
|
||||
context.BlacklistSyncConfigs.Add(new BlacklistSyncConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Enabled = false,
|
||||
CronExpression = "0 0 * * * ?",
|
||||
});
|
||||
|
||||
context.SaveChanges();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using Cleanuparr.Api.DependencyInjection;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Infrastructure;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.TestHelpers;
|
||||
|
||||
/// <summary>
|
||||
/// Attaches a minimal MVC <see cref="ControllerContext"/> (with a real <see cref="ProblemDetailsFactory"/>
|
||||
/// and <see cref="HttpContext"/>) to a directly-instantiated controller so that
|
||||
/// <c>this.ProblemResult(...)</c> can build problem-details responses in unit tests.
|
||||
/// </summary>
|
||||
public static class ControllerTestContext
|
||||
{
|
||||
private static readonly IServiceProvider Services = BuildServices();
|
||||
|
||||
private static IServiceProvider BuildServices()
|
||||
{
|
||||
ServiceCollection services = new();
|
||||
services.AddLogging();
|
||||
services.AddControllers();
|
||||
services.AddCleanuparrProblemDetails();
|
||||
return services.BuildServiceProvider();
|
||||
}
|
||||
|
||||
public static void Attach(ControllerBase controller)
|
||||
{
|
||||
controller.ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = new DefaultHttpContext { RequestServices = Services },
|
||||
};
|
||||
controller.ProblemDetailsFactory = Services.GetRequiredService<ProblemDetailsFactory>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
|
||||
"parallelizeAssembly": false,
|
||||
"parallelizeTestCollections": false
|
||||
}
|
||||
@@ -16,19 +16,22 @@ public static class TrustedNetworkAuthenticationDefaults
|
||||
|
||||
public class TrustedNetworkAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
|
||||
{
|
||||
private readonly DataContext _dataContext;
|
||||
|
||||
public TrustedNetworkAuthenticationHandler(
|
||||
IOptionsMonitor<AuthenticationSchemeOptions> options,
|
||||
ILoggerFactory logger,
|
||||
UrlEncoder encoder)
|
||||
UrlEncoder encoder,
|
||||
DataContext dataContext)
|
||||
: base(options, logger, encoder)
|
||||
{
|
||||
_dataContext = dataContext;
|
||||
}
|
||||
|
||||
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||
{
|
||||
// Load auth config from database
|
||||
await using var dataContext = DataContext.CreateStaticInstance();
|
||||
var config = await dataContext.GeneralConfigs.AsNoTracking().FirstOrDefaultAsync();
|
||||
var config = await _dataContext.GeneralConfigs.AsNoTracking().FirstOrDefaultAsync();
|
||||
|
||||
if (config is null || !config.Auth.DisableAuthForLocalAddresses)
|
||||
{
|
||||
@@ -36,7 +39,7 @@ public class TrustedNetworkAuthenticationHandler : AuthenticationHandler<Authent
|
||||
}
|
||||
|
||||
// Determine client IP
|
||||
var clientIp = GetClientIp(config.Auth.TrustForwardedHeaders);
|
||||
var clientIp = ResolveClientIp(Context);
|
||||
if (clientIp is null)
|
||||
{
|
||||
return AuthenticateResult.NoResult();
|
||||
@@ -73,42 +76,13 @@ public class TrustedNetworkAuthenticationHandler : AuthenticationHandler<Authent
|
||||
return AuthenticateResult.Success(ticket);
|
||||
}
|
||||
|
||||
private IPAddress? GetClientIp(bool trustForwardedHeaders) =>
|
||||
ResolveClientIp(Context, trustForwardedHeaders);
|
||||
|
||||
public static IPAddress? ResolveClientIp(HttpContext httpContext, bool trustForwardedHeaders)
|
||||
{
|
||||
var remoteIp = httpContext.Connection.RemoteIpAddress;
|
||||
if (remoteIp is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Only trust forwarded headers if the direct connection is from a local address
|
||||
if (trustForwardedHeaders && remoteIp.IsLocalAddress())
|
||||
{
|
||||
// Check X-Forwarded-For first, then X-Real-IP
|
||||
var forwardedFor = httpContext.Request.Headers["X-Forwarded-For"].FirstOrDefault();
|
||||
if (!string.IsNullOrEmpty(forwardedFor))
|
||||
{
|
||||
// X-Forwarded-For can contain multiple IPs: client, proxy1, proxy2
|
||||
// The first one is the original client
|
||||
var firstIp = forwardedFor.Split(',')[0].Trim();
|
||||
if (IPAddress.TryParse(firstIp, out var parsedIp))
|
||||
{
|
||||
return parsedIp;
|
||||
}
|
||||
}
|
||||
|
||||
var realIp = httpContext.Request.Headers["X-Real-IP"].FirstOrDefault();
|
||||
if (!string.IsNullOrEmpty(realIp) && IPAddress.TryParse(realIp, out var realParsedIp))
|
||||
{
|
||||
return realParsedIp;
|
||||
}
|
||||
}
|
||||
|
||||
return remoteIp;
|
||||
}
|
||||
/// <summary>
|
||||
/// Returns the connection's remote IP address. Callers must run <see cref="Cleanuparr.Api.Middleware.TrustedForwardedHeadersMiddleware"/>
|
||||
/// earlier in the pipeline so that <c>X-Forwarded-*</c> headers from trusted proxy chains have already been resolved into <c>Connection.RemoteIpAddress</c>.
|
||||
/// </summary>
|
||||
/// <param name="httpContext">The current HTTP context.</param>
|
||||
/// <returns>The resolved client IP, or <c>null</c> when unavailable.</returns>
|
||||
public static IPAddress? ResolveClientIp(HttpContext httpContext) => httpContext.Connection.RemoteIpAddress;
|
||||
|
||||
public static bool IsTrustedAddress(IPAddress clientIp, List<string> trustedNetworks)
|
||||
{
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<PublishReadyToRun>true</PublishReadyToRun>
|
||||
<ServerGarbageCollection>false</ServerGarbageCollection>
|
||||
<ConcurrentGarbageCollection>true</ConcurrentGarbageCollection>
|
||||
<EnableMacOSCodeSign>false</EnableMacOSCodeSign>
|
||||
<CodeSignOnCopy>false</CodeSignOnCopy>
|
||||
<_CodeSignDuringBuild>false</_CodeSignDuringBuild>
|
||||
@@ -20,6 +22,8 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Cleanuparr.Infrastructure\Cleanuparr.Infrastructure.csproj" />
|
||||
<ProjectReference Include="..\Cleanuparr.Persistence.Sqlite\Cleanuparr.Persistence.Sqlite.csproj" />
|
||||
<ProjectReference Include="..\Cleanuparr.Persistence.Postgres\Cleanuparr.Persistence.Postgres.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
using Cleanuparr.Infrastructure.Features.DatabaseMigration;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace Cleanuparr.Api.Commands;
|
||||
|
||||
public static class MigrateToPostgresCommand
|
||||
{
|
||||
public const string Name = "migrate-to-postgres";
|
||||
public const string ForceFlag = "--force";
|
||||
|
||||
public static bool Matches(string[] args) => args.Length > 0 && args[0] == Name;
|
||||
|
||||
public static async Task<int> RunAsync(string[] args)
|
||||
{
|
||||
IConfiguration configuration = new ConfigurationBuilder()
|
||||
.AddJsonFile(ConfigurationPathProvider.GetConfigFilePath(), optional: true)
|
||||
.AddEnvironmentVariables()
|
||||
.Build();
|
||||
DatabaseConfigProvider.Initialize(configuration);
|
||||
|
||||
bool force = args.Contains(ForceFlag);
|
||||
SqliteToPostgresMigrator migrator = new();
|
||||
MigrationResult result = await migrator.RunAsync(force, new ConsoleProgress(), CancellationToken.None);
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
Console.Error.WriteLine($"Migration failed: {result.Error}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
Console.WriteLine("Migration complete. Row counts:");
|
||||
foreach (KeyValuePair<string, int> entry in result.TableCounts.OrderBy(pair => pair.Key))
|
||||
{
|
||||
Console.WriteLine($" {entry.Key}: {entry.Value}");
|
||||
}
|
||||
|
||||
Console.WriteLine("Set DATABASE_PROVIDER=postgres and restart Cleanuparr to run on PostgreSQL.");
|
||||
Console.WriteLine("(Your SQLite databases were upgraded to the current schema in place; their data was preserved.)");
|
||||
return 0;
|
||||
}
|
||||
|
||||
private sealed class ConsoleProgress : IProgress<string>
|
||||
{
|
||||
public void Report(string value) => Console.WriteLine($" {value}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace Cleanuparr.Api.Common;
|
||||
|
||||
public static class TimelineWindow
|
||||
{
|
||||
public const int MinHours = 1;
|
||||
|
||||
public const int MaxHours = 8760;
|
||||
|
||||
public static int ClampHours(int hours) => Math.Clamp(hours, MinHours, MaxHours);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Cleanuparr.Api.Contracts.Responses;
|
||||
|
||||
public class PaginatedResult<T>
|
||||
{
|
||||
public List<T> Items { get; set; } = new();
|
||||
|
||||
public int Page { get; set; }
|
||||
|
||||
public int PageSize { get; set; }
|
||||
|
||||
public int TotalCount { get; set; }
|
||||
|
||||
public int TotalPages { get; set; }
|
||||
|
||||
[JsonIgnore]
|
||||
public bool HasPrevious => Page > 1;
|
||||
|
||||
[JsonIgnore]
|
||||
public bool HasNext => Page < TotalPages;
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using Cleanuparr.Api.Common;
|
||||
using Cleanuparr.Api.Contracts.Responses;
|
||||
using Cleanuparr.Api.Features.Events.Contracts.Responses;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Stats;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Events;
|
||||
using Cleanuparr.Persistence.Providers;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
@@ -9,26 +14,29 @@ namespace Cleanuparr.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
[Authorize]
|
||||
public class EventsController : ControllerBase
|
||||
{
|
||||
private readonly EventsContext _context;
|
||||
private readonly IDatabaseProvider _databaseProvider;
|
||||
|
||||
public EventsController(EventsContext context)
|
||||
public EventsController(EventsContext context, IDatabaseProvider databaseProvider)
|
||||
{
|
||||
_context = context;
|
||||
_databaseProvider = databaseProvider;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets events with pagination and filtering
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<PaginatedResult<AppEvent>>> GetEvents(
|
||||
public async Task<ActionResult<PaginatedResult<EventListItem>>> GetEvents(
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 100,
|
||||
[FromQuery] int pageSize = 50,
|
||||
[FromQuery] string? severity = null,
|
||||
[FromQuery] string? eventType = null,
|
||||
[FromQuery] DateTime? fromDate = null,
|
||||
[FromQuery] DateTime? toDate = null,
|
||||
[FromQuery] DateTimeOffset? fromDate = null,
|
||||
[FromQuery] DateTimeOffset? toDate = null,
|
||||
[FromQuery] string? search = null,
|
||||
[FromQuery] string? jobRunId = null)
|
||||
{
|
||||
@@ -40,42 +48,47 @@ public class EventsController : ControllerBase
|
||||
|
||||
if (pageSize < 1)
|
||||
{
|
||||
pageSize = 100;
|
||||
pageSize = 50;
|
||||
}
|
||||
|
||||
if (pageSize > 1000)
|
||||
|
||||
if (pageSize > 500)
|
||||
{
|
||||
pageSize = 1000; // Cap at 1000 for performance
|
||||
pageSize = 500;
|
||||
}
|
||||
|
||||
var query = _context.Events.AsQueryable();
|
||||
|
||||
IQueryable<EventListItem> query = _context.Events
|
||||
.Select(EventListItem.FromEvent);
|
||||
|
||||
// Apply filters
|
||||
if (!string.IsNullOrWhiteSpace(severity))
|
||||
{
|
||||
if (Enum.TryParse<EventSeverity>(severity, true, out var severityEnum))
|
||||
if (EnumSentinel.TryParseSelectable(severity, out EventSeverity severityEnum))
|
||||
{
|
||||
query = query.Where(e => e.Severity == severityEnum);
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(eventType))
|
||||
{
|
||||
if (Enum.TryParse<EventType>(eventType, true, out var eventTypeEnum))
|
||||
if (EnumSentinel.TryParseSelectable(eventType, out EventType eventTypeEnum))
|
||||
{
|
||||
query = query.Where(e => e.EventType == eventTypeEnum);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Apply date range filters
|
||||
if (fromDate.HasValue)
|
||||
{
|
||||
query = query.Where(e => e.Timestamp >= fromDate.Value);
|
||||
}
|
||||
|
||||
|
||||
if (toDate.HasValue)
|
||||
{
|
||||
query = query.Where(e => e.Timestamp <= toDate.Value);
|
||||
}
|
||||
|
||||
// Apply job run ID exact-match filter
|
||||
if (!string.IsNullOrWhiteSpace(jobRunId) && Guid.TryParse(jobRunId, out var jobRunGuid))
|
||||
if (!string.IsNullOrWhiteSpace(jobRunId) && Guid.TryParse(jobRunId, out Guid jobRunGuid))
|
||||
{
|
||||
query = query.Where(e => e.JobRunId == jobRunGuid);
|
||||
}
|
||||
@@ -85,31 +98,29 @@ public class EventsController : ControllerBase
|
||||
{
|
||||
string pattern = EventsContext.GetLikePattern(search);
|
||||
query = query.Where(e =>
|
||||
EF.Functions.Like(e.Message, pattern) ||
|
||||
EF.Functions.Like(e.Data, pattern) ||
|
||||
EF.Functions.Like(e.TrackingId.ToString(), pattern) ||
|
||||
EF.Functions.Like(e.InstanceUrl, pattern) ||
|
||||
EF.Functions.Like(e.DownloadClientName, pattern) ||
|
||||
EF.Functions.Like(e.JobRunId.ToString(), pattern)
|
||||
EF.Functions.Like(e.Message.ToLower(), pattern, "\\") ||
|
||||
(e.ItemTitle != null && EF.Functions.Like(e.ItemTitle.ToLower(), pattern, "\\")) ||
|
||||
EF.Functions.Like(e.TrackingId.ToString().ToLower(), pattern, "\\") ||
|
||||
EF.Functions.Like(e.JobRunId.ToString().ToLower(), pattern, "\\")
|
||||
);
|
||||
}
|
||||
|
||||
// Count total matching records for pagination
|
||||
var totalCount = await query.CountAsync();
|
||||
|
||||
int totalCount = await query.CountAsync();
|
||||
|
||||
// Calculate pagination
|
||||
var totalPages = (int)Math.Ceiling(totalCount / (double)pageSize);
|
||||
var skip = (page - 1) * pageSize;
|
||||
|
||||
// Get paginated data
|
||||
var events = await query
|
||||
int totalPages = (int)Math.Ceiling(totalCount / (double)pageSize);
|
||||
int skip = (page - 1) * pageSize;
|
||||
|
||||
List<EventListItem> events = await query
|
||||
.OrderByDescending(e => e.Timestamp)
|
||||
.ThenByDescending(e => e.Id)
|
||||
.Skip(skip)
|
||||
.Take(pageSize)
|
||||
.ToListAsync();
|
||||
|
||||
// Return paginated result
|
||||
var result = new PaginatedResult<AppEvent>
|
||||
PaginatedResult<EventListItem> result = new()
|
||||
{
|
||||
Items = events,
|
||||
Page = page,
|
||||
@@ -117,7 +128,7 @@ public class EventsController : ControllerBase
|
||||
TotalCount = totalCount,
|
||||
TotalPages = totalPages
|
||||
};
|
||||
|
||||
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
@@ -128,7 +139,7 @@ public class EventsController : ControllerBase
|
||||
public async Task<ActionResult<AppEvent>> GetEvent(Guid id)
|
||||
{
|
||||
var eventEntity = await _context.Events.FindAsync(id);
|
||||
|
||||
|
||||
if (eventEntity == null)
|
||||
return NotFound();
|
||||
|
||||
@@ -149,29 +160,13 @@ public class EventsController : ControllerBase
|
||||
return Ok(events);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Manually triggers cleanup of old events
|
||||
/// </summary>
|
||||
[HttpPost("cleanup")]
|
||||
public async Task<ActionResult<object>> CleanupOldEvents([FromQuery] int retentionDays = 30)
|
||||
{
|
||||
var cutoffDate = DateTime.UtcNow.AddDays(-retentionDays);
|
||||
|
||||
await _context.Events
|
||||
.Where(e => e.Timestamp < cutoffDate)
|
||||
.ExecuteDeleteAsync();
|
||||
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets unique event types
|
||||
/// </summary>
|
||||
[HttpGet("types")]
|
||||
public async Task<ActionResult<List<string>>> GetEventTypes()
|
||||
{
|
||||
var types = Enum.GetNames(typeof(EventType)).ToList();
|
||||
return Ok(types);
|
||||
return Ok(EnumSentinel.SelectableNames<EventType>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -180,51 +175,75 @@ public class EventsController : ControllerBase
|
||||
[HttpGet("severities")]
|
||||
public async Task<ActionResult<List<string>>> GetSeverities()
|
||||
{
|
||||
var severities = Enum.GetNames(typeof(EventSeverity)).ToList();
|
||||
return Ok(severities);
|
||||
return Ok(EnumSentinel.SelectableNames<EventSeverity>());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a paginated result set
|
||||
/// </summary>
|
||||
/// <typeparam name="T">Type of items in the result</typeparam>
|
||||
public class PaginatedResult<T>
|
||||
{
|
||||
/// <summary>
|
||||
/// The items in the current page
|
||||
/// </summary>
|
||||
public List<T> Items { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Current page number (1-based)
|
||||
/// </summary>
|
||||
public int Page { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of items per page
|
||||
/// </summary>
|
||||
public int PageSize { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Total number of items across all pages
|
||||
/// </summary>
|
||||
public int TotalCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Total number of pages
|
||||
/// </summary>
|
||||
public int TotalPages { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether there is a previous page
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public bool HasPrevious => Page > 1;
|
||||
|
||||
/// <summary>
|
||||
/// Whether there is a next page
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public bool HasNext => Page < TotalPages;
|
||||
}
|
||||
[HttpGet("timeline")]
|
||||
public async Task<ActionResult<EventTypeTimelineResponse>> GetTimeline([FromQuery] int hours = 720)
|
||||
{
|
||||
hours = TimelineWindow.ClampHours(hours);
|
||||
DateTimeOffset now = DateTimeOffset.UtcNow;
|
||||
DateTimeOffset cutoff = now.AddHours(-hours);
|
||||
TimelineBucketSize size = TimelineBucketing.DefaultFor(hours);
|
||||
|
||||
string bucketExpr = _databaseProvider.GetTimelineBucketExpr(size);
|
||||
string? schema = _databaseProvider.GetSchema(DbContextKind.Events);
|
||||
string eventsTable = schema is null ? "events" : $"{schema}.events";
|
||||
List<BucketTypeCount> rows = await _context.Database
|
||||
.SqlQueryRaw<BucketTypeCount>(
|
||||
$$"""
|
||||
SELECT {{bucketExpr}} AS "bucket", event_type AS "event_type", COUNT(*) AS "count"
|
||||
FROM {{eventsTable}}
|
||||
WHERE "timestamp" >= {0}
|
||||
GROUP BY {{bucketExpr}}, event_type
|
||||
""",
|
||||
cutoff)
|
||||
.ToListAsync();
|
||||
|
||||
Dictionary<(DateTimeOffset Bucket, EventType Type), int> byBucketType = new();
|
||||
HashSet<EventType> presentSet = [];
|
||||
foreach (BucketTypeCount row in rows)
|
||||
{
|
||||
DateTimeOffset bucket = TimelineBucketing.ParseKey(row.Bucket, size);
|
||||
// Raw SQL skips the value converters, so mirror what they do with unknown text.
|
||||
EventType type = EnumSentinel.ParseOrUnknown<EventType>(row.EventType);
|
||||
// Several unrecognised types read as one.
|
||||
// Their rows have to add up.
|
||||
byBucketType.TryGetValue((bucket, type), out int running);
|
||||
byBucketType[(bucket, type)] = running + row.Count;
|
||||
presentSet.Add(type);
|
||||
}
|
||||
|
||||
List<EventType> presentTypes = presentSet
|
||||
.OrderBy(t => (int)t)
|
||||
.ToList();
|
||||
|
||||
List<EventTypeTimelineBucket> buckets = [];
|
||||
foreach (DateTimeOffset bucket in TimelineBucketing.Buckets(cutoff, now, size))
|
||||
{
|
||||
Dictionary<string, int> counts = new();
|
||||
foreach (EventType type in presentTypes)
|
||||
{
|
||||
if (byBucketType.TryGetValue((bucket, type), out int count) && count > 0)
|
||||
{
|
||||
counts[type.ToString()] = count;
|
||||
}
|
||||
}
|
||||
|
||||
buckets.Add(new EventTypeTimelineBucket { Date = bucket, Counts = counts });
|
||||
}
|
||||
|
||||
return Ok(new EventTypeTimelineResponse
|
||||
{
|
||||
Types = presentTypes.Select(t => t.ToString()).ToList(),
|
||||
Buckets = buckets,
|
||||
});
|
||||
}
|
||||
|
||||
private sealed class BucketTypeCount
|
||||
{
|
||||
public string Bucket { get; set; } = string.Empty;
|
||||
public string EventType { get; set; } = string.Empty;
|
||||
public int Count { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
using Cleanuparr.Api.Extensions;
|
||||
using Cleanuparr.Infrastructure.Health;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Cleanuparr.Api.Controllers;
|
||||
@@ -8,19 +10,16 @@ namespace Cleanuparr.Api.Controllers;
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/health")]
|
||||
[Authorize]
|
||||
public class HealthCheckController : ControllerBase
|
||||
{
|
||||
private readonly ILogger<HealthCheckController> _logger;
|
||||
private readonly IHealthCheckService _healthCheckService;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="HealthCheckController"/> class
|
||||
/// </summary>
|
||||
public HealthCheckController(
|
||||
ILogger<HealthCheckController> logger,
|
||||
IHealthCheckService healthCheckService)
|
||||
public HealthCheckController(IHealthCheckService healthCheckService)
|
||||
{
|
||||
_logger = logger;
|
||||
_healthCheckService = healthCheckService;
|
||||
}
|
||||
|
||||
@@ -30,16 +29,8 @@ public class HealthCheckController : ControllerBase
|
||||
[HttpGet]
|
||||
public IActionResult GetAllHealth()
|
||||
{
|
||||
try
|
||||
{
|
||||
var healthStatuses = _healthCheckService.GetAllClientHealth();
|
||||
return Ok(healthStatuses);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error retrieving client health statuses");
|
||||
return StatusCode(500, new { Error = "An error occurred while retrieving client health statuses" });
|
||||
}
|
||||
var healthStatuses = _healthCheckService.GetAllClientHealth();
|
||||
return Ok(healthStatuses);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -48,21 +39,13 @@ public class HealthCheckController : ControllerBase
|
||||
[HttpGet("{id:guid}")]
|
||||
public IActionResult GetClientHealth(Guid id)
|
||||
{
|
||||
try
|
||||
var healthStatus = _healthCheckService.GetClientHealth(id);
|
||||
if (healthStatus == null)
|
||||
{
|
||||
var healthStatus = _healthCheckService.GetClientHealth(id);
|
||||
if (healthStatus == null)
|
||||
{
|
||||
return NotFound(new { Message = $"Health status for client with ID '{id}' not found" });
|
||||
}
|
||||
return this.ProblemResult(StatusCodes.Status404NotFound, $"Health status for client with ID '{id}' not found");
|
||||
}
|
||||
|
||||
return Ok(healthStatus);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error retrieving health status for client {id}", id);
|
||||
return StatusCode(500, new { Error = "An error occurred while retrieving the client health status" });
|
||||
}
|
||||
return Ok(healthStatus);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -71,16 +54,8 @@ public class HealthCheckController : ControllerBase
|
||||
[HttpPost("check")]
|
||||
public async Task<IActionResult> CheckAllHealth()
|
||||
{
|
||||
try
|
||||
{
|
||||
var results = await _healthCheckService.CheckAllClientsHealthAsync();
|
||||
return Ok(results);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error checking health for all clients");
|
||||
return StatusCode(500, new { Error = "An error occurred while checking client health" });
|
||||
}
|
||||
var results = await _healthCheckService.CheckAllClientsHealthAsync();
|
||||
return Ok(results);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -89,15 +64,7 @@ public class HealthCheckController : ControllerBase
|
||||
[HttpPost("check/{id:guid}")]
|
||||
public async Task<IActionResult> CheckClientHealth(Guid id)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await _healthCheckService.CheckClientHealthAsync(id);
|
||||
return Ok(result);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error checking health for client {id}", id);
|
||||
return StatusCode(500, new { Error = "An error occurred while checking client health" });
|
||||
}
|
||||
var result = await _healthCheckService.CheckClientHealthAsync(id);
|
||||
return Ok(result);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
@@ -33,13 +36,13 @@ public class HealthController : ControllerBase
|
||||
registration => registration.Tags.Contains("liveness"));
|
||||
|
||||
return result.Status == HealthStatus.Healthy
|
||||
? Ok(new { status = "healthy", timestamp = DateTime.UtcNow })
|
||||
: StatusCode(503, new { status = "unhealthy", timestamp = DateTime.UtcNow });
|
||||
? Ok(new { status = "healthy", timestamp = DateTimeOffset.UtcNow })
|
||||
: StatusCode(503, new { status = "unhealthy", timestamp = DateTimeOffset.UtcNow });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Health check failed");
|
||||
return StatusCode(503, new { status = "unhealthy", error = "Health check failed", timestamp = DateTime.UtcNow });
|
||||
return StatusCode(503, new { status = "unhealthy", error = "Health check failed", timestamp = DateTimeOffset.UtcNow });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
@@ -58,13 +62,13 @@ public class HealthController : ControllerBase
|
||||
|
||||
if (result.Status == HealthStatus.Healthy)
|
||||
{
|
||||
return Ok(new { status = "ready", timestamp = DateTime.UtcNow });
|
||||
return Ok(new { status = "ready", timestamp = DateTimeOffset.UtcNow });
|
||||
}
|
||||
|
||||
// For readiness, we consider degraded as not ready
|
||||
return StatusCode(503, new {
|
||||
status = "not_ready",
|
||||
timestamp = DateTime.UtcNow,
|
||||
timestamp = DateTimeOffset.UtcNow,
|
||||
details = result.Entries.Where(e => e.Value.Status != HealthStatus.Healthy)
|
||||
.ToDictionary(e => e.Key, e => new {
|
||||
status = e.Value.Status.ToString().ToLowerInvariant(),
|
||||
@@ -75,7 +79,7 @@ public class HealthController : ControllerBase
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Readiness check failed");
|
||||
return StatusCode(503, new { status = "not_ready", error = "Readiness check failed", timestamp = DateTime.UtcNow });
|
||||
return StatusCode(503, new { status = "not_ready", error = "Readiness check failed", timestamp = DateTimeOffset.UtcNow });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,7 +97,7 @@ public class HealthController : ControllerBase
|
||||
var response = new
|
||||
{
|
||||
status = result.Status.ToString().ToLowerInvariant(),
|
||||
timestamp = DateTime.UtcNow,
|
||||
timestamp = DateTimeOffset.UtcNow,
|
||||
totalDuration = result.TotalDuration.TotalMilliseconds,
|
||||
entries = result.Entries.ToDictionary(
|
||||
e => e.Key,
|
||||
@@ -118,7 +122,7 @@ public class HealthController : ControllerBase
|
||||
return StatusCode(503, new {
|
||||
status = "unhealthy",
|
||||
error = "Detailed health check failed",
|
||||
timestamp = DateTime.UtcNow
|
||||
timestamp = DateTimeOffset.UtcNow
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,124 +1,102 @@
|
||||
using Cleanuparr.Api.Extensions;
|
||||
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;
|
||||
private readonly ILogger<JobsController> _logger;
|
||||
|
||||
public JobsController(IJobManagementService jobManagementService, ILogger<JobsController> logger)
|
||||
public JobsController(IJobManagementService jobManagementService)
|
||||
{
|
||||
_jobManagementService = jobManagementService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetAllJobs()
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await _jobManagementService.GetAllJobs();
|
||||
return Ok(result);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error getting all jobs");
|
||||
return StatusCode(500, "An error occurred while retrieving jobs");
|
||||
}
|
||||
var result = await _jobManagementService.GetAllJobs();
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
[HttpGet("{jobType}")]
|
||||
public async Task<IActionResult> GetJob(JobType jobType)
|
||||
{
|
||||
try
|
||||
var jobInfo = await _jobManagementService.GetJob(jobType);
|
||||
|
||||
if (jobInfo.Status == "Not Found")
|
||||
{
|
||||
var jobInfo = await _jobManagementService.GetJob(jobType);
|
||||
|
||||
if (jobInfo.Status == "Not Found")
|
||||
{
|
||||
return NotFound($"Job '{jobType}' not found");
|
||||
}
|
||||
return Ok(jobInfo);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error getting job {jobType}", jobType);
|
||||
return StatusCode(500, $"An error occurred while retrieving job '{jobType}'");
|
||||
return this.ProblemResult(StatusCodes.Status404NotFound, $"Job '{jobType}' not found");
|
||||
}
|
||||
return Ok(jobInfo);
|
||||
}
|
||||
|
||||
[HttpPost("{jobType}/start")]
|
||||
public async Task<IActionResult> StartJob(JobType jobType, [FromBody] ScheduleRequest scheduleRequest = null)
|
||||
public async Task<IActionResult> StartJob(JobType jobType, [FromBody] ScheduleRequest scheduleRequest)
|
||||
{
|
||||
try
|
||||
if (jobType == JobType.Seeker)
|
||||
{
|
||||
// Get the schedule from the request body if provided
|
||||
JobSchedule jobSchedule = scheduleRequest.Schedule;
|
||||
|
||||
var result = await _jobManagementService.StartJob(jobType, jobSchedule);
|
||||
|
||||
if (!result)
|
||||
{
|
||||
return BadRequest($"Failed to start job '{jobType}'");
|
||||
}
|
||||
return Ok(new { Message = $"Job '{jobType}' started successfully" });
|
||||
return this.ProblemResult(StatusCodes.Status400BadRequest, "The Seeker job cannot be manually controlled");
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
if (scheduleRequest?.Schedule is null)
|
||||
{
|
||||
_logger.LogError(ex, "Error starting job {jobType}", jobType);
|
||||
return StatusCode(500, $"An error occurred while starting job '{jobType}'");
|
||||
return this.ProblemResult(StatusCodes.Status400BadRequest, "Schedule is required");
|
||||
}
|
||||
|
||||
var result = await _jobManagementService.StartJob(jobType, scheduleRequest.Schedule);
|
||||
|
||||
if (!result)
|
||||
{
|
||||
return this.ProblemResult(StatusCodes.Status400BadRequest, $"Failed to start job '{jobType}'");
|
||||
}
|
||||
return Ok(new { Message = $"Job '{jobType}' started successfully" });
|
||||
}
|
||||
|
||||
[HttpPost("{jobType}/trigger")]
|
||||
public async Task<IActionResult> TriggerJob(JobType jobType)
|
||||
{
|
||||
try
|
||||
if (jobType == JobType.Seeker)
|
||||
{
|
||||
var result = await _jobManagementService.TriggerJobOnce(jobType);
|
||||
|
||||
if (!result)
|
||||
{
|
||||
return BadRequest($"Failed to trigger job '{jobType}' - job may not exist or be configured");
|
||||
}
|
||||
return Ok(new { Message = $"Job '{jobType}' triggered successfully for one-time execution" });
|
||||
return this.ProblemResult(StatusCodes.Status400BadRequest, "The Seeker job cannot be manually triggered");
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
var result = await _jobManagementService.TriggerJobOnce(jobType);
|
||||
|
||||
if (!result)
|
||||
{
|
||||
_logger.LogError(ex, "Error triggering job {jobType}", jobType);
|
||||
return StatusCode(500, $"An error occurred while triggering job '{jobType}'");
|
||||
return this.ProblemResult(StatusCodes.Status400BadRequest, $"Failed to trigger job '{jobType}' - job may not exist or be configured");
|
||||
}
|
||||
return Ok(new { Message = $"Job '{jobType}' triggered successfully for one-time execution" });
|
||||
}
|
||||
|
||||
[HttpPut("{jobType}/schedule")]
|
||||
public async Task<IActionResult> UpdateJobSchedule(JobType jobType, [FromBody] ScheduleRequest scheduleRequest)
|
||||
{
|
||||
if (scheduleRequest?.Schedule == null)
|
||||
if (jobType == JobType.Seeker)
|
||||
{
|
||||
return BadRequest("Schedule is required");
|
||||
return this.ProblemResult(StatusCodes.Status400BadRequest, "The Seeker job schedule cannot be manually modified");
|
||||
}
|
||||
|
||||
try
|
||||
if (scheduleRequest?.Schedule == null)
|
||||
{
|
||||
var result = await _jobManagementService.UpdateJobSchedule(jobType, scheduleRequest.Schedule);
|
||||
|
||||
if (!result)
|
||||
{
|
||||
return BadRequest($"Failed to update schedule for job '{jobType}'");
|
||||
}
|
||||
return Ok(new { Message = $"Job '{jobType}' schedule updated successfully" });
|
||||
return this.ProblemResult(StatusCodes.Status400BadRequest, "Schedule is required");
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
var result = await _jobManagementService.UpdateJobSchedule(jobType, scheduleRequest.Schedule);
|
||||
|
||||
if (!result)
|
||||
{
|
||||
_logger.LogError(ex, "Error updating job {jobType} schedule", jobType);
|
||||
return StatusCode(500, $"An error occurred while updating schedule for job '{jobType}'");
|
||||
return this.ProblemResult(StatusCodes.Status400BadRequest, $"Failed to update schedule for job '{jobType}'");
|
||||
}
|
||||
return Ok(new { Message = $"Job '{jobType}' schedule updated successfully" });
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
using Cleanuparr.Api.Contracts.Responses;
|
||||
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 +10,7 @@ namespace Cleanuparr.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
[Authorize]
|
||||
public class ManualEventsController : ControllerBase
|
||||
{
|
||||
private readonly EventsContext _context;
|
||||
@@ -23,17 +26,28 @@ public class ManualEventsController : ControllerBase
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<PaginatedResult<ManualEvent>>> GetManualEvents(
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 100,
|
||||
[FromQuery] int pageSize = 50,
|
||||
[FromQuery] bool? isResolved = null,
|
||||
[FromQuery] string? severity = null,
|
||||
[FromQuery] DateTime? fromDate = null,
|
||||
[FromQuery] DateTime? toDate = null,
|
||||
[FromQuery] DateTimeOffset? fromDate = null,
|
||||
[FromQuery] DateTimeOffset? toDate = null,
|
||||
[FromQuery] string? search = null)
|
||||
{
|
||||
// Validate pagination parameters
|
||||
if (page < 1) page = 1;
|
||||
if (pageSize < 1) pageSize = 100;
|
||||
if (pageSize > 1000) pageSize = 1000; // Cap at 1000 for performance
|
||||
if (page < 1)
|
||||
{
|
||||
page = 1;
|
||||
}
|
||||
|
||||
if (pageSize < 1)
|
||||
{
|
||||
pageSize = 50;
|
||||
}
|
||||
|
||||
if (pageSize > 500)
|
||||
{
|
||||
pageSize = 500;
|
||||
}
|
||||
|
||||
var query = _context.ManualEvents.AsQueryable();
|
||||
|
||||
@@ -45,7 +59,7 @@ public class ManualEventsController : ControllerBase
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(severity))
|
||||
{
|
||||
if (Enum.TryParse<EventSeverity>(severity, true, out var severityEnum))
|
||||
if (EnumSentinel.TryParseSelectable(severity, out EventSeverity severityEnum))
|
||||
query = query.Where(e => e.Severity == severityEnum);
|
||||
}
|
||||
|
||||
@@ -65,10 +79,8 @@ public class ManualEventsController : ControllerBase
|
||||
{
|
||||
string pattern = EventsContext.GetLikePattern(search);
|
||||
query = query.Where(e =>
|
||||
EF.Functions.Like(e.Message, pattern) ||
|
||||
EF.Functions.Like(e.Data, pattern) ||
|
||||
EF.Functions.Like(e.InstanceUrl, pattern) ||
|
||||
EF.Functions.Like(e.DownloadClientName, pattern)
|
||||
EF.Functions.Like(e.Message.ToLower(), pattern, "\\") ||
|
||||
(e.ItemTitle != null && EF.Functions.Like(e.ItemTitle.ToLower(), pattern, "\\"))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -125,11 +137,28 @@ public class ManualEventsController : ControllerBase
|
||||
return NotFound();
|
||||
|
||||
eventEntity.IsResolved = true;
|
||||
eventEntity.ResolvedAt = DateTimeOffset.UtcNow;
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks all unresolved manual events as resolved
|
||||
/// </summary>
|
||||
[HttpPost("resolve_all")]
|
||||
public async Task<ActionResult<object>> ResolveAllManualEvents()
|
||||
{
|
||||
DateTimeOffset resolvedAt = DateTimeOffset.UtcNow;
|
||||
int resolvedCount = await _context.ManualEvents
|
||||
.Where(e => !e.IsResolved)
|
||||
.ExecuteUpdateAsync(setter => setter
|
||||
.SetProperty(e => e.IsResolved, true)
|
||||
.SetProperty(e => e.ResolvedAt, resolvedAt));
|
||||
|
||||
return Ok(new { ResolvedCount = resolvedCount });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets manual event statistics
|
||||
/// </summary>
|
||||
@@ -161,22 +190,6 @@ public class ManualEventsController : ControllerBase
|
||||
[HttpGet("severities")]
|
||||
public async Task<ActionResult<List<string>>> GetSeverities()
|
||||
{
|
||||
var severities = Enum.GetNames(typeof(EventSeverity)).ToList();
|
||||
return Ok(severities);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Manually triggers cleanup of old resolved events
|
||||
/// </summary>
|
||||
[HttpPost("cleanup")]
|
||||
public async Task<ActionResult<object>> CleanupOldResolvedEvents([FromQuery] int retentionDays = 30)
|
||||
{
|
||||
var cutoffDate = DateTime.UtcNow.AddDays(-retentionDays);
|
||||
|
||||
var deletedCount = await _context.ManualEvents
|
||||
.Where(e => e.IsResolved && e.Timestamp < cutoffDate)
|
||||
.ExecuteDeleteAsync();
|
||||
|
||||
return Ok(new { DeletedCount = deletedCount });
|
||||
return Ok(EnumSentinel.SelectableNames<EventSeverity>());
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
using Cleanuparr.Infrastructure.Stats;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Cleanuparr.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Aggregated statistics endpoint for dashboard integrations
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class StatsController : ControllerBase
|
||||
{
|
||||
private readonly ILogger<StatsController> _logger;
|
||||
private readonly IStatsService _statsService;
|
||||
|
||||
public StatsController(
|
||||
ILogger<StatsController> logger,
|
||||
IStatsService statsService)
|
||||
{
|
||||
_logger = logger;
|
||||
_statsService = statsService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets aggregated application statistics for the specified timeframe
|
||||
/// </summary>
|
||||
/// <param name="hours">Timeframe in hours (default 24, range 1-720)</param>
|
||||
/// <param name="includeEvents">Number of recent events to include (0 = none, max 100)</param>
|
||||
/// <param name="includeStrikes">Number of recent strikes to include (0 = none, max 100)</param>
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetStats(
|
||||
[FromQuery] int hours = 24,
|
||||
[FromQuery] int includeEvents = 0,
|
||||
[FromQuery] int includeStrikes = 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
hours = Math.Clamp(hours, 1, 720);
|
||||
includeEvents = Math.Clamp(includeEvents, 0, 100);
|
||||
includeStrikes = Math.Clamp(includeStrikes, 0, 100);
|
||||
|
||||
var stats = await _statsService.GetStatsAsync(hours, includeEvents, includeStrikes);
|
||||
return Ok(stats);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error retrieving stats");
|
||||
return StatusCode(500, new { Error = "An error occurred while retrieving stats" });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using Cleanuparr.Api.Common;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Stats;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Cleanuparr.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/v2/stats")]
|
||||
[Authorize]
|
||||
public class StatsV2Controller : ControllerBase
|
||||
{
|
||||
private readonly IStatsService _statsService;
|
||||
|
||||
public StatsV2Controller(IStatsService statsService)
|
||||
{
|
||||
_statsService = statsService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Aggregated statistics for the given timeframe. Every section except health is scoped to the timeframe and, by
|
||||
/// default, excludes dry-run activity.
|
||||
/// </summary>
|
||||
/// <param name="hours">Timeframe in hours (default 168, range 1-8760)</param>
|
||||
/// <param name="includeDryRun">Include dry-run activity in the timeframe-scoped sections (default false)</param>
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetStats([FromQuery] int hours = 168, [FromQuery] bool includeDryRun = false)
|
||||
{
|
||||
hours = TimelineWindow.ClampHours(hours);
|
||||
StatsV2Response stats = await _statsService.GetStatsV2Async(hours, includeDryRun);
|
||||
return Ok(stats);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bucketed timeline for a single metric.
|
||||
/// </summary>
|
||||
/// <param name="metric">strikesIssued | recovered | removed | malwareBlocked | events</param>
|
||||
/// <param name="hours">Timeframe in hours (default 720, range 1-8760)</param>
|
||||
/// <param name="bucket">Bucket size: hour | day | week | month. When omitted, hourly for timeframes up to 24h, daily otherwise.</param>
|
||||
/// <param name="includeDryRun">Include dry-run activity (default false)</param>
|
||||
[HttpGet("timeline")]
|
||||
public async Task<IActionResult> GetTimeline(
|
||||
[FromQuery] string metric = "events",
|
||||
[FromQuery] int hours = 720,
|
||||
[FromQuery] string? bucket = null,
|
||||
[FromQuery] bool includeDryRun = false)
|
||||
{
|
||||
TimelineBucketSize? size = null;
|
||||
if (!string.IsNullOrWhiteSpace(bucket))
|
||||
{
|
||||
if (!Enum.TryParse(bucket, ignoreCase: true, out TimelineBucketSize parsed) || !Enum.IsDefined(parsed))
|
||||
{
|
||||
return BadRequest($"Unsupported bucket '{bucket}'. Supported values: hour, day, week, month.");
|
||||
}
|
||||
|
||||
size = parsed;
|
||||
}
|
||||
|
||||
hours = TimelineWindow.ClampHours(hours);
|
||||
List<TimelineBucketDto> series = await _statsService.GetTimelineAsync(metric, hours, size, includeDryRun);
|
||||
return Ok(series);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics;
|
||||
using Cleanuparr.Api.Features.Status.Contracts.Responses;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Health;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
@@ -9,266 +13,134 @@ namespace Cleanuparr.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
[Authorize]
|
||||
public class StatusController : ControllerBase
|
||||
{
|
||||
private readonly ILogger<StatusController> _logger;
|
||||
private readonly DataContext _dataContext;
|
||||
private readonly IArrClientFactory _arrClientFactory;
|
||||
private readonly IInstanceHealthChecker _healthChecker;
|
||||
|
||||
// Every member is seeded in arr_configs, so a new one must not be forgotten here.
|
||||
private static readonly IReadOnlyList<InstanceType> ArrTypes = EnumSentinel.SelectableValues<InstanceType>();
|
||||
|
||||
public StatusController(
|
||||
ILogger<StatusController> logger,
|
||||
DataContext dataContext,
|
||||
IArrClientFactory arrClientFactory)
|
||||
IInstanceHealthChecker healthChecker)
|
||||
{
|
||||
_logger = logger;
|
||||
_dataContext = dataContext;
|
||||
_arrClientFactory = arrClientFactory;
|
||||
_healthChecker = healthChecker;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetSystemStatus()
|
||||
{
|
||||
try
|
||||
{
|
||||
var process = Process.GetCurrentProcess();
|
||||
|
||||
// Get configuration
|
||||
var downloadClients = await _dataContext.DownloadClients
|
||||
.AsNoTracking()
|
||||
.ToListAsync();
|
||||
var sonarrConfig = await _dataContext.ArrConfigs
|
||||
.Include(x => x.Instances)
|
||||
.AsNoTracking()
|
||||
.FirstAsync(x => x.Type == InstanceType.Sonarr);
|
||||
var radarrConfig = await _dataContext.ArrConfigs
|
||||
.Include(x => x.Instances)
|
||||
.AsNoTracking()
|
||||
.FirstAsync(x => x.Type == InstanceType.Radarr);
|
||||
var lidarrConfig = await _dataContext.ArrConfigs
|
||||
.Include(x => x.Instances)
|
||||
.AsNoTracking()
|
||||
.FirstAsync(x => x.Type == InstanceType.Lidarr);
|
||||
var readarrConfig = await _dataContext.ArrConfigs
|
||||
.Include(x => x.Instances)
|
||||
.AsNoTracking()
|
||||
.FirstAsync(x => x.Type == InstanceType.Readarr);
|
||||
|
||||
var status = new
|
||||
{
|
||||
Application = new
|
||||
{
|
||||
Version = GetType().Assembly.GetName().Version?.ToString() ?? "Unknown",
|
||||
process.StartTime,
|
||||
UpTime = DateTime.Now - process.StartTime,
|
||||
MemoryUsageMB = Math.Round(process.WorkingSet64 / 1024.0 / 1024.0, 2),
|
||||
ProcessorTime = process.TotalProcessorTime
|
||||
},
|
||||
DownloadClient = new
|
||||
{
|
||||
// TODO
|
||||
},
|
||||
MediaManagers = new
|
||||
{
|
||||
Sonarr = new
|
||||
{
|
||||
InstanceCount = sonarrConfig.Instances.Count
|
||||
},
|
||||
Radarr = new
|
||||
{
|
||||
InstanceCount = radarrConfig.Instances.Count
|
||||
},
|
||||
Lidarr = new
|
||||
{
|
||||
InstanceCount = lidarrConfig.Instances.Count
|
||||
},
|
||||
Readarr = new
|
||||
{
|
||||
InstanceCount = readarrConfig.Instances.Count
|
||||
}
|
||||
}
|
||||
};
|
||||
using var process = Process.GetCurrentProcess();
|
||||
|
||||
return Ok(status);
|
||||
}
|
||||
catch (Exception ex)
|
||||
Dictionary<InstanceType, ArrConfig> configsByType = await _dataContext.ArrConfigs
|
||||
.Include(x => x.Instances)
|
||||
.Where(x => ArrTypes.Contains(x.Type))
|
||||
.AsNoTracking()
|
||||
.ToDictionaryAsync(x => x.Type);
|
||||
|
||||
Dictionary<string, MediaManagerStatusResponse> mediaManagers = ArrTypes.ToDictionary(
|
||||
type => type.ToString(),
|
||||
type => new MediaManagerStatusResponse
|
||||
{
|
||||
InstanceCount = configsByType.TryGetValue(type, out ArrConfig? config) ? config.Instances.Count : 0,
|
||||
});
|
||||
|
||||
SystemStatusResponse status = new()
|
||||
{
|
||||
_logger.LogError(ex, "Error retrieving system status");
|
||||
return StatusCode(500, "An error occurred while retrieving system status");
|
||||
}
|
||||
Application = new ApplicationStatusResponse
|
||||
{
|
||||
Version = GetType().Assembly.GetName().Version?.ToString() ?? "Unknown",
|
||||
StartTime = process.StartTime,
|
||||
UpTime = DateTimeOffset.UtcNow - process.StartTime.ToUniversalTime(),
|
||||
MemoryUsageMB = Math.Round(process.WorkingSet64 / 1024.0 / 1024.0, 2),
|
||||
ProcessorTime = process.TotalProcessorTime,
|
||||
},
|
||||
MediaManagers = mediaManagers,
|
||||
};
|
||||
|
||||
return Ok(status);
|
||||
}
|
||||
|
||||
[HttpGet("download-client")]
|
||||
public async Task<IActionResult> GetDownloadClientStatus()
|
||||
{
|
||||
try
|
||||
{
|
||||
var downloadClients = await _dataContext.DownloadClients
|
||||
.AsNoTracking()
|
||||
.ToListAsync();
|
||||
var result = new Dictionary<string, object>();
|
||||
|
||||
// Check for configured clients
|
||||
if (downloadClients.Count > 0)
|
||||
{
|
||||
var clientsStatus = new List<object>();
|
||||
foreach (var client in downloadClients)
|
||||
{
|
||||
clientsStatus.Add(new
|
||||
{
|
||||
client.Id,
|
||||
client.Name,
|
||||
Type = client.TypeName,
|
||||
client.Host,
|
||||
client.Enabled,
|
||||
IsConnected = client.Enabled, // We can't check connection status without implementing test methods
|
||||
});
|
||||
}
|
||||
|
||||
result["Clients"] = clientsStatus;
|
||||
}
|
||||
List<DownloadClientConfig> downloadClients = await _dataContext.DownloadClients
|
||||
.AsNoTracking()
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(result);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error retrieving download client status");
|
||||
return StatusCode(500, "An error occurred while retrieving download client status");
|
||||
}
|
||||
List<DownloadClientStatusResponse> clients = downloadClients
|
||||
.Select(client => new DownloadClientStatusResponse
|
||||
{
|
||||
Id = client.Id,
|
||||
Name = client.Name,
|
||||
Type = client.TypeName,
|
||||
Host = client.Host,
|
||||
Enabled = client.Enabled,
|
||||
IsConnected = client.Enabled,
|
||||
})
|
||||
.ToList();
|
||||
|
||||
return Ok(new Dictionary<string, List<DownloadClientStatusResponse>> { ["Clients"] = clients });
|
||||
}
|
||||
|
||||
[HttpGet("arrs")]
|
||||
public async Task<IActionResult> GetMediaManagersStatus()
|
||||
{
|
||||
try
|
||||
Dictionary<string, List<InstanceConnectionResponse>> status = new();
|
||||
|
||||
foreach (InstanceType type in ArrTypes)
|
||||
{
|
||||
var status = new Dictionary<string, object>();
|
||||
|
||||
// Get configurations
|
||||
var enabledSonarrInstances = await _dataContext.ArrConfigs
|
||||
List<ArrInstance> enabledInstances = await _dataContext.ArrConfigs
|
||||
.Include(x => x.Instances)
|
||||
.Where(x => x.Type == InstanceType.Sonarr)
|
||||
.Where(x => x.Type == type)
|
||||
.SelectMany(x => x.Instances)
|
||||
.Where(x => x.Enabled)
|
||||
.AsNoTracking()
|
||||
.ToListAsync();
|
||||
var enabledRadarrInstances = await _dataContext.ArrConfigs
|
||||
.Include(x => x.Instances)
|
||||
.Where(x => x.Type == InstanceType.Radarr)
|
||||
.SelectMany(x => x.Instances)
|
||||
.Where(x => x.Enabled)
|
||||
.AsNoTracking()
|
||||
.ToListAsync();
|
||||
var enabledLidarrInstances = await _dataContext.ArrConfigs
|
||||
.Include(x => x.Instances)
|
||||
.Where(x => x.Type == InstanceType.Lidarr)
|
||||
.SelectMany(x => x.Instances)
|
||||
.Where(x => x.Enabled)
|
||||
.AsNoTracking()
|
||||
.ToListAsync();;
|
||||
|
||||
|
||||
// Check Sonarr instances
|
||||
var sonarrStatus = new List<object>();
|
||||
|
||||
foreach (var instance in enabledSonarrInstances)
|
||||
{
|
||||
try
|
||||
{
|
||||
var sonarrClient = _arrClientFactory.GetClient(InstanceType.Sonarr, instance.Version);
|
||||
await sonarrClient.HealthCheckAsync(instance);
|
||||
|
||||
sonarrStatus.Add(new
|
||||
{
|
||||
instance.Name,
|
||||
instance.Url,
|
||||
IsConnected = true,
|
||||
Message = "Successfully connected"
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
sonarrStatus.Add(new
|
||||
{
|
||||
instance.Name,
|
||||
instance.Url,
|
||||
IsConnected = false,
|
||||
Message = $"Connection failed: {ex.Message}"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
status["Sonarr"] = sonarrStatus;
|
||||
|
||||
// Check Radarr instances
|
||||
var radarrStatus = new List<object>();
|
||||
|
||||
foreach (var instance in enabledRadarrInstances)
|
||||
{
|
||||
try
|
||||
{
|
||||
var radarrClient = _arrClientFactory.GetClient(InstanceType.Radarr, instance.Version);
|
||||
await radarrClient.HealthCheckAsync(instance);
|
||||
|
||||
radarrStatus.Add(new
|
||||
{
|
||||
instance.Name,
|
||||
instance.Url,
|
||||
IsConnected = true,
|
||||
Message = "Successfully connected"
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
radarrStatus.Add(new
|
||||
{
|
||||
instance.Name,
|
||||
instance.Url,
|
||||
IsConnected = false,
|
||||
Message = $"Connection failed: {ex.Message}"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
status["Radarr"] = radarrStatus;
|
||||
|
||||
// Check Lidarr instances
|
||||
var lidarrStatus = new List<object>();
|
||||
|
||||
foreach (var instance in enabledLidarrInstances)
|
||||
{
|
||||
try
|
||||
{
|
||||
var lidarrClient = _arrClientFactory.GetClient(InstanceType.Lidarr, instance.Version);
|
||||
await lidarrClient.HealthCheckAsync(instance);
|
||||
|
||||
lidarrStatus.Add(new
|
||||
{
|
||||
instance.Name,
|
||||
instance.Url,
|
||||
IsConnected = true,
|
||||
Message = "Successfully connected"
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
lidarrStatus.Add(new
|
||||
{
|
||||
instance.Name,
|
||||
instance.Url,
|
||||
IsConnected = false,
|
||||
Message = $"Connection failed: {ex.Message}"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
status["Lidarr"] = lidarrStatus;
|
||||
|
||||
return Ok(status);
|
||||
status[type.ToString()] = await CheckInstancesAsync(type, enabledInstances);
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
return Ok(status);
|
||||
}
|
||||
|
||||
private async Task<List<InstanceConnectionResponse>> CheckInstancesAsync(InstanceType type, IReadOnlyList<ArrInstance> instances)
|
||||
{
|
||||
List<InstanceConnectionResponse> results = new(instances.Count);
|
||||
|
||||
foreach (ArrInstance instance in instances)
|
||||
{
|
||||
_logger.LogError(ex, "Error retrieving media managers status");
|
||||
return StatusCode(500, "An error occurred while retrieving media managers status");
|
||||
try
|
||||
{
|
||||
await _healthChecker.CheckAsync(type, instance);
|
||||
|
||||
results.Add(new InstanceConnectionResponse
|
||||
{
|
||||
Name = instance.Name,
|
||||
Url = instance.Url,
|
||||
IsConnected = true,
|
||||
Message = "Successfully connected",
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "health check failed for {Type} instance | {Url}", type, instance.Url);
|
||||
results.Add(new InstanceConnectionResponse
|
||||
{
|
||||
Name = instance.Name,
|
||||
Url = instance.Url,
|
||||
IsConnected = false,
|
||||
Message = $"Connection failed: {ex.Message}",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
using Cleanuparr.Api.Contracts.Responses;
|
||||
using Cleanuparr.Api.Features.Strikes.Contracts.Responses;
|
||||
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 +11,7 @@ namespace Cleanuparr.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
[Authorize]
|
||||
public class StrikesController : ControllerBase
|
||||
{
|
||||
private readonly EventsContext _context;
|
||||
@@ -27,9 +31,20 @@ public class StrikesController : ControllerBase
|
||||
[FromQuery] string? search = null,
|
||||
[FromQuery] string? type = null)
|
||||
{
|
||||
if (page < 1) page = 1;
|
||||
if (pageSize < 1) pageSize = 50;
|
||||
if (pageSize > 100) pageSize = 100;
|
||||
if (page < 1)
|
||||
{
|
||||
page = 1;
|
||||
}
|
||||
|
||||
if (pageSize < 1)
|
||||
{
|
||||
pageSize = 50;
|
||||
}
|
||||
|
||||
if (pageSize > 500)
|
||||
{
|
||||
pageSize = 500;
|
||||
}
|
||||
|
||||
var query = _context.DownloadItems
|
||||
.Include(d => d.Strikes)
|
||||
@@ -38,7 +53,7 @@ public class StrikesController : ControllerBase
|
||||
// Filter by strike type: only show items that have strikes of this type
|
||||
if (!string.IsNullOrWhiteSpace(type))
|
||||
{
|
||||
if (Enum.TryParse<StrikeType>(type, true, out var strikeType))
|
||||
if (EnumSentinel.TryParseSelectable(type, out StrikeType strikeType))
|
||||
query = query.Where(d => d.Strikes.Any(s => s.Type == strikeType));
|
||||
}
|
||||
|
||||
@@ -47,8 +62,8 @@ public class StrikesController : ControllerBase
|
||||
{
|
||||
string pattern = EventsContext.GetLikePattern(search);
|
||||
query = query.Where(d =>
|
||||
EF.Functions.Like(d.Title, pattern) ||
|
||||
EF.Functions.Like(d.DownloadId, pattern));
|
||||
EF.Functions.Like(d.Title.ToLower(), pattern, "\\") ||
|
||||
EF.Functions.Like(d.DownloadId.ToLower(), pattern, "\\"));
|
||||
}
|
||||
|
||||
var totalCount = await query.CountAsync();
|
||||
@@ -75,6 +90,7 @@ public class StrikesController : ControllerBase
|
||||
IsMarkedForRemoval = d.IsMarkedForRemoval,
|
||||
IsRemoved = d.IsRemoved,
|
||||
IsReturning = d.IsReturning,
|
||||
HasDryRunStrikes = d.Strikes.Any(s => s.IsDryRun),
|
||||
Strikes = d.Strikes
|
||||
.OrderByDescending(s => s.CreatedAt)
|
||||
.Select(s => new StrikeDetailDto
|
||||
@@ -84,6 +100,7 @@ public class StrikesController : ControllerBase
|
||||
CreatedAt = s.CreatedAt,
|
||||
LastDownloadedBytes = s.LastDownloadedBytes,
|
||||
JobRunId = s.JobRunId,
|
||||
IsDryRun = s.IsDryRun,
|
||||
}).ToList(),
|
||||
}).ToList();
|
||||
|
||||
@@ -118,6 +135,7 @@ public class StrikesController : ControllerBase
|
||||
CreatedAt = s.CreatedAt,
|
||||
DownloadId = s.DownloadItem.DownloadId,
|
||||
Title = s.DownloadItem.Title,
|
||||
IsDryRun = s.IsDryRun,
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
@@ -130,8 +148,7 @@ public class StrikesController : ControllerBase
|
||||
[HttpGet("types")]
|
||||
public ActionResult<List<string>> GetStrikeTypes()
|
||||
{
|
||||
var types = Enum.GetNames(typeof(StrikeType)).ToList();
|
||||
return Ok(types);
|
||||
return Ok(EnumSentinel.SelectableNames<StrikeType>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -154,36 +171,3 @@ public class StrikesController : ControllerBase
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
|
||||
public class DownloadItemStrikesDto
|
||||
{
|
||||
public Guid DownloadItemId { get; set; }
|
||||
public string DownloadId { get; set; } = string.Empty;
|
||||
public string Title { get; set; } = string.Empty;
|
||||
public int TotalStrikes { get; set; }
|
||||
public Dictionary<string, int> StrikesByType { get; set; } = new();
|
||||
public DateTime LatestStrikeAt { get; set; }
|
||||
public DateTime FirstStrikeAt { get; set; }
|
||||
public bool IsMarkedForRemoval { get; set; }
|
||||
public bool IsRemoved { get; set; }
|
||||
public bool IsReturning { get; set; }
|
||||
public List<StrikeDetailDto> Strikes { get; set; } = [];
|
||||
}
|
||||
|
||||
public class StrikeDetailDto
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string Type { get; set; } = string.Empty;
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public long? LastDownloadedBytes { get; set; }
|
||||
public Guid JobRunId { get; set; }
|
||||
}
|
||||
|
||||
public class RecentStrikeDto
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string Type { get; set; } = string.Empty;
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public string DownloadId { get; set; } = string.Empty;
|
||||
public string Title { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Diagnostics;
|
||||
using Cleanuparr.Api.Filters;
|
||||
using Cleanuparr.Api.Json;
|
||||
using Cleanuparr.Infrastructure.Health;
|
||||
using Cleanuparr.Infrastructure.Hubs;
|
||||
using Microsoft.AspNetCore.Http.Json;
|
||||
using System.Text;
|
||||
using Cleanuparr.Api.Middleware;
|
||||
using Cleanuparr.Shared.Configuration;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Cleanuparr.Api.DependencyInjection;
|
||||
@@ -12,63 +15,73 @@ public static class ApiDI
|
||||
{
|
||||
public static IServiceCollection AddApiServices(this IServiceCollection services)
|
||||
{
|
||||
services.Configure<JsonOptions>(options =>
|
||||
{
|
||||
options.SerializerOptions.PropertyNameCaseInsensitive = true;
|
||||
options.SerializerOptions.Converters.Add(new JsonStringEnumConverter());
|
||||
options.SerializerOptions.ReferenceHandler = ReferenceHandler.IgnoreCycles;
|
||||
});
|
||||
|
||||
services.Configure<JsonOptions>(options => CleanuparrJsonConfiguration.ConfigureApiInbound(options.SerializerOptions));
|
||||
|
||||
// Make JsonSerializerOptions available for injection
|
||||
services.AddSingleton(sp =>
|
||||
sp.GetRequiredService<IOptions<JsonOptions>>().Value.SerializerOptions);
|
||||
|
||||
|
||||
// Add API-specific services
|
||||
services
|
||||
.AddControllers()
|
||||
.AddJsonOptions(options =>
|
||||
{
|
||||
options.JsonSerializerOptions.PropertyNameCaseInsensitive = true;
|
||||
options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());
|
||||
options.JsonSerializerOptions.ReferenceHandler = ReferenceHandler.IgnoreCycles;
|
||||
});
|
||||
.AddJsonOptions(options => CleanuparrJsonConfiguration.ConfigureApiInbound(options.JsonSerializerOptions));
|
||||
services.AddEndpointsApiExplorer();
|
||||
|
||||
|
||||
// Add SignalR for real-time updates
|
||||
services
|
||||
.AddSignalR()
|
||||
.AddJsonProtocol(options =>
|
||||
{
|
||||
options.PayloadSerializerOptions.PropertyNameCaseInsensitive = true;
|
||||
options.PayloadSerializerOptions.Converters.Add(new JsonStringEnumConverter());
|
||||
});
|
||||
.AddJsonProtocol(options => CleanuparrJsonConfiguration.ConfigureApi(options.PayloadSerializerOptions));
|
||||
|
||||
// Add health status broadcaster
|
||||
services.AddHostedService<HealthStatusBroadcaster>();
|
||||
|
||||
services.AddCleanuparrProblemDetails();
|
||||
services.AddExceptionHandler<GlobalExceptionHandler>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers RFC 9457 problem-details responses for both the exception handler and
|
||||
/// [ApiController] model-state validation, attaching a uniform Activity-tied traceId.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddCleanuparrProblemDetails(this IServiceCollection services)
|
||||
{
|
||||
services.AddProblemDetails(options =>
|
||||
{
|
||||
options.CustomizeProblemDetails = ctx =>
|
||||
ctx.ProblemDetails.Extensions.TryAdd(
|
||||
"traceId", Activity.Current?.Id ?? ctx.HttpContext.TraceIdentifier);
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
public static WebApplication ConfigureApi(this WebApplication app)
|
||||
{
|
||||
ILogger<Program> logger = app.Services.GetRequiredService<ILogger<Program>>();
|
||||
|
||||
// Map unhandled exceptions to RFC 9457 problem-details responses (GlobalExceptionHandler).
|
||||
// Registered first so it also covers exceptions thrown by downstream middleware.
|
||||
app.UseExceptionHandler();
|
||||
|
||||
// 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
|
||||
app.UseMiddleware<ExceptionMiddleware>();
|
||||
|
||||
// Resolve the real client IP / scheme / host from X-Forwarded-* headers
|
||||
app.UseMiddleware<TrustedForwardedHeadersMiddleware>();
|
||||
|
||||
// Block non-auth requests until setup is complete
|
||||
app.UseMiddleware<SetupGuardMiddleware>();
|
||||
|
||||
app.UseCors("Any");
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseCors("DevSpa");
|
||||
}
|
||||
app.UseRouting();
|
||||
|
||||
app.UseAuthentication();
|
||||
@@ -78,7 +91,7 @@ public static class ApiDI
|
||||
// Custom SPA fallback to inject base path
|
||||
app.MapFallback(async context =>
|
||||
{
|
||||
var basePath = app.Configuration.GetValue<string>("BASE_PATH") ?? "/";
|
||||
var basePath = app.Configuration.GetValue<string>(ConfigurationKeys.BasePath) ?? "/";
|
||||
|
||||
// Normalize the base path (remove trailing slash if not root)
|
||||
if (basePath != "/" && basePath.EndsWith("/"))
|
||||
@@ -111,6 +124,7 @@ public static class ApiDI
|
||||
);
|
||||
|
||||
context.Response.ContentType = "text/html";
|
||||
NoCacheAttribute.Apply(context.Response.Headers);
|
||||
await context.Response.WriteAsync(indexContent, Encoding.UTF8);
|
||||
}).AllowAnonymous();
|
||||
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using Cleanuparr.Api.Json;
|
||||
using Cleanuparr.Domain.Entities.Arr;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadHunter.Consumers;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadRemover.Consumers;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Consumers;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Models;
|
||||
using Cleanuparr.Infrastructure.Health;
|
||||
using Cleanuparr.Infrastructure.Http;
|
||||
using Cleanuparr.Infrastructure.Http.DynamicHttpClientSystem;
|
||||
using Data.Models.Arr;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
using MassTransit;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
|
||||
@@ -28,42 +27,28 @@ public static class MainDI
|
||||
{
|
||||
config.DisableUsageTelemetry();
|
||||
|
||||
config.AddConsumer<DownloadRemoverConsumer<SearchItem>>();
|
||||
config.AddConsumer<DownloadRemoverConsumer<SeriesSearchItem>>();
|
||||
config.AddConsumer<DownloadHunterConsumer<SearchItem>>();
|
||||
config.AddConsumer<DownloadHunterConsumer<SeriesSearchItem>>();
|
||||
|
||||
config.AddConsumer<DownloadRemoverConsumer>();
|
||||
config.AddConsumer<NotificationConsumer<FailedImportStrikeNotification>>();
|
||||
config.AddConsumer<NotificationConsumer<StalledStrikeNotification>>();
|
||||
config.AddConsumer<NotificationConsumer<SlowSpeedStrikeNotification>>();
|
||||
config.AddConsumer<NotificationConsumer<SlowTimeStrikeNotification>>();
|
||||
config.AddConsumer<NotificationConsumer<QueueItemDeletedNotification>>();
|
||||
config.AddConsumer<NotificationConsumer<DownloadCleanedNotification>>();
|
||||
config.AddConsumer<NotificationConsumer<DownloadStoppedNotification>>();
|
||||
config.AddConsumer<NotificationConsumer<CategoryChangedNotification>>();
|
||||
|
||||
config.UsingInMemory((context, cfg) =>
|
||||
{
|
||||
cfg.ConfigureJsonSerializerOptions(options =>
|
||||
{
|
||||
options.PropertyNameCaseInsensitive = true;
|
||||
options.Converters.Add(new JsonStringEnumConverter());
|
||||
options.ReferenceHandler = ReferenceHandler.IgnoreCycles;
|
||||
CleanuparrJsonConfiguration.ConfigureCore(options);
|
||||
|
||||
return options;
|
||||
});
|
||||
|
||||
cfg.ReceiveEndpoint("download-remover-queue", e =>
|
||||
{
|
||||
e.ConfigureConsumer<DownloadRemoverConsumer<SearchItem>>(context);
|
||||
e.ConfigureConsumer<DownloadRemoverConsumer<SeriesSearchItem>>(context);
|
||||
e.ConcurrentMessageLimit = 1;
|
||||
e.PrefetchCount = 1;
|
||||
});
|
||||
|
||||
cfg.ReceiveEndpoint("download-hunter-queue", e =>
|
||||
{
|
||||
e.ConfigureConsumer<DownloadHunterConsumer<SearchItem>>(context);
|
||||
e.ConfigureConsumer<DownloadHunterConsumer<SeriesSearchItem>>(context);
|
||||
e.ConfigureConsumer<DownloadRemoverConsumer>(context);
|
||||
e.ConcurrentMessageLimit = 1;
|
||||
e.PrefetchCount = 1;
|
||||
});
|
||||
@@ -76,6 +61,7 @@ public static class MainDI
|
||||
e.ConfigureConsumer<NotificationConsumer<SlowTimeStrikeNotification>>(context);
|
||||
e.ConfigureConsumer<NotificationConsumer<QueueItemDeletedNotification>>(context);
|
||||
e.ConfigureConsumer<NotificationConsumer<DownloadCleanedNotification>>(context);
|
||||
e.ConfigureConsumer<NotificationConsumer<DownloadStoppedNotification>>(context);
|
||||
e.ConfigureConsumer<NotificationConsumer<CategoryChangedNotification>>(context);
|
||||
e.ConcurrentMessageLimit = 1;
|
||||
e.PrefetchCount = 1;
|
||||
@@ -91,9 +77,6 @@ public static class MainDI
|
||||
// Add the dynamic HTTP client provider that uses the new system
|
||||
services.AddSingleton<IDynamicHttpClientProvider, DynamicHttpClientProvider>();
|
||||
|
||||
// Add HTTP client for Plex authentication
|
||||
services.AddHttpClient("PlexAuth");
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
using Cleanuparr.Api.Features.Auth;
|
||||
using Cleanuparr.Infrastructure.Health;
|
||||
using Cleanuparr.Infrastructure.Features.LazyLibrarian;
|
||||
using Cleanuparr.Infrastructure.Events;
|
||||
using Cleanuparr.Infrastructure.Events.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.Arr;
|
||||
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.Auth;
|
||||
using Cleanuparr.Infrastructure.Features.BlacklistSync;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadCleaner.Services;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadHunter;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadHunter.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadRemover;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadRemover.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.Files;
|
||||
@@ -19,6 +21,7 @@ using Cleanuparr.Infrastructure.Services;
|
||||
using Cleanuparr.Infrastructure.Services.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Stats;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Providers;
|
||||
|
||||
namespace Cleanuparr.Api.DependencyInjection;
|
||||
|
||||
@@ -29,27 +32,40 @@ public static class ServicesDI
|
||||
.AddScoped<EventsContext>()
|
||||
.AddScoped<DataContext>()
|
||||
.AddScoped<UsersContext>()
|
||||
.AddSingleton<IDatabaseProvider>(_ => DatabaseProviderFactory.Current)
|
||||
.AddSingleton<IJwtService, JwtService>()
|
||||
.AddSingleton<IPasswordService, PasswordService>()
|
||||
.AddSingleton<ITotpService, TotpService>()
|
||||
.AddScoped<LoginAttemptTracker>()
|
||||
.AddScoped<IPlexAuthService, PlexAuthService>()
|
||||
.AddScoped<IOidcAuthService, OidcAuthService>()
|
||||
.AddScoped<IEventPublisher, EventPublisher>()
|
||||
.AddHostedService<EventCleanupService>()
|
||||
.AddScoped<IDryRunInterceptor, DryRunInterceptor>()
|
||||
.AddScoped<CertificateValidationService>()
|
||||
.AddScoped<ISonarrClient, SonarrClient>()
|
||||
.AddScoped<ISportarrClient, SportarrClient>()
|
||||
.AddScoped<IRadarrClient, RadarrClient>()
|
||||
.AddScoped<ILidarrClient, LidarrClient>()
|
||||
.AddScoped<IReadarrClient, ReadarrClient>()
|
||||
.AddScoped<IWhisparrV2Client, WhisparrV2Client>()
|
||||
.AddScoped<IWhisparrV3Client, WhisparrV3Client>()
|
||||
.AddScoped<ILazyLibrarianService, LazyLibrarianService>()
|
||||
.AddScoped<IInstanceHealthChecker, InstanceHealthChecker>()
|
||||
.AddKeyedScoped<ILazyLibrarianEvaluator, LazyLibrarianServiceQC>(ILazyLibrarianEvaluator.QueueCleanerKey)
|
||||
.AddKeyedScoped<ILazyLibrarianEvaluator, LazyLibrarianServiceCB>(ILazyLibrarianEvaluator.MalwareBlockerKey)
|
||||
.AddScoped<IArrClientFactory, ArrClientFactory>()
|
||||
.AddScoped<QueueCleaner>()
|
||||
.AddScoped<BlacklistSynchronizer>()
|
||||
.AddScoped<MalwareBlocker>()
|
||||
.AddScoped<DownloadCleaner>()
|
||||
.AddScoped<ISeedingRulesCleanupService, SeedingRulesCleanupService>()
|
||||
.AddScoped<IUnlinkedDownloadsService, UnlinkedDownloadsService>()
|
||||
.AddScoped<IDeadTorrentService, DeadTorrentService>()
|
||||
.AddScoped<IOrphanedFilesCleanupService, OrphanedFilesCleanupService>()
|
||||
.AddScoped<Seeker>()
|
||||
.AddScoped<CustomFormatScoreSyncer>()
|
||||
.AddScoped<IQueueItemRemover, QueueItemRemover>()
|
||||
.AddScoped<IDownloadHunter, DownloadHunter>()
|
||||
.AddScoped<IFilenameEvaluator, FilenameEvaluator>()
|
||||
.AddScoped<IHardLinkFileService, HardLinkFileService>()
|
||||
.AddScoped<IUnixHardLinkFileService, UnixHardLinkFileService>()
|
||||
@@ -57,14 +73,17 @@ public static class ServicesDI
|
||||
.AddScoped<IArrQueueIterator, ArrQueueIterator>()
|
||||
.AddScoped<IDownloadServiceFactory, DownloadServiceFactory>()
|
||||
.AddScoped<IStriker, Striker>()
|
||||
.AddScoped<IConnectivityChecker, ConnectivityChecker>()
|
||||
.AddScoped<FileReader>()
|
||||
.AddScoped<IRuleManager, RuleManager>()
|
||||
.AddScoped<IRuleEvaluator, RuleEvaluator>()
|
||||
.AddScoped<IQueueRuleManager, QueueRuleManager>()
|
||||
.AddScoped<IQueueRuleEvaluator, QueueRuleEvaluator>()
|
||||
.AddScoped<ISeedingRuleEvaluator, SeedingRuleEvaluator>()
|
||||
.AddScoped<IRuleIntervalValidator, RuleIntervalValidator>()
|
||||
.AddScoped<IStatsService, StatsService>()
|
||||
.AddSingleton<IJobManagementService, JobManagementService>()
|
||||
.AddSingleton<IBlocklistProvider, BlocklistProvider>()
|
||||
.AddSingleton(TimeProvider.System)
|
||||
.AddSingleton<AppStatusSnapshot>()
|
||||
.AddHostedService<AppStatusRefreshService>();
|
||||
.AddHostedService<AppStatusRefreshService>()
|
||||
.AddHostedService<SeekerCommandMonitor>();
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using Cleanuparr.Persistence;
|
||||
using Microsoft.EntityFrameworkCore.Design;
|
||||
|
||||
namespace Cleanuparr.Api.DesignTime;
|
||||
|
||||
public sealed class DataContextFactory : IDesignTimeDbContextFactory<DataContext>
|
||||
{
|
||||
public DataContext CreateDbContext(string[] args) => DataContext.CreateStaticInstance();
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using Cleanuparr.Persistence;
|
||||
using Microsoft.EntityFrameworkCore.Design;
|
||||
|
||||
namespace Cleanuparr.Api.DesignTime;
|
||||
|
||||
public sealed class EventsContextFactory : IDesignTimeDbContextFactory<EventsContext>
|
||||
{
|
||||
public EventsContext CreateDbContext(string[] args) => EventsContext.CreateStaticInstance();
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using Cleanuparr.Persistence;
|
||||
using Microsoft.EntityFrameworkCore.Design;
|
||||
|
||||
namespace Cleanuparr.Api.DesignTime;
|
||||
|
||||
public sealed class UsersContextFactory : IDesignTimeDbContextFactory<UsersContext>
|
||||
{
|
||||
public UsersContext CreateDbContext(string[] args) => UsersContext.CreateStaticInstance();
|
||||
}
|
||||
Loaded 100 of 1509 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user