mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2026-09-08 11:28:02 -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 |
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@v5
|
||||
uses: actions/setup-dotnet@v6
|
||||
with:
|
||||
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
|
||||
|
||||
@@ -9,12 +9,15 @@ on:
|
||||
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
|
||||
@@ -24,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: ${{ 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
|
||||
|
||||
@@ -45,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,21 +70,22 @@ 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@v5
|
||||
uses: actions/setup-dotnet@v6
|
||||
with:
|
||||
dotnet-version: 10.0.200
|
||||
|
||||
@@ -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 }}'
|
||||
|
||||
@@ -14,6 +14,9 @@ on:
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build-windows-installer:
|
||||
runs-on: windows-latest
|
||||
@@ -49,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
|
||||
@@ -60,20 +63,21 @@ jobs:
|
||||
secrets/data/github packages_pat | PACKAGES_PAT
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
repository: ${{ env.githubRepository }}
|
||||
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@v5
|
||||
uses: actions/setup-dotnet@v6
|
||||
with:
|
||||
dotnet-version: 10.0.200
|
||||
|
||||
@@ -95,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
|
||||
|
||||
@@ -143,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,9 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Create directory for static files
|
||||
run: |
|
||||
@@ -33,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
|
||||
+78
-35
@@ -1,22 +1,17 @@
|
||||
name: E2E Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'code/**'
|
||||
- 'e2e/**'
|
||||
- '.github/workflows/e2e.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'code/**'
|
||||
- 'e2e/**'
|
||||
- '.github/workflows/e2e.yml'
|
||||
workflow_call:
|
||||
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-${{ github.event.pull_request.number || github.ref }}
|
||||
group: E2E Tests-${{ inputs.ref || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
@@ -25,15 +20,51 @@ permissions:
|
||||
jobs:
|
||||
e2e:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
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@v4
|
||||
uses: actions/checkout@v7
|
||||
timeout-minutes: 1
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Get vault secrets
|
||||
uses: hashicorp/vault-action@v2
|
||||
uses: hashicorp/vault-action@v4
|
||||
with:
|
||||
url: ${{ secrets.VAULT_HOST }}
|
||||
method: approle
|
||||
@@ -44,45 +75,57 @@ jobs:
|
||||
|
||||
- name: Start services
|
||||
working-directory: e2e
|
||||
run: docker compose -f docker-compose.e2e.yml up -d --build
|
||||
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@v4
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22
|
||||
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: Wait for Keycloak
|
||||
run: |
|
||||
echo "Waiting for Keycloak realm to be ready..."
|
||||
timeout 120 bash -c 'until curl -sf http://localhost:8080/realms/cleanuparr-test/.well-known/openid-configuration; do sleep 3; done'
|
||||
echo "Keycloak ready!"
|
||||
|
||||
- name: Wait for app
|
||||
run: |
|
||||
echo "Waiting for Cleanuparr to be ready..."
|
||||
timeout 120 bash -c 'until curl -sf http://localhost:5000/health; do sleep 3; done'
|
||||
echo "App ready!"
|
||||
|
||||
- name: Run E2E tests
|
||||
working-directory: e2e
|
||||
run: npx playwright test
|
||||
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@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
if: always()
|
||||
with:
|
||||
name: e2e-test-results
|
||||
name: e2e-test-results-${{ matrix.suite.name }}
|
||||
path: |
|
||||
e2e/playwright-report/
|
||||
e2e/test-results/
|
||||
@@ -91,4 +134,4 @@ jobs:
|
||||
- name: Stop services
|
||||
if: always()
|
||||
working-directory: e2e
|
||||
run: docker compose -f docker-compose.e2e.yml down
|
||||
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}`);
|
||||
}
|
||||
+192
-75
@@ -4,21 +4,21 @@ on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
concurrency:
|
||||
group: pr-build-${{ github.event.issue.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
actions: read
|
||||
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_windows: ${{ steps.parse.outputs.build_windows }}
|
||||
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 }}
|
||||
@@ -26,20 +26,51 @@ jobs:
|
||||
steps:
|
||||
- name: Parse command and check permissions
|
||||
id: parse
|
||||
uses: actions/github-script@v7
|
||||
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': 'build_windows'
|
||||
'/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.`);
|
||||
core.setOutput('build_windows', 'false');
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -64,49 +95,27 @@ jobs:
|
||||
// Verify PR is open
|
||||
if (pr.data.state !== 'open') {
|
||||
console.log('PR is not open, skipping.');
|
||||
core.setOutput('build_windows', 'false');
|
||||
return;
|
||||
}
|
||||
|
||||
// Block fork PRs — fork code should not run with access to secrets
|
||||
const isFork = pr.data.head.repo.full_name !== context.repo.owner + '/' + context.repo.repo;
|
||||
if (isFork) {
|
||||
console.log(`PR is from fork ${pr.data.head.repo.full_name}, blocking build.`);
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: 'On-demand builds are not available for PRs from forks.'
|
||||
});
|
||||
core.setOutput('build_windows', 'false');
|
||||
return;
|
||||
}
|
||||
console.log(`${login} triggered ${comment} on PR #${pr.data.number} @ ${pr.data.head.sha}.`);
|
||||
|
||||
// Verify commenter has write access
|
||||
let permission = 'none';
|
||||
try {
|
||||
const resp = await github.rest.repos.getCollaboratorPermissionLevel({
|
||||
await github.rest.repos.createCommitStatus({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
username: context.payload.comment.user.login
|
||||
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}`
|
||||
});
|
||||
permission = resp.data.permission;
|
||||
} catch (_) {}
|
||||
|
||||
if (!['admin', 'write'].includes(permission)) {
|
||||
console.log(`User ${context.payload.comment.user.login} has '${permission}' permission — insufficient.`);
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: `@${context.payload.comment.user.login} Only collaborators with write access can trigger builds.`
|
||||
});
|
||||
core.setOutput('build_windows', 'false');
|
||||
return;
|
||||
} catch (e) {
|
||||
console.log(`Could not create commit status: ${e}`);
|
||||
}
|
||||
|
||||
console.log(`User ${context.payload.comment.user.login} has '${permission}' permission — proceeding with ${command}.`);
|
||||
core.setOutput(command, 'true');
|
||||
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);
|
||||
@@ -115,59 +124,167 @@ jobs:
|
||||
|
||||
build-frontend:
|
||||
needs: validate
|
||||
if: needs.validate.outputs.build_windows == 'true'
|
||||
if: needs.validate.outputs.build == 'windows' || needs.validate.outputs.build == 'linux'
|
||||
uses: ./.github/workflows/build-frontend.yml
|
||||
with:
|
||||
ref: ${{ needs.validate.outputs.pr_ref }}
|
||||
ref: ${{ needs.validate.outputs.pr_sha }}
|
||||
secrets: inherit
|
||||
|
||||
build-windows:
|
||||
needs: [validate, build-frontend]
|
||||
if: needs.validate.outputs.build_windows == 'true'
|
||||
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_ref }}
|
||||
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-windows]
|
||||
if: always() && needs.validate.outputs.build_windows == 'true'
|
||||
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@v7
|
||||
uses: actions/github-script@v9
|
||||
env:
|
||||
PR_REF: ${{ needs.validate.outputs.pr_ref }}
|
||||
META: ${{ needs.validate.outputs.meta }}
|
||||
with:
|
||||
script: |
|
||||
const buildResult = '${{ needs.build-windows.result }}';
|
||||
const runUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
|
||||
const prRef = '${{ needs.validate.outputs.pr_ref }}';
|
||||
const prSha = '${{ needs.validate.outputs.pr_sha }}';
|
||||
const shortSha = prSha.substring(0, 7);
|
||||
const target = '${{ needs.validate.outputs.build }}';
|
||||
|
||||
// Skip comment for skipped builds
|
||||
if (buildResult === 'skipped') {
|
||||
console.log('Build was skipped, no comment needed.');
|
||||
// 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;
|
||||
}
|
||||
|
||||
let body;
|
||||
if (buildResult === 'success') {
|
||||
body = [
|
||||
`Windows installer build **succeeded** for \`${prRef}\` (\`${shortSha}\`).`,
|
||||
``,
|
||||
`**Download:** open the [workflow run](${runUrl}), scroll to the **Artifacts** section at the bottom.`,
|
||||
`The artifact \`Cleanuparr-windows-installer\` is retained for 30 days.`
|
||||
].join('\n');
|
||||
} else if (buildResult === 'cancelled') {
|
||||
body = [
|
||||
`Windows installer build was **cancelled** for \`${prRef}\` (\`${shortSha}\`).`,
|
||||
``,
|
||||
`See the [workflow run](${runUrl}) for details.`
|
||||
].join('\n');
|
||||
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 = [
|
||||
`Windows installer build **failed** for \`${prRef}\` (\`${shortSha}\`).`,
|
||||
`${label} ${verb} for \`${prRef}\` (\`${shortSha}\`).`,
|
||||
``,
|
||||
`See the [workflow run](${runUrl}) for details.`
|
||||
].join('\n');
|
||||
@@ -176,6 +293,6 @@ jobs:
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: parseInt('${{ needs.validate.outputs.pr_number }}'),
|
||||
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
|
||||
@@ -96,33 +157,24 @@ jobs:
|
||||
uses: ./.github/workflows/test.yml
|
||||
secrets: inherit
|
||||
|
||||
# Run E2E tests
|
||||
e2e:
|
||||
needs: validate
|
||||
if: ${{ needs.validate.outputs.is_tag == 'true' || github.event.inputs.runTests == 'true' }}
|
||||
uses: ./.github/workflows/e2e.yml
|
||||
secrets: inherit
|
||||
|
||||
# Build frontend once for all build jobs and cache it
|
||||
build-frontend:
|
||||
needs: [validate, test, e2e]
|
||||
needs: [validate, test]
|
||||
if: |
|
||||
always() &&
|
||||
needs.validate.result == 'success' &&
|
||||
(needs.test.result == 'success' || needs.test.result == 'skipped') &&
|
||||
(needs.e2e.result == 'success' || needs.e2e.result == 'skipped') &&
|
||||
(needs.validate.outputs.is_tag == 'true' || github.event.inputs.buildBinaries == 'true')
|
||||
uses: ./.github/workflows/build-frontend.yml
|
||||
secrets: inherit
|
||||
|
||||
# Build portable executables
|
||||
build-executables:
|
||||
needs: [validate, test, e2e, build-frontend]
|
||||
needs: [validate, test, build-frontend]
|
||||
if: |
|
||||
always() &&
|
||||
needs.validate.result == 'success' &&
|
||||
(needs.test.result == 'success' || needs.test.result == 'skipped') &&
|
||||
(needs.e2e.result == 'success' || needs.e2e.result == 'skipped') &&
|
||||
needs.build-frontend.result == 'success' &&
|
||||
(needs.validate.outputs.is_tag == 'true' || github.event.inputs.buildBinaries == 'true')
|
||||
uses: ./.github/workflows/build-executable.yml
|
||||
@@ -132,12 +184,11 @@ jobs:
|
||||
|
||||
# Build Windows installer
|
||||
build-windows-installer:
|
||||
needs: [validate, test, e2e, build-frontend]
|
||||
needs: [validate, test, build-frontend]
|
||||
if: |
|
||||
always() &&
|
||||
needs.validate.result == 'success' &&
|
||||
(needs.test.result == 'success' || needs.test.result == 'skipped') &&
|
||||
(needs.e2e.result == 'success' || needs.e2e.result == 'skipped') &&
|
||||
needs.build-frontend.result == 'success' &&
|
||||
(needs.validate.outputs.is_tag == 'true' || github.event.inputs.buildBinaries == 'true')
|
||||
uses: ./.github/workflows/build-windows-installer.yml
|
||||
@@ -147,12 +198,11 @@ jobs:
|
||||
|
||||
# Build macOS installers (Intel and ARM)
|
||||
build-macos:
|
||||
needs: [validate, test, e2e, build-frontend]
|
||||
needs: [validate, test, build-frontend]
|
||||
if: |
|
||||
always() &&
|
||||
needs.validate.result == 'success' &&
|
||||
(needs.test.result == 'success' || needs.test.result == 'skipped') &&
|
||||
(needs.e2e.result == 'success' || needs.e2e.result == 'skipped') &&
|
||||
needs.build-frontend.result == 'success' &&
|
||||
(needs.validate.outputs.is_tag == 'true' || github.event.inputs.buildBinaries == 'true')
|
||||
uses: ./.github/workflows/build-macos-installer.yml
|
||||
@@ -162,12 +212,14 @@ jobs:
|
||||
|
||||
# Build and push Docker image(s)
|
||||
build-docker:
|
||||
needs: [validate, test, e2e]
|
||||
needs: [validate, test]
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
if: |
|
||||
always() &&
|
||||
needs.validate.result == 'success' &&
|
||||
(needs.test.result == 'success' || needs.test.result == 'skipped') &&
|
||||
(needs.e2e.result == 'success' || needs.e2e.result == 'skipped') &&
|
||||
(needs.validate.outputs.is_tag == 'true' || github.event.inputs.buildDocker == 'true')
|
||||
uses: ./.github/workflows/build-docker.yml
|
||||
with:
|
||||
@@ -192,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
|
||||
@@ -202,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
|
||||
@@ -229,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 }}
|
||||
@@ -242,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, e2e, build-frontend, build-executables, build-windows-installer, build-macos, build-docker]
|
||||
needs: [validate, test, build-frontend, build-executables, build-windows-installer, build-macos, build-docker]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
if: always()
|
||||
|
||||
steps:
|
||||
@@ -289,7 +363,6 @@ jobs:
|
||||
}
|
||||
|
||||
print_result "Tests" "${{ needs.test.result }}"
|
||||
print_result "E2E Tests" "${{ needs.e2e.result }}"
|
||||
print_result "Frontend Build" "${{ needs.build-frontend.result }}"
|
||||
print_result "Portable Executables" "${{ needs.build-executables.result }}"
|
||||
print_result "Windows Installer" "${{ needs.build-windows-installer.result }}"
|
||||
|
||||
@@ -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: Tests-${{ 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@v5
|
||||
uses: actions/setup-dotnet@v6
|
||||
with:
|
||||
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
|
||||
|
||||
@@ -46,11 +46,13 @@ Cleanuparr is a tool for automating the cleanup of unwanted or blocked files in
|
||||
- Always use **NSubstitute** for mocking in new tests (Moq is being phased out)
|
||||
|
||||
### Frontend
|
||||
- **Angular 21** with TypeScript 5.9 (standalone components, zoneless, OnPush)
|
||||
- **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)
|
||||
- **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
|
||||
|
||||
@@ -69,12 +71,12 @@ Cleanuparr/
|
||||
│ │ ├── Cleanuparr.Persistence/ # SQLite data access
|
||||
│ │ ├── Cleanuparr.Persistence.Tests/
|
||||
│ │ └── Cleanuparr.Shared/ # Shared utilities
|
||||
│ ├── frontend/ # Angular 21 application
|
||||
│ ├── e2e/ # Playwright E2E tests
|
||||
│ ├── 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
|
||||
@@ -93,11 +95,16 @@ Cleanuparr/
|
||||
- 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`
|
||||
@@ -109,7 +116,13 @@ Cleanuparr/
|
||||
- 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
|
||||
- No frontend unit tests currently
|
||||
- **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
|
||||
@@ -136,9 +149,17 @@ 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
|
||||
```
|
||||
Both run as concurrent `backend` and `frontend` jobs in `.github/workflows/test.yml`.
|
||||
|
||||
## Database Migrations
|
||||
|
||||
@@ -179,11 +200,17 @@ make migrate-users name=YourMigrationName
|
||||
|
||||
- **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 21)
|
||||
- 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
|
||||
- **Settings dirty tracking** uses JSON snapshot comparison (`buildSnapshot()` + `hasPendingChanges()`)
|
||||
- **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)
|
||||
+47
-2
@@ -27,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/)
|
||||
@@ -160,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
|
||||
@@ -241,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
|
||||
|
||||
@@ -34,6 +34,7 @@ Cleanuparr was created primarily to address malicious files, such as `*.lnk` or
|
||||
> - 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.
|
||||
|
||||
@@ -53,6 +54,8 @@ https://cleanuparr.github.io/Cleanuparr/docs/screenshots
|
||||
- **Readarr**
|
||||
- **Whisparr v2**
|
||||
- **Whisparr v3**
|
||||
- **Sportarr**
|
||||
- **LazyLibrarian**
|
||||
|
||||
### Download Clients (latest version)
|
||||
- **qBittorrent**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -87,7 +87,7 @@ public class AccountControllerOidcTests : IClassFixture<AccountControllerOidcTes
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
body.GetProperty("error").GetString().ShouldContain("OIDC is not enabled");
|
||||
body.GetProperty("detail").GetString().ShouldContain("OIDC is not enabled");
|
||||
}
|
||||
|
||||
[Fact, TestPriority(3)]
|
||||
@@ -214,6 +214,18 @@ public class AccountControllerOidcTests : IClassFixture<AccountControllerOidcTes
|
||||
}
|
||||
|
||||
[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);
|
||||
@@ -221,7 +233,7 @@ public class AccountControllerOidcTests : IClassFixture<AccountControllerOidcTes
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Forbidden);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(13)]
|
||||
[Fact, TestPriority(14)]
|
||||
public async Task PlexUnlink_Blocked_WhenExclusiveModeActive()
|
||||
{
|
||||
var response = await _client.DeleteAsync("/api/account/plex/link");
|
||||
@@ -229,7 +241,7 @@ public class AccountControllerOidcTests : IClassFixture<AccountControllerOidcTes
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Forbidden);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(14)]
|
||||
[Fact, TestPriority(15)]
|
||||
public async Task OidcConfigUpdate_StillWorks_WhenExclusiveModeActive()
|
||||
{
|
||||
var response = await _client.PutAsJsonAsync("/api/account/oidc", new
|
||||
@@ -248,7 +260,7 @@ public class AccountControllerOidcTests : IClassFixture<AccountControllerOidcTes
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(15)]
|
||||
[Fact, TestPriority(16)]
|
||||
public async Task OidcUnlink_ResetsExclusiveMode()
|
||||
{
|
||||
var response = await _client.DeleteAsync("/api/account/oidc/link");
|
||||
@@ -260,7 +272,7 @@ public class AccountControllerOidcTests : IClassFixture<AccountControllerOidcTes
|
||||
exclusiveMode.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact, TestPriority(16)]
|
||||
[Fact, TestPriority(17)]
|
||||
public async Task DisableExclusiveMode_PasswordChangeWorks_Again()
|
||||
{
|
||||
// Re-enable OIDC with a linked subject but without exclusive mode
|
||||
@@ -276,6 +288,26 @@ public class AccountControllerOidcTests : IClassFixture<AccountControllerOidcTes
|
||||
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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -268,55 +268,7 @@ public class AuthControllerTests : IClassFixture<CustomWebApplicationFactory>
|
||||
body.TryGetProperty("oidcProviderName", out _).ShouldBeTrue();
|
||||
}
|
||||
|
||||
#region TOTP helpers
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -67,9 +67,11 @@ public class OidcAuthControllerTests : IClassFixture<OidcAuthControllerTests.Oid
|
||||
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("error").GetString()!.ShouldContain("OIDC is not enabled");
|
||||
body.GetProperty("detail").GetString()!.ShouldContain("OIDC is not enabled");
|
||||
body.GetProperty("traceId").GetString().ShouldNotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact, TestPriority(3)]
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+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");
|
||||
}
|
||||
}
|
||||
+214
-76
@@ -1,10 +1,14 @@
|
||||
using System.Text.Json;
|
||||
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;
|
||||
@@ -22,6 +26,7 @@ public class SeedingRulesControllerTests : IDisposable
|
||||
_dataContext = SeedingRulesTestDataFactory.CreateDataContext();
|
||||
var logger = Substitute.For<ILogger<SeedingRulesController>>();
|
||||
_controller = new SeedingRulesController(logger, _dataContext);
|
||||
ControllerTestContext.Attach(_controller);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
@@ -40,7 +45,10 @@ public class SeedingRulesControllerTests : IDisposable
|
||||
double maxRatio = 2.0,
|
||||
double minSeedTime = 0,
|
||||
double maxSeedTime = -1,
|
||||
bool deleteSourceFiles = true)
|
||||
int minSeeders = 0,
|
||||
double maxInactiveDays = -1,
|
||||
bool deleteSourceFiles = true,
|
||||
SeedingRuleAction action = SeedingRuleAction.Delete)
|
||||
{
|
||||
return new SeedingRuleRequest
|
||||
{
|
||||
@@ -54,22 +62,24 @@ public class SeedingRulesControllerTests : IDisposable
|
||||
MaxRatio = maxRatio,
|
||||
MinSeedTime = minSeedTime,
|
||||
MaxSeedTime = maxSeedTime,
|
||||
MinSeeders = minSeeders,
|
||||
MaxInactiveDays = maxInactiveDays,
|
||||
DeleteSourceFiles = deleteSourceFiles,
|
||||
Action = action,
|
||||
};
|
||||
}
|
||||
|
||||
private static JsonElement GetJsonBody(IActionResult result)
|
||||
private static List<SeedingRuleResponse> GetRulesFromOk(IActionResult result)
|
||||
{
|
||||
var okResult = result.ShouldBeOfType<OkObjectResult>();
|
||||
var json = JsonSerializer.Serialize(okResult.Value);
|
||||
return JsonDocument.Parse(json).RootElement;
|
||||
IEnumerable<SeedingRuleResponse> rules = okResult.Value.ShouldBeAssignableTo<IEnumerable<SeedingRuleResponse>>()!;
|
||||
return rules.ToList();
|
||||
}
|
||||
|
||||
private static JsonElement GetCreatedJsonBody(IActionResult result)
|
||||
private static T GetCreatedRule<T>(IActionResult result) where T : ISeedingRule
|
||||
{
|
||||
var createdResult = result.ShouldBeOfType<CreatedAtActionResult>();
|
||||
var json = JsonSerializer.Serialize(createdResult.Value);
|
||||
return JsonDocument.Parse(json).RootElement;
|
||||
return createdResult.Value.ShouldBeOfType<T>();
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
@@ -83,10 +93,7 @@ public class SeedingRulesControllerTests : IDisposable
|
||||
|
||||
var result = await _controller.GetSeedingRules(client.Id);
|
||||
|
||||
var okResult = result.ShouldBeOfType<OkObjectResult>();
|
||||
var json = JsonSerializer.Serialize(okResult.Value);
|
||||
var array = JsonDocument.Parse(json).RootElement;
|
||||
array.GetArrayLength().ShouldBe(0);
|
||||
GetRulesFromOk(result).ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -99,20 +106,18 @@ public class SeedingRulesControllerTests : IDisposable
|
||||
|
||||
var result = await _controller.GetSeedingRules(client.Id);
|
||||
|
||||
var okResult = result.ShouldBeOfType<OkObjectResult>();
|
||||
var json = JsonSerializer.Serialize(okResult.Value);
|
||||
var array = JsonDocument.Parse(json).RootElement;
|
||||
array.GetArrayLength().ShouldBe(3);
|
||||
array[0].GetProperty("name").GetString().ShouldBe("Rule A");
|
||||
array[1].GetProperty("name").GetString().ShouldBe("Rule B");
|
||||
array[2].GetProperty("name").GetString().ShouldBe("Rule C");
|
||||
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<NotFoundObjectResult>();
|
||||
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(StatusCodes.Status404NotFound);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -124,12 +129,21 @@ public class SeedingRulesControllerTests : IDisposable
|
||||
|
||||
var result = await _controller.GetSeedingRules(client.Id);
|
||||
|
||||
var okResult = result.ShouldBeOfType<OkObjectResult>();
|
||||
var json = JsonSerializer.Serialize(okResult.Value);
|
||||
var rule = JsonDocument.Parse(json).RootElement[0];
|
||||
rule.GetProperty("tagsAny").GetArrayLength().ShouldBe(2);
|
||||
rule.GetProperty("tagsAll").GetArrayLength().ShouldBe(1);
|
||||
rule.GetProperty("tagsAll")[0].GetString().ShouldBe("required");
|
||||
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]
|
||||
@@ -140,11 +154,9 @@ public class SeedingRulesControllerTests : IDisposable
|
||||
|
||||
var result = await _controller.GetSeedingRules(client.Id);
|
||||
|
||||
var okResult = result.ShouldBeOfType<OkObjectResult>();
|
||||
var json = JsonSerializer.Serialize(okResult.Value);
|
||||
var rule = JsonDocument.Parse(json).RootElement[0];
|
||||
rule.GetProperty("tagsAny").GetArrayLength().ShouldBe(0);
|
||||
rule.GetProperty("tagsAll").GetArrayLength().ShouldBe(0);
|
||||
SeedingRuleResponse rule = GetRulesFromOk(result).Single();
|
||||
rule.TagsAny.ShouldBeEmpty();
|
||||
rule.TagsAll.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
@@ -162,9 +174,9 @@ public class SeedingRulesControllerTests : IDisposable
|
||||
var createdResult = result.ShouldBeOfType<CreatedAtActionResult>();
|
||||
createdResult.StatusCode.ShouldBe(201);
|
||||
|
||||
var body = GetCreatedJsonBody(result);
|
||||
body.GetProperty("Name").GetString().ShouldBe("Movies Rule");
|
||||
body.GetProperty("Categories").GetArrayLength().ShouldBe(2);
|
||||
QBitSeedingRule rule = GetCreatedRule<QBitSeedingRule>(result);
|
||||
rule.Name.ShouldBe("Movies Rule");
|
||||
rule.Categories.ShouldBe(new List<string> { "movies", "films" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -175,8 +187,29 @@ public class SeedingRulesControllerTests : IDisposable
|
||||
|
||||
var result = await _controller.CreateSeedingRule(client.Id, request);
|
||||
|
||||
var body = GetCreatedJsonBody(result);
|
||||
body.GetProperty("Priority").GetInt32().ShouldBe(1);
|
||||
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]
|
||||
@@ -189,8 +222,7 @@ public class SeedingRulesControllerTests : IDisposable
|
||||
|
||||
var result = await _controller.CreateSeedingRule(client.Id, request);
|
||||
|
||||
var body = GetCreatedJsonBody(result);
|
||||
body.GetProperty("Priority").GetInt32().ShouldBe(2);
|
||||
GetCreatedRule<QBitSeedingRule>(result).Priority.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -202,7 +234,7 @@ public class SeedingRulesControllerTests : IDisposable
|
||||
var request = CreateValidRequest(priority: 1);
|
||||
|
||||
var result = await _controller.CreateSeedingRule(client.Id, request);
|
||||
result.ShouldBeOfType<BadRequestObjectResult>();
|
||||
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(StatusCodes.Status400BadRequest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -211,7 +243,7 @@ public class SeedingRulesControllerTests : IDisposable
|
||||
var request = CreateValidRequest();
|
||||
|
||||
var result = await _controller.CreateSeedingRule(Guid.NewGuid(), request);
|
||||
result.ShouldBeOfType<NotFoundObjectResult>();
|
||||
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(StatusCodes.Status404NotFound);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -220,10 +252,7 @@ public class SeedingRulesControllerTests : IDisposable
|
||||
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
|
||||
var request = CreateValidRequest(categories: []);
|
||||
|
||||
var result = await _controller.CreateSeedingRule(client.Id, request);
|
||||
|
||||
// Validate() throws ValidationException → caught → BadRequest
|
||||
result.ShouldBeOfType<BadRequestObjectResult>();
|
||||
await Should.ThrowAsync<ValidationException>(() => _controller.CreateSeedingRule(client.Id, request));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -235,11 +264,8 @@ public class SeedingRulesControllerTests : IDisposable
|
||||
|
||||
var result = await _controller.CreateSeedingRule(client.Id, request);
|
||||
|
||||
var body = GetCreatedJsonBody(result);
|
||||
var patterns = body.GetProperty("TrackerPatterns");
|
||||
patterns.GetArrayLength().ShouldBe(2);
|
||||
patterns[0].GetString().ShouldBe("valid.com");
|
||||
patterns[1].GetString().ShouldBe("trimmed.com");
|
||||
QBitSeedingRule rule = GetCreatedRule<QBitSeedingRule>(result);
|
||||
rule.TrackerPatterns.ShouldBe(new List<string> { "valid.com", "trimmed.com" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -251,8 +277,59 @@ public class SeedingRulesControllerTests : IDisposable
|
||||
|
||||
var result = await _controller.CreateSeedingRule(client.Id, request);
|
||||
|
||||
var createdResult = result.ShouldBeOfType<CreatedAtActionResult>();
|
||||
createdResult.Value.ShouldBeOfType<TransmissionSeedingRule>();
|
||||
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));
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
@@ -306,13 +383,43 @@ public class SeedingRulesControllerTests : IDisposable
|
||||
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<NotFoundObjectResult>();
|
||||
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(StatusCodes.Status404NotFound);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -324,8 +431,48 @@ public class SeedingRulesControllerTests : IDisposable
|
||||
// Both maxRatio and maxSeedTime negative → validation failure
|
||||
var request = CreateValidRequest(maxRatio: -1, maxSeedTime: -1);
|
||||
|
||||
var result = await _controller.UpdateSeedingRule(rule.Id, request);
|
||||
result.ShouldBeOfType<BadRequestObjectResult>();
|
||||
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));
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
@@ -357,18 +504,14 @@ public class SeedingRulesControllerTests : IDisposable
|
||||
var request = new ReorderSeedingRulesRequest { OrderedIds = [rule3.Id, rule2.Id, rule1.Id] };
|
||||
await _controller.ReorderSeedingRules(client.Id, request);
|
||||
|
||||
// Verify via GET
|
||||
var getResult = await _controller.GetSeedingRules(client.Id);
|
||||
var okResult = getResult.ShouldBeOfType<OkObjectResult>();
|
||||
var json = JsonSerializer.Serialize(okResult.Value);
|
||||
var array = JsonDocument.Parse(json).RootElement;
|
||||
List<SeedingRuleResponse> rules = GetRulesFromOk(await _controller.GetSeedingRules(client.Id));
|
||||
|
||||
array[0].GetProperty("name").GetString().ShouldBe("C");
|
||||
array[0].GetProperty("priority").GetInt32().ShouldBe(1);
|
||||
array[1].GetProperty("name").GetString().ShouldBe("B");
|
||||
array[1].GetProperty("priority").GetInt32().ShouldBe(2);
|
||||
array[2].GetProperty("name").GetString().ShouldBe("A");
|
||||
array[2].GetProperty("priority").GetInt32().ShouldBe(3);
|
||||
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]
|
||||
@@ -377,7 +520,7 @@ public class SeedingRulesControllerTests : IDisposable
|
||||
var request = new ReorderSeedingRulesRequest { OrderedIds = [Guid.NewGuid()] };
|
||||
|
||||
var result = await _controller.ReorderSeedingRules(Guid.NewGuid(), request);
|
||||
result.ShouldBeOfType<NotFoundObjectResult>();
|
||||
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(StatusCodes.Status404NotFound);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -390,7 +533,7 @@ public class SeedingRulesControllerTests : IDisposable
|
||||
var request = new ReorderSeedingRulesRequest { OrderedIds = [rule1.Id, rule1.Id] };
|
||||
|
||||
var result = await _controller.ReorderSeedingRules(client.Id, request);
|
||||
result.ShouldBeOfType<BadRequestObjectResult>();
|
||||
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(StatusCodes.Status400BadRequest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -404,7 +547,7 @@ public class SeedingRulesControllerTests : IDisposable
|
||||
var request = new ReorderSeedingRulesRequest { OrderedIds = [rule1.Id] };
|
||||
|
||||
var result = await _controller.ReorderSeedingRules(client.Id, request);
|
||||
result.ShouldBeOfType<BadRequestObjectResult>();
|
||||
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(StatusCodes.Status400BadRequest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -417,7 +560,7 @@ public class SeedingRulesControllerTests : IDisposable
|
||||
var request = new ReorderSeedingRulesRequest { OrderedIds = [rule1.Id, Guid.NewGuid()] };
|
||||
|
||||
var result = await _controller.ReorderSeedingRules(client.Id, request);
|
||||
result.ShouldBeOfType<BadRequestObjectResult>();
|
||||
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(StatusCodes.Status400BadRequest);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
@@ -442,18 +585,13 @@ public class SeedingRulesControllerTests : IDisposable
|
||||
|
||||
await _controller.DeleteSeedingRule(rule.Id);
|
||||
|
||||
// Verify rule no longer exists
|
||||
var getResult = await _controller.GetSeedingRules(client.Id);
|
||||
var okResult = getResult.ShouldBeOfType<OkObjectResult>();
|
||||
var json = JsonSerializer.Serialize(okResult.Value);
|
||||
var array = JsonDocument.Parse(json).RootElement;
|
||||
array.GetArrayLength().ShouldBe(0);
|
||||
GetRulesFromOk(await _controller.GetSeedingRules(client.Id)).ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteSeedingRule_NonExistentRule_ReturnsNotFound()
|
||||
{
|
||||
var result = await _controller.DeleteSeedingRule(Guid.NewGuid());
|
||||
result.ShouldBeOfType<NotFoundObjectResult>();
|
||||
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(StatusCodes.Status404NotFound);
|
||||
}
|
||||
}
|
||||
+13
-5
@@ -48,7 +48,8 @@ public static class SeedingRulesTestDataFactory
|
||||
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.Whisparr, Instances = [], FailedImportMaxStrikes = 3 },
|
||||
new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Sportarr, Instances = [], FailedImportMaxStrikes = 3 }
|
||||
);
|
||||
|
||||
context.QueueCleanerConfigs.Add(new QueueCleanerConfig
|
||||
@@ -67,7 +68,8 @@ public static class SeedingRulesTestDataFactory
|
||||
Radarr = new BlocklistSettings { Enabled = false },
|
||||
Lidarr = new BlocklistSettings { Enabled = false },
|
||||
Readarr = new BlocklistSettings { Enabled = false },
|
||||
Whisparr = new BlocklistSettings { Enabled = false }
|
||||
Whisparr = new BlocklistSettings { Enabled = false },
|
||||
Sportarr = new BlocklistSettings { Enabled = false }
|
||||
});
|
||||
|
||||
context.DownloadCleanerConfigs.Add(new DownloadCleanerConfig
|
||||
@@ -119,7 +121,8 @@ public static class SeedingRulesTestDataFactory
|
||||
List<string>? tagsAll = null,
|
||||
double maxRatio = 2.0,
|
||||
double minSeedTime = 0,
|
||||
double maxSeedTime = -1)
|
||||
double maxSeedTime = -1,
|
||||
int minSeeders = 0)
|
||||
{
|
||||
var rule = new QBitSeedingRule
|
||||
{
|
||||
@@ -135,6 +138,7 @@ public static class SeedingRulesTestDataFactory
|
||||
MaxRatio = maxRatio,
|
||||
MinSeedTime = minSeedTime,
|
||||
MaxSeedTime = maxSeedTime,
|
||||
MinSeeders = minSeeders,
|
||||
DeleteSourceFiles = true,
|
||||
};
|
||||
|
||||
@@ -150,7 +154,8 @@ public static class SeedingRulesTestDataFactory
|
||||
int priority = 1,
|
||||
List<string>? categories = null,
|
||||
double maxRatio = 2.0,
|
||||
double maxSeedTime = -1)
|
||||
double maxSeedTime = -1,
|
||||
int minSeeders = 0)
|
||||
{
|
||||
var rule = new DelugeSeedingRule
|
||||
{
|
||||
@@ -164,6 +169,7 @@ public static class SeedingRulesTestDataFactory
|
||||
MaxRatio = maxRatio,
|
||||
MinSeedTime = 0,
|
||||
MaxSeedTime = maxSeedTime,
|
||||
MinSeeders = minSeeders,
|
||||
DeleteSourceFiles = true,
|
||||
};
|
||||
|
||||
@@ -179,7 +185,8 @@ public static class SeedingRulesTestDataFactory
|
||||
int priority = 1,
|
||||
List<string>? categories = null,
|
||||
double maxRatio = 2.0,
|
||||
double maxSeedTime = -1)
|
||||
double maxSeedTime = -1,
|
||||
int minSeeders = 0)
|
||||
{
|
||||
var rule = new TransmissionSeedingRule
|
||||
{
|
||||
@@ -195,6 +202,7 @@ public static class SeedingRulesTestDataFactory
|
||||
MaxRatio = maxRatio,
|
||||
MinSeedTime = 0,
|
||||
MaxSeedTime = maxSeedTime,
|
||||
MinSeeders = minSeeders,
|
||||
DeleteSourceFiles = true,
|
||||
};
|
||||
|
||||
|
||||
+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",
|
||||
};
|
||||
}
|
||||
+22
-5
@@ -5,6 +5,7 @@ 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;
|
||||
|
||||
@@ -13,17 +14,20 @@ 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();
|
||||
_controller = new CustomFormatScoreController(_dataContext);
|
||||
_eventsContext = SeekerTestDataFactory.CreateEventsContext();
|
||||
_controller = new CustomFormatScoreController(_dataContext, _eventsContext, new SqliteDatabaseProvider());
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_dataContext.Dispose();
|
||||
_eventsContext.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
@@ -50,6 +54,19 @@ public class CustomFormatScoreControllerTests : IDisposable
|
||||
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()
|
||||
{
|
||||
@@ -495,7 +512,7 @@ public class CustomFormatScoreControllerTests : IDisposable
|
||||
bool isMonitored = true,
|
||||
string qualityProfileName = "HD")
|
||||
{
|
||||
_dataContext.CustomFormatScoreEntries.Add(new CustomFormatScoreEntry
|
||||
_eventsContext.CustomFormatScoreEntries.Add(new CustomFormatScoreEntry
|
||||
{
|
||||
ArrInstanceId = arrInstanceId,
|
||||
ExternalItemId = externalItemId,
|
||||
@@ -509,7 +526,7 @@ public class CustomFormatScoreControllerTests : IDisposable
|
||||
IsMonitored = isMonitored,
|
||||
LastSyncedAt = lastSynced ?? DateTime.UtcNow
|
||||
});
|
||||
_dataContext.SaveChanges();
|
||||
_eventsContext.SaveChanges();
|
||||
}
|
||||
|
||||
private void AddHistoryEntry(
|
||||
@@ -521,7 +538,7 @@ public class CustomFormatScoreControllerTests : IDisposable
|
||||
int cutoffScore = 500,
|
||||
InstanceType itemType = InstanceType.Radarr)
|
||||
{
|
||||
_dataContext.CustomFormatScoreHistory.Add(new CustomFormatScoreHistory
|
||||
_eventsContext.CustomFormatScoreHistory.Add(new CustomFormatScoreHistory
|
||||
{
|
||||
ArrInstanceId = arrInstanceId,
|
||||
ExternalItemId = externalItemId,
|
||||
@@ -532,7 +549,7 @@ public class CustomFormatScoreControllerTests : IDisposable
|
||||
CutoffScore = cutoffScore,
|
||||
RecordedAt = recordedAt
|
||||
});
|
||||
_dataContext.SaveChanges();
|
||||
_eventsContext.SaveChanges();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
@@ -279,21 +279,16 @@ public class SearchStatsControllerTests : IDisposable
|
||||
Timestamp = timestamp ?? DateTime.UtcNow
|
||||
};
|
||||
|
||||
_eventsContext.Events.Add(appEvent);
|
||||
_eventsContext.SaveChanges();
|
||||
|
||||
if (itemTitle is not null)
|
||||
{
|
||||
_eventsContext.SearchEventData.Add(new SearchEventData
|
||||
{
|
||||
AppEventId = appEvent.Id,
|
||||
ItemTitle = itemTitle,
|
||||
SearchType = searchType,
|
||||
SearchReason = searchReason,
|
||||
GrabbedItems = grabbedItems ?? [],
|
||||
});
|
||||
_eventsContext.SaveChanges();
|
||||
appEvent.ItemTitle = itemTitle;
|
||||
appEvent.SearchType = searchType;
|
||||
appEvent.SearchReason = searchReason;
|
||||
appEvent.GrabbedItems = grabbedItems ?? [];
|
||||
}
|
||||
|
||||
_eventsContext.Events.Add(appEvent);
|
||||
_eventsContext.SaveChanges();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
@@ -52,9 +52,29 @@ public class SeekerConfigControllerTests : IDisposable
|
||||
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()
|
||||
{
|
||||
@@ -138,11 +158,11 @@ public class SeekerConfigControllerTests : IDisposable
|
||||
});
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
// Keep interval at default (3)
|
||||
// Keep interval at default (10)
|
||||
var request = new UpdateSeekerConfigRequest
|
||||
{
|
||||
SearchEnabled = true,
|
||||
SearchInterval = 3,
|
||||
SearchInterval = 10,
|
||||
ProactiveSearchEnabled = true,
|
||||
Instances =
|
||||
[
|
||||
@@ -354,6 +374,7 @@ public class SeekerConfigControllerTests : IDisposable
|
||||
Enabled = true,
|
||||
SkipTags = ["new-tag"],
|
||||
ActiveDownloadLimit = 5,
|
||||
IgnoreStruckDownloads = true,
|
||||
MinCycleTimeDays = 14
|
||||
},
|
||||
// Create new sonarr config
|
||||
@@ -377,11 +398,13 @@ public class SeekerConfigControllerTests : IDisposable
|
||||
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
|
||||
|
||||
+4
-2
@@ -66,7 +66,8 @@ public static class SeekerTestDataFactory
|
||||
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.Whisparr, Instances = [], FailedImportMaxStrikes = 3 },
|
||||
new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Sportarr, Instances = [], FailedImportMaxStrikes = 3 }
|
||||
);
|
||||
|
||||
context.QueueCleanerConfigs.Add(new QueueCleanerConfig
|
||||
@@ -85,7 +86,8 @@ public static class SeekerTestDataFactory
|
||||
Radarr = new BlocklistSettings { Enabled = false },
|
||||
Lidarr = new BlocklistSettings { Enabled = false },
|
||||
Readarr = new BlocklistSettings { Enabled = false },
|
||||
Whisparr = new BlocklistSettings { Enabled = false }
|
||||
Whisparr = new BlocklistSettings { Enabled = false },
|
||||
Sportarr = new BlocklistSettings { Enabled = false }
|
||||
});
|
||||
|
||||
context.DownloadCleanerConfigs.Add(new DownloadCleanerConfig
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
}
|
||||
@@ -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,7 +1,11 @@
|
||||
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;
|
||||
@@ -14,23 +18,25 @@ namespace Cleanuparr.Api.Controllers;
|
||||
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 = 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)
|
||||
{
|
||||
@@ -49,35 +55,40 @@ public class EventsController : ControllerBase
|
||||
{
|
||||
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);
|
||||
}
|
||||
@@ -87,29 +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.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,3 +1,4 @@
|
||||
using Cleanuparr.Api.Extensions;
|
||||
using Cleanuparr.Infrastructure.Health;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -12,17 +13,13 @@ namespace Cleanuparr.Api.Controllers;
|
||||
[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;
|
||||
}
|
||||
|
||||
@@ -32,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>
|
||||
@@ -50,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>
|
||||
@@ -73,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>
|
||||
@@ -91,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);
|
||||
}
|
||||
}
|
||||
@@ -36,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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,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(),
|
||||
@@ -79,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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,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,
|
||||
@@ -122,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,3 +1,4 @@
|
||||
using Cleanuparr.Api.Extensions;
|
||||
using Cleanuparr.Api.Models;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Models;
|
||||
@@ -13,47 +14,29 @@ namespace Cleanuparr.Api.Controllers;
|
||||
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")]
|
||||
@@ -61,27 +44,21 @@ public class JobsController : ControllerBase
|
||||
{
|
||||
if (jobType == JobType.Seeker)
|
||||
{
|
||||
return BadRequest("The Seeker job cannot be manually controlled");
|
||||
return this.ProblemResult(StatusCodes.Status400BadRequest, "The Seeker job cannot be manually controlled");
|
||||
}
|
||||
|
||||
try
|
||||
if (scheduleRequest?.Schedule is null)
|
||||
{
|
||||
// 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, "Schedule is required");
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
var result = await _jobManagementService.StartJob(jobType, scheduleRequest.Schedule);
|
||||
|
||||
if (!result)
|
||||
{
|
||||
_logger.LogError(ex, "Error starting job {jobType}", jobType);
|
||||
return StatusCode(500, $"An error occurred while starting job '{jobType}'");
|
||||
return this.ProblemResult(StatusCodes.Status400BadRequest, $"Failed to start job '{jobType}'");
|
||||
}
|
||||
return Ok(new { Message = $"Job '{jobType}' started successfully" });
|
||||
}
|
||||
|
||||
[HttpPost("{jobType}/trigger")]
|
||||
@@ -89,24 +66,16 @@ public class JobsController : ControllerBase
|
||||
{
|
||||
if (jobType == JobType.Seeker)
|
||||
{
|
||||
return BadRequest("The Seeker job cannot be manually triggered");
|
||||
return this.ProblemResult(StatusCodes.Status400BadRequest, "The Seeker job cannot be manually triggered");
|
||||
}
|
||||
|
||||
try
|
||||
var result = await _jobManagementService.TriggerJobOnce(jobType);
|
||||
|
||||
if (!result)
|
||||
{
|
||||
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" });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_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")]
|
||||
@@ -114,28 +83,20 @@ public class JobsController : ControllerBase
|
||||
{
|
||||
if (jobType == JobType.Seeker)
|
||||
{
|
||||
return BadRequest("The Seeker job schedule cannot be manually modified");
|
||||
return this.ProblemResult(StatusCodes.Status400BadRequest, "The Seeker job schedule cannot be manually modified");
|
||||
}
|
||||
|
||||
if (scheduleRequest?.Schedule == null)
|
||||
{
|
||||
return BadRequest("Schedule is required");
|
||||
return this.ProblemResult(StatusCodes.Status400BadRequest, "Schedule is required");
|
||||
}
|
||||
|
||||
try
|
||||
var result = await _jobManagementService.UpdateJobSchedule(jobType, scheduleRequest.Schedule);
|
||||
|
||||
if (!result)
|
||||
{
|
||||
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" });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_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,3 +1,4 @@
|
||||
using Cleanuparr.Api.Contracts.Responses;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Events;
|
||||
@@ -28,8 +29,8 @@ public class ManualEventsController : ControllerBase
|
||||
[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
|
||||
@@ -58,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);
|
||||
}
|
||||
|
||||
@@ -78,8 +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.Message.ToLower(), pattern, "\\") ||
|
||||
(e.ItemTitle != null && EF.Functions.Like(e.ItemTitle.ToLower(), pattern, "\\"))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -136,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>
|
||||
@@ -172,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,53 +0,0 @@
|
||||
using Cleanuparr.Infrastructure.Stats;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Cleanuparr.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Aggregated statistics endpoint for dashboard integrations
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
[Authorize]
|
||||
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,10 @@
|
||||
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;
|
||||
@@ -15,262 +18,129 @@ 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,3 +1,5 @@
|
||||
using Cleanuparr.Api.Contracts.Responses;
|
||||
using Cleanuparr.Api.Features.Strikes.Contracts.Responses;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.State;
|
||||
@@ -51,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));
|
||||
}
|
||||
|
||||
@@ -60,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();
|
||||
@@ -146,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>
|
||||
@@ -170,39 +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 bool HasDryRunStrikes { 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 bool IsDryRun { 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;
|
||||
public bool IsDryRun { get; set; }
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Text.Json.Serialization.Metadata;
|
||||
using System.Diagnostics;
|
||||
using Cleanuparr.Api.Filters;
|
||||
using Cleanuparr.Api.Json;
|
||||
using Cleanuparr.Infrastructure.Health;
|
||||
@@ -7,6 +6,7 @@ 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;
|
||||
@@ -15,14 +15,7 @@ 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;
|
||||
options.SerializerOptions.TypeInfoResolver = new SensitiveDataResolver(
|
||||
options.SerializerOptions.TypeInfoResolver ?? new DefaultJsonTypeInfoResolver());
|
||||
});
|
||||
services.Configure<JsonOptions>(options => CleanuparrJsonConfiguration.ConfigureApiInbound(options.SerializerOptions));
|
||||
|
||||
// Make JsonSerializerOptions available for injection
|
||||
services.AddSingleton(sp =>
|
||||
@@ -31,48 +24,53 @@ public static class ApiDI
|
||||
// Add API-specific services
|
||||
services
|
||||
.AddControllers()
|
||||
.AddJsonOptions(options =>
|
||||
{
|
||||
options.JsonSerializerOptions.PropertyNameCaseInsensitive = true;
|
||||
options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());
|
||||
options.JsonSerializerOptions.ReferenceHandler = ReferenceHandler.IgnoreCycles;
|
||||
options.JsonSerializerOptions.TypeInfoResolver = new SensitiveDataResolver(
|
||||
options.JsonSerializerOptions.TypeInfoResolver ?? new DefaultJsonTypeInfoResolver());
|
||||
});
|
||||
.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());
|
||||
options.PayloadSerializerOptions.TypeInfoResolver = new SensitiveDataResolver(
|
||||
options.PayloadSerializerOptions.TypeInfoResolver ?? new DefaultJsonTypeInfoResolver());
|
||||
});
|
||||
.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 without caching
|
||||
app.UseStaticFiles(new StaticFileOptions
|
||||
{
|
||||
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>();
|
||||
@@ -93,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("/"))
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using Cleanuparr.Api.Json;
|
||||
using Cleanuparr.Domain.Entities.Arr;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadRemover.Consumers;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Consumers;
|
||||
@@ -6,6 +6,7 @@ using Cleanuparr.Infrastructure.Features.Notifications.Models;
|
||||
using Cleanuparr.Infrastructure.Health;
|
||||
using Cleanuparr.Infrastructure.Http;
|
||||
using Cleanuparr.Infrastructure.Http.DynamicHttpClientSystem;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
using MassTransit;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
|
||||
@@ -26,31 +27,28 @@ public static class MainDI
|
||||
{
|
||||
config.DisableUsageTelemetry();
|
||||
|
||||
config.AddConsumer<DownloadRemoverConsumer<SearchItem>>();
|
||||
config.AddConsumer<DownloadRemoverConsumer<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.ConfigureConsumer<DownloadRemoverConsumer>(context);
|
||||
e.ConcurrentMessageLimit = 1;
|
||||
e.PrefetchCount = 1;
|
||||
});
|
||||
@@ -63,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;
|
||||
@@ -78,12 +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");
|
||||
|
||||
// Add HTTP client for OIDC authentication
|
||||
services.AddHttpClient("OidcAuth");
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
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.DownloadRemover;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadRemover.Interfaces;
|
||||
@@ -17,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;
|
||||
|
||||
@@ -27,9 +32,11 @@ 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>()
|
||||
@@ -37,16 +44,25 @@ public static class ServicesDI
|
||||
.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>()
|
||||
@@ -57,6 +73,7 @@ public static class ServicesDI
|
||||
.AddScoped<IArrQueueIterator, ArrQueueIterator>()
|
||||
.AddScoped<IDownloadServiceFactory, DownloadServiceFactory>()
|
||||
.AddScoped<IStriker, Striker>()
|
||||
.AddScoped<IConnectivityChecker, ConnectivityChecker>()
|
||||
.AddScoped<FileReader>()
|
||||
.AddScoped<IQueueRuleManager, QueueRuleManager>()
|
||||
.AddScoped<IQueueRuleEvaluator, QueueRuleEvaluator>()
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Cleanuparr.Api.Extensions;
|
||||
|
||||
public static class ControllerBaseExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds an RFC 9457 problem-details error response for a direct (non-throwing) controller return.
|
||||
/// Mirrors the shape produced by <see cref="Middleware.GlobalExceptionHandler"/> so every error
|
||||
/// response carries the same <c>application/problem+json</c> body and <c>traceId</c>. The
|
||||
/// <c>traceId</c> extension is added by the shared <c>CustomizeProblemDetails</c> hook inside
|
||||
/// <see cref="ProblemDetailsFactory.CreateProblemDetails"/>.
|
||||
/// </summary>
|
||||
public static ObjectResult ProblemResult(
|
||||
this ControllerBase controller,
|
||||
int statusCode,
|
||||
string detail,
|
||||
string? title = null,
|
||||
IReadOnlyDictionary<string, object?>? extensions = null)
|
||||
{
|
||||
ProblemDetails problemDetails = controller.ProblemDetailsFactory
|
||||
.CreateProblemDetails(controller.HttpContext, statusCode: statusCode, title: title, detail: detail);
|
||||
|
||||
if (extensions is not null)
|
||||
{
|
||||
foreach (KeyValuePair<string, object?> extension in extensions)
|
||||
{
|
||||
problemDetails.Extensions[extension.Key] = extension.Value;
|
||||
}
|
||||
}
|
||||
|
||||
return new ObjectResult(problemDetails)
|
||||
{
|
||||
StatusCode = statusCode,
|
||||
ContentTypes = { "application/problem+json" },
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,16 @@
|
||||
using Cleanuparr.Api.Extensions;
|
||||
using Cleanuparr.Api.Features.Arr.Contracts.Requests;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Events.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.Arr.Dtos;
|
||||
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Health;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
using Mapster;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Storage;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Arr.Controllers;
|
||||
|
||||
@@ -18,16 +21,25 @@ public sealed class ArrConfigController : ControllerBase
|
||||
{
|
||||
private readonly ILogger<ArrConfigController> _logger;
|
||||
private readonly DataContext _dataContext;
|
||||
private readonly IArrClientFactory _arrClientFactory;
|
||||
private readonly EventsContext _eventsContext;
|
||||
private readonly IInstanceHealthChecker _healthChecker;
|
||||
private readonly IEventPublisher _eventPublisher;
|
||||
|
||||
/// <summary>
|
||||
/// Creates the controller with its injected dependencies.
|
||||
/// </summary>
|
||||
public ArrConfigController(
|
||||
ILogger<ArrConfigController> logger,
|
||||
DataContext dataContext,
|
||||
IArrClientFactory arrClientFactory)
|
||||
EventsContext eventsContext,
|
||||
IInstanceHealthChecker healthChecker,
|
||||
IEventPublisher eventPublisher)
|
||||
{
|
||||
_logger = logger;
|
||||
_dataContext = dataContext;
|
||||
_arrClientFactory = arrClientFactory;
|
||||
_eventsContext = eventsContext;
|
||||
_healthChecker = healthChecker;
|
||||
_eventPublisher = eventPublisher;
|
||||
}
|
||||
|
||||
[HttpGet("sonarr")]
|
||||
@@ -45,6 +57,11 @@ public sealed class ArrConfigController : ControllerBase
|
||||
[HttpGet("whisparr")]
|
||||
public Task<IActionResult> GetWhisparrConfig() => GetArrConfig(InstanceType.Whisparr);
|
||||
|
||||
[HttpGet("sportarr")]
|
||||
public Task<IActionResult> GetSportarrConfig() => GetArrConfig(InstanceType.Sportarr);
|
||||
[HttpGet("lazylibrarian")]
|
||||
public Task<IActionResult> GetLazyLibrarianConfig() => GetArrConfig(InstanceType.LazyLibrarian);
|
||||
|
||||
[HttpPut("sonarr")]
|
||||
public Task<IActionResult> UpdateSonarrConfig([FromBody] UpdateArrConfigRequest request)
|
||||
=> UpdateArrConfig(InstanceType.Sonarr, request);
|
||||
@@ -65,6 +82,13 @@ public sealed class ArrConfigController : ControllerBase
|
||||
public Task<IActionResult> UpdateWhisparrConfig([FromBody] UpdateArrConfigRequest request)
|
||||
=> UpdateArrConfig(InstanceType.Whisparr, request);
|
||||
|
||||
[HttpPut("sportarr")]
|
||||
public Task<IActionResult> UpdateSportarrConfig([FromBody] UpdateArrConfigRequest request)
|
||||
=> UpdateArrConfig(InstanceType.Sportarr, request);
|
||||
[HttpPut("lazylibrarian")]
|
||||
public Task<IActionResult> UpdateLazyLibrarianConfig([FromBody] UpdateArrConfigRequest request)
|
||||
=> UpdateArrConfig(InstanceType.LazyLibrarian, request);
|
||||
|
||||
[HttpPost("sonarr/instances")]
|
||||
public Task<IActionResult> CreateSonarrInstance([FromBody] ArrInstanceRequest request)
|
||||
=> CreateArrInstance(InstanceType.Sonarr, request);
|
||||
@@ -125,6 +149,29 @@ public sealed class ArrConfigController : ControllerBase
|
||||
public Task<IActionResult> DeleteWhisparrInstance(Guid id)
|
||||
=> DeleteArrInstance(InstanceType.Whisparr, id);
|
||||
|
||||
[HttpPost("sportarr/instances")]
|
||||
public Task<IActionResult> CreateSportarrInstance([FromBody] ArrInstanceRequest request)
|
||||
=> CreateArrInstance(InstanceType.Sportarr, request);
|
||||
|
||||
[HttpPut("sportarr/instances/{id}")]
|
||||
public Task<IActionResult> UpdateSportarrInstance(Guid id, [FromBody] ArrInstanceRequest request)
|
||||
=> UpdateArrInstance(InstanceType.Sportarr, id, request);
|
||||
|
||||
[HttpDelete("sportarr/instances/{id}")]
|
||||
public Task<IActionResult> DeleteSportarrInstance(Guid id)
|
||||
=> DeleteArrInstance(InstanceType.Sportarr, id);
|
||||
[HttpPost("lazylibrarian/instances")]
|
||||
public Task<IActionResult> CreateLazyLibrarianInstance([FromBody] ArrInstanceRequest request)
|
||||
=> CreateArrInstance(InstanceType.LazyLibrarian, request);
|
||||
|
||||
[HttpPut("lazylibrarian/instances/{id}")]
|
||||
public Task<IActionResult> UpdateLazyLibrarianInstance(Guid id, [FromBody] ArrInstanceRequest request)
|
||||
=> UpdateArrInstance(InstanceType.LazyLibrarian, id, request);
|
||||
|
||||
[HttpDelete("lazylibrarian/instances/{id}")]
|
||||
public Task<IActionResult> DeleteLazyLibrarianInstance(Guid id)
|
||||
=> DeleteArrInstance(InstanceType.LazyLibrarian, id);
|
||||
|
||||
[HttpPost("sonarr/instances/test")]
|
||||
public Task<IActionResult> TestSonarrInstance([FromBody] TestArrInstanceRequest request)
|
||||
=> TestArrInstance(InstanceType.Sonarr, request);
|
||||
@@ -145,6 +192,13 @@ public sealed class ArrConfigController : ControllerBase
|
||||
public Task<IActionResult> TestWhisparrInstance([FromBody] TestArrInstanceRequest request)
|
||||
=> TestArrInstance(InstanceType.Whisparr, request);
|
||||
|
||||
[HttpPost("sportarr/instances/test")]
|
||||
public Task<IActionResult> TestSportarrInstance([FromBody] TestArrInstanceRequest request)
|
||||
=> TestArrInstance(InstanceType.Sportarr, request);
|
||||
[HttpPost("lazylibrarian/instances/test")]
|
||||
public Task<IActionResult> TestLazyLibrarianInstance([FromBody] TestArrInstanceRequest request)
|
||||
=> TestArrInstance(InstanceType.LazyLibrarian, request);
|
||||
|
||||
private async Task<IActionResult> GetArrConfig(InstanceType type)
|
||||
{
|
||||
await DataContext.Lock.WaitAsync();
|
||||
@@ -159,7 +213,7 @@ public sealed class ArrConfigController : ControllerBase
|
||||
.OrderBy(i => i.Name)
|
||||
.ToList();
|
||||
|
||||
return Ok(config.Adapt<ArrConfigDto>());
|
||||
return Ok(ToDto(config));
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -182,11 +236,6 @@ public sealed class ArrConfigController : ControllerBase
|
||||
|
||||
return Ok(new { Message = $"{type} configuration updated successfully" });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to save {Type} configuration", type);
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
@@ -205,12 +254,7 @@ public sealed class ArrConfigController : ControllerBase
|
||||
await _dataContext.ArrInstances.AddAsync(instance);
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
return CreatedAtAction(GetConfigActionName(type), new { id = instance.Id }, instance.Adapt<ArrInstanceDto>());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to create {Type} instance", type);
|
||||
throw;
|
||||
return CreatedAtAction(GetConfigActionName(type), new { id = instance.Id }, ToDto(instance));
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -230,19 +274,14 @@ public sealed class ArrConfigController : ControllerBase
|
||||
var instance = config.Instances.FirstOrDefault(i => i.Id == id);
|
||||
if (instance is null)
|
||||
{
|
||||
return NotFound($"{type} instance with ID {id} not found");
|
||||
return this.ProblemResult(StatusCodes.Status404NotFound, $"{type} instance with ID {id} not found");
|
||||
}
|
||||
|
||||
request.ApplyTo(instance);
|
||||
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
return Ok(instance.Adapt<ArrInstanceDto>());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to update {Type} instance with ID {Id}", type, id);
|
||||
throw;
|
||||
return Ok(ToDto(instance));
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -262,24 +301,85 @@ public sealed class ArrConfigController : ControllerBase
|
||||
var instance = config.Instances.FirstOrDefault(i => i.Id == id);
|
||||
if (instance is null)
|
||||
{
|
||||
return NotFound($"{type} instance with ID {id} not found");
|
||||
return this.ProblemResult(StatusCodes.Status404NotFound, $"{type} instance with ID {id} not found");
|
||||
}
|
||||
|
||||
config.Instances.Remove(instance);
|
||||
await _dataContext.SaveChangesAsync();
|
||||
await using IDbContextTransaction transaction = await _dataContext.Database.BeginTransactionAsync();
|
||||
|
||||
try
|
||||
{
|
||||
config.Instances.Remove(instance);
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
await DeleteSeekerEventsForInstanceAsync(id);
|
||||
|
||||
await transaction.CommitAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
await transaction.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
|
||||
await FailStrandedSearchEventsForInstanceAsync(id);
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to delete {Type} instance with ID {Id}", type, id);
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeleteSeekerEventsForInstanceAsync(Guid arrInstanceId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using IDbContextTransaction transaction = await _eventsContext.Database.BeginTransactionAsync(cancellationToken);
|
||||
|
||||
try
|
||||
{
|
||||
|
||||
await _eventsContext.CustomFormatScoreEntries
|
||||
.Where(e => e.ArrInstanceId == arrInstanceId)
|
||||
.ExecuteDeleteAsync(cancellationToken);
|
||||
|
||||
await _eventsContext.CustomFormatScoreHistory
|
||||
.Where(e => e.ArrInstanceId == arrInstanceId)
|
||||
.ExecuteDeleteAsync(cancellationToken);
|
||||
|
||||
await _eventsContext.SeekerHistory
|
||||
.Where(e => e.ArrInstanceId == arrInstanceId)
|
||||
.ExecuteDeleteAsync(cancellationToken);
|
||||
|
||||
await _eventsContext.SearchQueue
|
||||
.Where(e => e.ArrInstanceId == arrInstanceId)
|
||||
.ExecuteDeleteAsync(cancellationToken);
|
||||
|
||||
await _eventsContext.SeekerCommandTrackers
|
||||
.Where(e => e.ArrInstanceId == arrInstanceId)
|
||||
.ExecuteDeleteAsync(cancellationToken);
|
||||
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task FailStrandedSearchEventsForInstanceAsync(Guid arrInstanceId)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _eventPublisher.FailStrandedSearchEvents(arrInstanceId);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_logger.LogError(exception,
|
||||
"Failed to mark the search events of instance {InstanceId} as failed, the seeker command monitor will retry",
|
||||
arrInstanceId);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<IActionResult> TestArrInstance(InstanceType type, TestArrInstanceRequest request)
|
||||
{
|
||||
@@ -295,25 +395,43 @@ public sealed class ArrConfigController : ControllerBase
|
||||
|
||||
if (existingInstance is null)
|
||||
{
|
||||
return NotFound($"Instance with ID {request.InstanceId.Value} not found");
|
||||
return this.ProblemResult(StatusCodes.Status404NotFound, $"Instance with ID {request.InstanceId.Value} not found");
|
||||
}
|
||||
|
||||
resolvedApiKey = existingInstance.ApiKey;
|
||||
}
|
||||
|
||||
var testInstance = request.ToTestInstance(resolvedApiKey);
|
||||
var client = _arrClientFactory.GetClient(type, request.Version);
|
||||
await client.HealthCheckAsync(testInstance);
|
||||
await _healthChecker.CheckAsync(type, testInstance);
|
||||
|
||||
return Ok(new { Message = $"Connection to {type} instance successful" });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to test {Type} instance connection", type);
|
||||
return BadRequest(new { Message = $"Connection failed: {ex.Message}" });
|
||||
return this.ProblemResult(StatusCodes.Status400BadRequest, $"Connection failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static ArrConfigDto ToDto(ArrConfig config) => new()
|
||||
{
|
||||
Id = config.Id,
|
||||
Type = config.Type,
|
||||
FailedImportMaxStrikes = config.FailedImportMaxStrikes,
|
||||
Instances = config.Instances.Select(ToDto).ToList(),
|
||||
};
|
||||
|
||||
private static ArrInstanceDto ToDto(ArrInstance instance) => new()
|
||||
{
|
||||
Id = instance.Id,
|
||||
Enabled = instance.Enabled,
|
||||
Version = instance.Version,
|
||||
Name = instance.Name,
|
||||
Url = instance.Url.ToString(),
|
||||
ApiKey = instance.ApiKey,
|
||||
ExternalUrl = instance.ExternalUrl?.ToString(),
|
||||
};
|
||||
|
||||
private static string GetConfigActionName(InstanceType type) => type switch
|
||||
{
|
||||
InstanceType.Sonarr => nameof(GetSonarrConfig),
|
||||
@@ -321,6 +439,8 @@ public sealed class ArrConfigController : ControllerBase
|
||||
InstanceType.Lidarr => nameof(GetLidarrConfig),
|
||||
InstanceType.Readarr => nameof(GetReadarrConfig),
|
||||
InstanceType.Whisparr => nameof(GetWhisparrConfig),
|
||||
InstanceType.Sportarr => nameof(GetSportarrConfig),
|
||||
InstanceType.LazyLibrarian => nameof(GetLazyLibrarianConfig),
|
||||
_ => nameof(GetSonarrConfig),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Auth.Contracts.Requests;
|
||||
|
||||
public sealed record ChangeUsernameRequest
|
||||
{
|
||||
[Required]
|
||||
public required string CurrentPassword { get; init; }
|
||||
|
||||
[Required]
|
||||
[MinLength(3)]
|
||||
[MaxLength(50)]
|
||||
public required string NewUsername { get; init; }
|
||||
}
|
||||
@@ -8,6 +8,6 @@ public sealed record Disable2faRequest
|
||||
public required string Password { get; init; }
|
||||
|
||||
[Required]
|
||||
[StringLength(6, MinimumLength = 6)]
|
||||
[StringLength(32)]
|
||||
public required string TotpCode { get; init; }
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Auth.Contracts.Requests;
|
||||
|
||||
/// <summary>
|
||||
/// Request to record that the current user has seen the given features, used to drive the "NEW" feature badges in the UI.
|
||||
/// </summary>
|
||||
public sealed record RecordFeatureViewsRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// The feature identifiers the user has been exposed to.
|
||||
/// Unknown ids are recorded with the current timestamp; already-seen ids are ignored.
|
||||
/// </summary>
|
||||
[Required]
|
||||
[MaxLength(100)]
|
||||
public required IReadOnlyList<string> FeatureIds { get; init; }
|
||||
}
|
||||
+1
-1
@@ -8,6 +8,6 @@ public sealed record Regenerate2faRequest
|
||||
public required string Password { get; init; }
|
||||
|
||||
[Required]
|
||||
[StringLength(6, MinimumLength = 6)]
|
||||
[StringLength(32)]
|
||||
public required string TotpCode { get; init; }
|
||||
}
|
||||
@@ -5,6 +5,6 @@ namespace Cleanuparr.Api.Features.Auth.Contracts.Requests;
|
||||
public sealed record VerifyTotpRequest
|
||||
{
|
||||
[Required]
|
||||
[StringLength(6, MinimumLength = 6)]
|
||||
[StringLength(32)]
|
||||
public required string Code { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace Cleanuparr.Api.Features.Auth.Contracts.Responses;
|
||||
|
||||
public sealed record FeatureViewsResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// The user's account creation timestamp, used as the anchor for "new feature" detection:
|
||||
/// a feature is only considered new if it was first seen meaningfully after this point.
|
||||
/// </summary>
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Map of feature id to the UTC timestamp the user first saw it.
|
||||
/// </summary>
|
||||
public required Dictionary<string, DateTimeOffset> Views { get; init; }
|
||||
}
|
||||
@@ -4,13 +4,14 @@ using Cleanuparr.Api.Extensions;
|
||||
using Cleanuparr.Api.Features.Auth.Contracts.Requests;
|
||||
using Cleanuparr.Api.Features.Auth.Contracts.Responses;
|
||||
using Cleanuparr.Api.Filters;
|
||||
using Cleanuparr.Domain.Exceptions;
|
||||
using Cleanuparr.Infrastructure.Features.Auth;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Auth;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Auth.Controllers;
|
||||
|
||||
@@ -25,6 +26,7 @@ public sealed class AccountController : ControllerBase
|
||||
private readonly ITotpService _totpService;
|
||||
private readonly IPlexAuthService _plexAuthService;
|
||||
private readonly IOidcAuthService _oidcAuthService;
|
||||
private readonly LoginAttemptTracker _loginAttemptTracker;
|
||||
private readonly ILogger<AccountController> _logger;
|
||||
|
||||
public AccountController(
|
||||
@@ -33,6 +35,7 @@ public sealed class AccountController : ControllerBase
|
||||
ITotpService totpService,
|
||||
IPlexAuthService plexAuthService,
|
||||
IOidcAuthService oidcAuthService,
|
||||
LoginAttemptTracker loginAttemptTracker,
|
||||
ILogger<AccountController> logger)
|
||||
{
|
||||
_usersContext = usersContext;
|
||||
@@ -40,6 +43,7 @@ public sealed class AccountController : ControllerBase
|
||||
_totpService = totpService;
|
||||
_plexAuthService = plexAuthService;
|
||||
_oidcAuthService = oidcAuthService;
|
||||
_loginAttemptTracker = loginAttemptTracker;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -65,217 +69,314 @@ public sealed class AccountController : ControllerBase
|
||||
[HttpPut("password")]
|
||||
public async Task<IActionResult> ChangePassword([FromBody] ChangePasswordRequest request)
|
||||
{
|
||||
if (await IsOidcExclusiveModeActive())
|
||||
await UsersContext.Lock.WaitAsync();
|
||||
|
||||
try
|
||||
{
|
||||
return StatusCode(403, new { error = "Password changes are disabled while OIDC exclusive mode is active." });
|
||||
}
|
||||
if (await IsOidcExclusiveModeActive())
|
||||
{
|
||||
return this.ProblemResult(StatusCodes.Status403Forbidden, "Password changes are disabled while OIDC exclusive mode is active.");
|
||||
}
|
||||
|
||||
var user = await GetCurrentUser();
|
||||
if (user is null)
|
||||
User? user = await GetCurrentUser();
|
||||
if (user is null)
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
if (!_passwordService.VerifyPassword(request.CurrentPassword, user.PasswordHash))
|
||||
{
|
||||
return this.ProblemResult(StatusCodes.Status400BadRequest, "Current password is incorrect");
|
||||
}
|
||||
|
||||
DateTimeOffset now = DateTimeOffset.UtcNow;
|
||||
|
||||
user.PasswordHash = _passwordService.HashPassword(request.NewPassword);
|
||||
user.UpdatedAt = now;
|
||||
|
||||
await RevokeActiveRefreshTokens(user.Id, now);
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Password changed for user {Username}", user.Username);
|
||||
|
||||
return Ok(new { message = "Password changed" });
|
||||
}
|
||||
finally
|
||||
{
|
||||
return Unauthorized();
|
||||
UsersContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
if (!_passwordService.VerifyPassword(request.CurrentPassword, user.PasswordHash))
|
||||
[HttpPut("username")]
|
||||
public async Task<IActionResult> ChangeUsername([FromBody] ChangeUsernameRequest request)
|
||||
{
|
||||
await UsersContext.Lock.WaitAsync();
|
||||
|
||||
try
|
||||
{
|
||||
return BadRequest(new { error = "Current password is incorrect" });
|
||||
if (await IsOidcExclusiveModeActive())
|
||||
{
|
||||
return this.ProblemResult(StatusCodes.Status403Forbidden, "Username changes are disabled while OIDC exclusive mode is active.");
|
||||
}
|
||||
|
||||
User? user = await GetCurrentUser();
|
||||
if (user is null)
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
if (!_passwordService.VerifyPassword(request.CurrentPassword, user.PasswordHash))
|
||||
{
|
||||
return this.ProblemResult(StatusCodes.Status400BadRequest, "Current password is incorrect");
|
||||
}
|
||||
|
||||
// Login compares the stored value, so surrounding whitespace would lock the user out
|
||||
string newUsername = request.NewUsername.Trim();
|
||||
|
||||
if (newUsername.Length < 3)
|
||||
{
|
||||
return this.ProblemResult(StatusCodes.Status400BadRequest, "Username must be at least 3 characters");
|
||||
}
|
||||
|
||||
if (string.Equals(newUsername, user.Username, StringComparison.Ordinal))
|
||||
{
|
||||
return this.ProblemResult(StatusCodes.Status400BadRequest, "New username must be different from the current username");
|
||||
}
|
||||
|
||||
DateTimeOffset now = DateTimeOffset.UtcNow;
|
||||
string previousUsername = user.Username;
|
||||
|
||||
user.Username = newUsername;
|
||||
user.UpdatedAt = now;
|
||||
|
||||
await RevokeActiveRefreshTokens(user.Id, now);
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Username changed from {PreviousUsername} to {Username}",
|
||||
previousUsername.SanitizeForLog(), newUsername.SanitizeForLog());
|
||||
|
||||
return Ok(new { message = "Username changed" });
|
||||
}
|
||||
|
||||
DateTime now = DateTime.UtcNow;
|
||||
|
||||
user.PasswordHash = _passwordService.HashPassword(request.NewPassword);
|
||||
user.UpdatedAt = now;
|
||||
|
||||
// Revoke all existing refresh tokens so old sessions can't be reused
|
||||
var activeTokens = await _usersContext.RefreshTokens
|
||||
.Where(r => r.UserId == user.Id && r.RevokedAt == null)
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var token in activeTokens)
|
||||
finally
|
||||
{
|
||||
token.RevokedAt = now;
|
||||
UsersContext.Lock.Release();
|
||||
}
|
||||
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Password changed for user {Username}", user.Username);
|
||||
|
||||
return Ok(new { message = "Password changed" });
|
||||
}
|
||||
|
||||
[HttpPost("2fa/regenerate")]
|
||||
public async Task<IActionResult> Regenerate2fa([FromBody] Regenerate2faRequest request)
|
||||
{
|
||||
var user = await GetCurrentUser(includeRecoveryCodes: true);
|
||||
if (user is null)
|
||||
User user;
|
||||
string? failureMessage = null;
|
||||
int retryAfterSeconds = 0;
|
||||
TotpSetupResponse? setup = null;
|
||||
|
||||
await UsersContext.Lock.WaitAsync();
|
||||
|
||||
try
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
// Verify current credentials
|
||||
if (!_passwordService.VerifyPassword(request.Password, user.PasswordHash))
|
||||
{
|
||||
return BadRequest(new { error = "Incorrect password" });
|
||||
}
|
||||
|
||||
if (!_totpService.ValidateCode(user.TotpSecret, request.TotpCode))
|
||||
{
|
||||
return BadRequest(new { error = "Invalid 2FA code" });
|
||||
}
|
||||
|
||||
// Generate new TOTP
|
||||
var secret = _totpService.GenerateSecret();
|
||||
var qrUri = _totpService.GetQrCodeUri(secret, user.Username);
|
||||
var recoveryCodes = _totpService.GenerateRecoveryCodes();
|
||||
|
||||
user.TotpSecret = secret;
|
||||
user.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
// Replace recovery codes
|
||||
_usersContext.RecoveryCodes.RemoveRange(user.RecoveryCodes);
|
||||
|
||||
foreach (var code in recoveryCodes)
|
||||
{
|
||||
_usersContext.RecoveryCodes.Add(new RecoveryCode
|
||||
User? currentUser = await GetCurrentUser(includeRecoveryCodes: true);
|
||||
if (currentUser is null)
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = user.Id,
|
||||
CodeHash = _totpService.HashRecoveryCode(code),
|
||||
IsUsed = false
|
||||
});
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
user = currentUser;
|
||||
|
||||
if (LoginAttemptTracker.GetLockoutSecondsRemaining(user) is { } remaining)
|
||||
{
|
||||
throw new RateLimitException("Account is locked", remaining);
|
||||
}
|
||||
|
||||
// Verify current credentials
|
||||
if (!_passwordService.VerifyPassword(request.Password, user.PasswordHash))
|
||||
{
|
||||
failureMessage = "Incorrect password";
|
||||
}
|
||||
else if (!_totpService.VerifySecondFactor(user, request.TotpCode))
|
||||
{
|
||||
failureMessage = "Invalid authenticator or recovery code";
|
||||
}
|
||||
else
|
||||
{
|
||||
setup = TwoFactorSecretRotation.Rotate(_totpService, _usersContext, user);
|
||||
|
||||
await _usersContext.SaveChangesAsync();
|
||||
}
|
||||
|
||||
if (failureMessage is not null)
|
||||
{
|
||||
retryAfterSeconds = await _loginAttemptTracker.IncrementFailedAttempts(user.Id);
|
||||
}
|
||||
else
|
||||
{
|
||||
await _loginAttemptTracker.ResetFailedAttempts(user.Id);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
UsersContext.Lock.Release();
|
||||
}
|
||||
|
||||
await _usersContext.SaveChangesAsync();
|
||||
if (failureMessage is not null)
|
||||
{
|
||||
return this.ProblemResult(StatusCodes.Status400BadRequest, failureMessage,
|
||||
extensions: new Dictionary<string, object?> { ["retryAfterSeconds"] = retryAfterSeconds });
|
||||
}
|
||||
|
||||
_logger.LogInformation("2FA regenerated for user {Username}", user.Username);
|
||||
|
||||
return Ok(new TotpSetupResponse
|
||||
{
|
||||
Secret = secret,
|
||||
QrCodeUri = qrUri,
|
||||
RecoveryCodes = recoveryCodes
|
||||
});
|
||||
return Ok(setup);
|
||||
}
|
||||
|
||||
[HttpPost("2fa/enable")]
|
||||
public async Task<IActionResult> Enable2fa([FromBody] Enable2faRequest request)
|
||||
{
|
||||
var user = await GetCurrentUser(includeRecoveryCodes: true);
|
||||
if (user is null)
|
||||
await UsersContext.Lock.WaitAsync();
|
||||
|
||||
try
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
if (user.TotpEnabled)
|
||||
{
|
||||
return Conflict(new { error = "2FA is already enabled" });
|
||||
}
|
||||
|
||||
if (!_passwordService.VerifyPassword(request.Password, user.PasswordHash))
|
||||
{
|
||||
return BadRequest(new { error = "Incorrect password" });
|
||||
}
|
||||
|
||||
// Generate new TOTP
|
||||
var secret = _totpService.GenerateSecret();
|
||||
var qrUri = _totpService.GetQrCodeUri(secret, user.Username);
|
||||
var recoveryCodes = _totpService.GenerateRecoveryCodes();
|
||||
|
||||
user.TotpSecret = secret;
|
||||
user.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
// Replace any existing recovery codes
|
||||
_usersContext.RecoveryCodes.RemoveRange(user.RecoveryCodes);
|
||||
|
||||
foreach (var code in recoveryCodes)
|
||||
{
|
||||
_usersContext.RecoveryCodes.Add(new RecoveryCode
|
||||
var user = await GetCurrentUser(includeRecoveryCodes: true);
|
||||
if (user is null)
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = user.Id,
|
||||
CodeHash = _totpService.HashRecoveryCode(code),
|
||||
IsUsed = false
|
||||
});
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
if (user.TotpEnabled)
|
||||
{
|
||||
return this.ProblemResult(StatusCodes.Status409Conflict, "2FA is already enabled");
|
||||
}
|
||||
|
||||
if (!_passwordService.VerifyPassword(request.Password, user.PasswordHash))
|
||||
{
|
||||
return this.ProblemResult(StatusCodes.Status400BadRequest, "Incorrect password");
|
||||
}
|
||||
|
||||
TotpSetupResponse setup = TwoFactorSecretRotation.Rotate(_totpService, _usersContext, user);
|
||||
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("2FA setup generated for user {Username}", user.Username);
|
||||
|
||||
return Ok(setup);
|
||||
}
|
||||
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("2FA setup generated for user {Username}", user.Username);
|
||||
|
||||
return Ok(new TotpSetupResponse
|
||||
finally
|
||||
{
|
||||
Secret = secret,
|
||||
QrCodeUri = qrUri,
|
||||
RecoveryCodes = recoveryCodes
|
||||
});
|
||||
UsersContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("2fa/enable/verify")]
|
||||
public async Task<IActionResult> VerifyEnable2fa([FromBody] VerifyTotpRequest request)
|
||||
{
|
||||
var user = await GetCurrentUser();
|
||||
if (user is null)
|
||||
await UsersContext.Lock.WaitAsync();
|
||||
|
||||
try
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
var user = await GetCurrentUser();
|
||||
if (user is null)
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
if (user.TotpEnabled)
|
||||
if (user.TotpEnabled)
|
||||
{
|
||||
return this.ProblemResult(StatusCodes.Status409Conflict, "2FA is already enabled");
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(user.TotpSecret))
|
||||
{
|
||||
return this.ProblemResult(StatusCodes.Status400BadRequest, "Generate 2FA setup first");
|
||||
}
|
||||
|
||||
if (!_totpService.ValidateCode(user.TotpSecret, request.Code))
|
||||
{
|
||||
return this.ProblemResult(StatusCodes.Status400BadRequest, "Invalid verification code");
|
||||
}
|
||||
|
||||
user.TotpEnabled = true;
|
||||
user.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("2FA enabled for user {Username}", user.Username);
|
||||
|
||||
return Ok(new { message = "2FA enabled" });
|
||||
}
|
||||
finally
|
||||
{
|
||||
return Conflict(new { error = "2FA is already enabled" });
|
||||
UsersContext.Lock.Release();
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(user.TotpSecret))
|
||||
{
|
||||
return BadRequest(new { error = "Generate 2FA setup first" });
|
||||
}
|
||||
|
||||
if (!_totpService.ValidateCode(user.TotpSecret, request.Code))
|
||||
{
|
||||
return BadRequest(new { error = "Invalid verification code" });
|
||||
}
|
||||
|
||||
user.TotpEnabled = true;
|
||||
user.UpdatedAt = DateTime.UtcNow;
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("2FA enabled for user {Username}", user.Username);
|
||||
|
||||
return Ok(new { message = "2FA enabled" });
|
||||
}
|
||||
|
||||
[HttpPost("2fa/disable")]
|
||||
public async Task<IActionResult> Disable2fa([FromBody] Disable2faRequest request)
|
||||
{
|
||||
var user = await GetCurrentUser(includeRecoveryCodes: true);
|
||||
if (user is null)
|
||||
User user;
|
||||
string? failureMessage = null;
|
||||
int retryAfterSeconds = 0;
|
||||
|
||||
await UsersContext.Lock.WaitAsync();
|
||||
|
||||
try
|
||||
{
|
||||
return Unauthorized();
|
||||
User? currentUser = await GetCurrentUser(includeRecoveryCodes: true);
|
||||
if (currentUser is null)
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
user = currentUser;
|
||||
|
||||
if (!user.TotpEnabled)
|
||||
{
|
||||
return this.ProblemResult(StatusCodes.Status400BadRequest, "2FA is not enabled");
|
||||
}
|
||||
|
||||
if (LoginAttemptTracker.GetLockoutSecondsRemaining(user) is { } remaining)
|
||||
{
|
||||
throw new RateLimitException("Account is locked", remaining);
|
||||
}
|
||||
|
||||
if (!_passwordService.VerifyPassword(request.Password, user.PasswordHash))
|
||||
{
|
||||
failureMessage = "Incorrect password";
|
||||
}
|
||||
else if (!_totpService.VerifySecondFactor(user, request.TotpCode))
|
||||
{
|
||||
failureMessage = "Invalid authenticator or recovery code";
|
||||
}
|
||||
else
|
||||
{
|
||||
user.TotpEnabled = false;
|
||||
user.TotpSecret = string.Empty;
|
||||
user.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
// Remove all recovery codes
|
||||
_usersContext.RecoveryCodes.RemoveRange(user.RecoveryCodes);
|
||||
|
||||
await _usersContext.SaveChangesAsync();
|
||||
}
|
||||
|
||||
if (failureMessage is not null)
|
||||
{
|
||||
retryAfterSeconds = await _loginAttemptTracker.IncrementFailedAttempts(user.Id);
|
||||
}
|
||||
else
|
||||
{
|
||||
await _loginAttemptTracker.ResetFailedAttempts(user.Id);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
UsersContext.Lock.Release();
|
||||
}
|
||||
|
||||
if (!user.TotpEnabled)
|
||||
if (failureMessage is not null)
|
||||
{
|
||||
return BadRequest(new { error = "2FA is not enabled" });
|
||||
return this.ProblemResult(StatusCodes.Status400BadRequest, failureMessage,
|
||||
extensions: new Dictionary<string, object?> { ["retryAfterSeconds"] = retryAfterSeconds });
|
||||
}
|
||||
|
||||
if (!_passwordService.VerifyPassword(request.Password, user.PasswordHash))
|
||||
{
|
||||
return BadRequest(new { error = "Incorrect password" });
|
||||
}
|
||||
|
||||
if (!_totpService.ValidateCode(user.TotpSecret, request.TotpCode))
|
||||
{
|
||||
return BadRequest(new { error = "Invalid 2FA code" });
|
||||
}
|
||||
|
||||
user.TotpEnabled = false;
|
||||
user.TotpSecret = string.Empty;
|
||||
user.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
// Remove all recovery codes
|
||||
_usersContext.RecoveryCodes.RemoveRange(user.RecoveryCodes);
|
||||
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("2FA disabled for user {Username}", user.Username);
|
||||
|
||||
return Ok(new { message = "2FA disabled" });
|
||||
@@ -307,7 +408,7 @@ public sealed class AccountController : ControllerBase
|
||||
rng.GetBytes(bytes);
|
||||
|
||||
user.ApiKey = Convert.ToHexString(bytes).ToLowerInvariant();
|
||||
user.UpdatedAt = DateTime.UtcNow;
|
||||
user.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("API key regenerated for user {Username}", user.Username);
|
||||
@@ -320,7 +421,7 @@ public sealed class AccountController : ControllerBase
|
||||
{
|
||||
if (await IsOidcExclusiveModeActive())
|
||||
{
|
||||
return StatusCode(403, new { error = "Plex account management is disabled while OIDC exclusive mode is active." });
|
||||
return this.ProblemResult(StatusCodes.Status403Forbidden, "Plex account management is disabled while OIDC exclusive mode is active.");
|
||||
}
|
||||
|
||||
var pin = await _plexAuthService.RequestPin();
|
||||
@@ -333,7 +434,7 @@ public sealed class AccountController : ControllerBase
|
||||
{
|
||||
if (await IsOidcExclusiveModeActive())
|
||||
{
|
||||
return StatusCode(403, new { error = "Plex account management is disabled while OIDC exclusive mode is active." });
|
||||
return this.ProblemResult(StatusCodes.Status403Forbidden, "Plex account management is disabled while OIDC exclusive mode is active.");
|
||||
}
|
||||
|
||||
var pinResult = await _plexAuthService.CheckPin(request.PinId);
|
||||
@@ -355,7 +456,7 @@ public sealed class AccountController : ControllerBase
|
||||
user.PlexUsername = plexAccount.Username;
|
||||
user.PlexEmail = plexAccount.Email;
|
||||
user.PlexAuthToken = pinResult.AuthToken;
|
||||
user.UpdatedAt = DateTime.UtcNow;
|
||||
user.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Plex account linked for user {Username}: {PlexUsername}",
|
||||
@@ -369,7 +470,7 @@ public sealed class AccountController : ControllerBase
|
||||
{
|
||||
if (await IsOidcExclusiveModeActive())
|
||||
{
|
||||
return StatusCode(403, new { error = "Plex account management is disabled while OIDC exclusive mode is active." });
|
||||
return this.ProblemResult(StatusCodes.Status403Forbidden, "Plex account management is disabled while OIDC exclusive mode is active.");
|
||||
}
|
||||
|
||||
var user = await GetCurrentUser();
|
||||
@@ -382,7 +483,7 @@ public sealed class AccountController : ControllerBase
|
||||
user.PlexUsername = null;
|
||||
user.PlexEmail = null;
|
||||
user.PlexAuthToken = null;
|
||||
user.UpdatedAt = DateTime.UtcNow;
|
||||
user.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Plex account unlinked for user {Username}", user.Username);
|
||||
@@ -405,9 +506,11 @@ public sealed class AccountController : ControllerBase
|
||||
[HttpPut("oidc")]
|
||||
public async Task<IActionResult> UpdateOidcConfig([FromBody] UpdateOidcConfigRequest request)
|
||||
{
|
||||
await UsersContext.Lock.WaitAsync();
|
||||
|
||||
try
|
||||
{
|
||||
var user = await GetCurrentUser();
|
||||
User? user = await GetCurrentUser();
|
||||
if (user is null)
|
||||
{
|
||||
return Unauthorized();
|
||||
@@ -415,14 +518,14 @@ public sealed class AccountController : ControllerBase
|
||||
|
||||
request.ApplyTo(user.Oidc);
|
||||
user.Oidc.Validate();
|
||||
user.UpdatedAt = DateTime.UtcNow;
|
||||
user.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
return Ok(new { message = "OIDC configuration updated" });
|
||||
}
|
||||
catch (ValidationException ex)
|
||||
finally
|
||||
{
|
||||
return BadRequest(new { error = ex.Message });
|
||||
UsersContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -437,7 +540,7 @@ public sealed class AccountController : ControllerBase
|
||||
|
||||
if (user.Oidc is not { Enabled: true })
|
||||
{
|
||||
return BadRequest(new { error = "OIDC is not enabled" });
|
||||
return this.ProblemResult(StatusCodes.Status400BadRequest, "OIDC is not enabled");
|
||||
}
|
||||
|
||||
var redirectUri = GetOidcLinkCallbackUrl(user.Oidc.RedirectUrl);
|
||||
@@ -450,8 +553,7 @@ public sealed class AccountController : ControllerBase
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to start OIDC link authorization");
|
||||
return StatusCode(429, new { error = ex.Message });
|
||||
throw new RateLimitException(ex.Message, ex);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -504,7 +606,7 @@ public sealed class AccountController : ControllerBase
|
||||
}
|
||||
|
||||
user.Oidc.AuthorizedSubject = result.Subject;
|
||||
user.UpdatedAt = DateTime.UtcNow;
|
||||
user.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("OIDC account linked with subject: {Subject} by user: {Username}",
|
||||
@@ -527,7 +629,7 @@ public sealed class AccountController : ControllerBase
|
||||
|
||||
user.Oidc.AuthorizedSubject = string.Empty;
|
||||
user.Oidc.ExclusiveMode = false;
|
||||
user.UpdatedAt = DateTime.UtcNow;
|
||||
user.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("OIDC account unlinked for user {Username}", user.Username);
|
||||
@@ -540,6 +642,79 @@ public sealed class AccountController : ControllerBase
|
||||
}
|
||||
}
|
||||
|
||||
private const int MaxFeatureIdsPerRequest = 100;
|
||||
private const int MaxFeatureIdLength = 64;
|
||||
|
||||
/// <summary>
|
||||
/// Records that the current user has seen the given features, used to drive the "NEW" feature badges in the UI.
|
||||
/// Recording is idempotent: unknown ids are stamped with the current time, already-seen ids keep their original timestamp.
|
||||
/// </summary>
|
||||
/// <param name="request">The feature ids the user has been exposed to.</param>
|
||||
/// <returns>
|
||||
/// The user's account creation timestamp and the full map of feature id to first-seen timestamp.
|
||||
/// </returns>
|
||||
[HttpPost("feature-views")]
|
||||
public async Task<IActionResult> RecordFeatureViews([FromBody] RecordFeatureViewsRequest request)
|
||||
{
|
||||
if (request.FeatureIds.Count > MaxFeatureIdsPerRequest)
|
||||
{
|
||||
return this.ProblemResult(StatusCodes.Status400BadRequest, $"featureIds exceeds the maximum allowed ({MaxFeatureIdsPerRequest}).");
|
||||
}
|
||||
|
||||
await UsersContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var user = await GetCurrentUser();
|
||||
if (user is null)
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
var existing = await _usersContext.UserFeatureViews
|
||||
.Where(v => v.UserId == user.Id)
|
||||
.ToListAsync();
|
||||
|
||||
var existingIds = existing
|
||||
.Select(v => v.FeatureId)
|
||||
.ToHashSet();
|
||||
|
||||
DateTimeOffset now = DateTimeOffset.UtcNow;
|
||||
|
||||
foreach (var featureId in request.FeatureIds.Distinct())
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(featureId) ||
|
||||
featureId.Length > MaxFeatureIdLength ||
|
||||
existingIds.Contains(featureId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var view = new UserFeatureView
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = user.Id,
|
||||
FeatureId = featureId,
|
||||
FirstSeenAt = now
|
||||
};
|
||||
|
||||
_usersContext.UserFeatureViews.Add(view);
|
||||
existing.Add(view);
|
||||
}
|
||||
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
return Ok(new FeatureViewsResponse
|
||||
{
|
||||
CreatedAt = user.CreatedAt,
|
||||
Views = existing.ToDictionary(v => v.FeatureId, v => v.FirstSeenAt)
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
UsersContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private string GetOidcLinkCallbackUrl(string? redirectUrl = null)
|
||||
{
|
||||
var baseUrl = string.IsNullOrEmpty(redirectUrl)
|
||||
@@ -548,6 +723,21 @@ public sealed class AccountController : ControllerBase
|
||||
return $"{baseUrl}/api/account/oidc/link/callback";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Revokes every active refresh token so sessions opened before a credential change cannot be reused.
|
||||
/// </summary>
|
||||
private async Task RevokeActiveRefreshTokens(Guid userId, DateTimeOffset now)
|
||||
{
|
||||
List<RefreshToken> activeTokens = await _usersContext.RefreshTokens
|
||||
.Where(r => r.UserId == userId && r.RevokedAt == null)
|
||||
.ToListAsync();
|
||||
|
||||
foreach (RefreshToken token in activeTokens)
|
||||
{
|
||||
token.RevokedAt = now;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> IsOidcExclusiveModeActive()
|
||||
{
|
||||
var user = await _usersContext.Users.AsNoTracking().FirstOrDefaultAsync();
|
||||
|
||||
@@ -4,9 +4,11 @@ using Cleanuparr.Api.Extensions;
|
||||
using Cleanuparr.Api.Features.Auth.Contracts.Requests;
|
||||
using Cleanuparr.Api.Features.Auth.Contracts.Responses;
|
||||
using Cleanuparr.Api.Filters;
|
||||
using Cleanuparr.Domain.Exceptions;
|
||||
using Cleanuparr.Infrastructure.Features.Auth;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Auth;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -26,7 +28,9 @@ public sealed class AuthController : ControllerBase
|
||||
private readonly ITotpService _totpService;
|
||||
private readonly IPlexAuthService _plexAuthService;
|
||||
private readonly IOidcAuthService _oidcAuthService;
|
||||
private readonly LoginAttemptTracker _loginAttemptTracker;
|
||||
private readonly ILogger<AuthController> _logger;
|
||||
private readonly IWebHostEnvironment _environment;
|
||||
|
||||
public AuthController(
|
||||
UsersContext usersContext,
|
||||
@@ -36,7 +40,9 @@ public sealed class AuthController : ControllerBase
|
||||
ITotpService totpService,
|
||||
IPlexAuthService plexAuthService,
|
||||
IOidcAuthService oidcAuthService,
|
||||
ILogger<AuthController> logger)
|
||||
LoginAttemptTracker loginAttemptTracker,
|
||||
ILogger<AuthController> logger,
|
||||
IWebHostEnvironment environment)
|
||||
{
|
||||
_usersContext = usersContext;
|
||||
_dataContext = dataContext;
|
||||
@@ -45,7 +51,9 @@ public sealed class AuthController : ControllerBase
|
||||
_totpService = totpService;
|
||||
_plexAuthService = plexAuthService;
|
||||
_oidcAuthService = oidcAuthService;
|
||||
_loginAttemptTracker = loginAttemptTracker;
|
||||
_logger = logger;
|
||||
_environment = environment;
|
||||
}
|
||||
|
||||
[HttpGet("status")]
|
||||
@@ -92,7 +100,7 @@ public sealed class AuthController : ControllerBase
|
||||
var existingUser = await _usersContext.Users.FirstOrDefaultAsync();
|
||||
if (existingUser is not null)
|
||||
{
|
||||
return Conflict(new { error = "Account already exists" });
|
||||
return this.ProblemResult(StatusCodes.Status409Conflict, "Account already exists");
|
||||
}
|
||||
|
||||
var user = new User
|
||||
@@ -104,14 +112,14 @@ public sealed class AuthController : ControllerBase
|
||||
TotpEnabled = false,
|
||||
ApiKey = GenerateApiKey(),
|
||||
SetupCompleted = false,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
_usersContext.Users.Add(user);
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Admin account created for user {Username}", request.Username);
|
||||
_logger.LogInformation("Admin account created for user {Username}", request.Username.SanitizeForLog());
|
||||
|
||||
return Created("", new { userId = user.Id });
|
||||
}
|
||||
@@ -133,47 +141,20 @@ public sealed class AuthController : ControllerBase
|
||||
|
||||
if (user is null)
|
||||
{
|
||||
return BadRequest(new { error = "Create an account first" });
|
||||
return this.ProblemResult(StatusCodes.Status400BadRequest, "Create an account first");
|
||||
}
|
||||
|
||||
if (user.SetupCompleted)
|
||||
{
|
||||
return Conflict(new { error = "Setup already completed. Use account settings to manage 2FA." });
|
||||
return this.ProblemResult(StatusCodes.Status409Conflict, "Setup already completed. Use account settings to manage 2FA.");
|
||||
}
|
||||
|
||||
// Generate new TOTP secret
|
||||
var secret = _totpService.GenerateSecret();
|
||||
var qrUri = _totpService.GetQrCodeUri(secret, user.Username);
|
||||
|
||||
// Generate recovery codes
|
||||
var recoveryCodes = _totpService.GenerateRecoveryCodes();
|
||||
|
||||
// Store secret (will be finalized on verify)
|
||||
user.TotpSecret = secret;
|
||||
user.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
// Remove old recovery codes and add new ones
|
||||
_usersContext.RecoveryCodes.RemoveRange(user.RecoveryCodes);
|
||||
|
||||
foreach (var code in recoveryCodes)
|
||||
{
|
||||
_usersContext.RecoveryCodes.Add(new RecoveryCode
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = user.Id,
|
||||
CodeHash = _totpService.HashRecoveryCode(code),
|
||||
IsUsed = false
|
||||
});
|
||||
}
|
||||
// Secret is finalized on verify
|
||||
TotpSetupResponse setup = TwoFactorSecretRotation.Rotate(_totpService, _usersContext, user);
|
||||
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
return Ok(new TotpSetupResponse
|
||||
{
|
||||
Secret = secret,
|
||||
QrCodeUri = qrUri,
|
||||
RecoveryCodes = recoveryCodes
|
||||
});
|
||||
return Ok(setup);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -190,26 +171,26 @@ public sealed class AuthController : ControllerBase
|
||||
var user = await _usersContext.Users.FirstOrDefaultAsync();
|
||||
if (user is null)
|
||||
{
|
||||
return BadRequest(new { error = "Create an account first" });
|
||||
return this.ProblemResult(StatusCodes.Status400BadRequest, "Create an account first");
|
||||
}
|
||||
|
||||
if (user.SetupCompleted)
|
||||
{
|
||||
return Conflict(new { error = "Setup already completed. Use account settings to manage 2FA." });
|
||||
return this.ProblemResult(StatusCodes.Status409Conflict, "Setup already completed. Use account settings to manage 2FA.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(user.TotpSecret))
|
||||
{
|
||||
return BadRequest(new { error = "Generate 2FA setup first" });
|
||||
return this.ProblemResult(StatusCodes.Status400BadRequest, "Generate 2FA setup first");
|
||||
}
|
||||
|
||||
if (!_totpService.ValidateCode(user.TotpSecret, request.Code))
|
||||
{
|
||||
return Unauthorized(new { error = "Invalid verification code" });
|
||||
return this.ProblemResult(StatusCodes.Status401Unauthorized, "Invalid verification code");
|
||||
}
|
||||
|
||||
user.TotpEnabled = true;
|
||||
user.UpdatedAt = DateTime.UtcNow;
|
||||
user.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("2FA enabled for user {Username}", user.Username);
|
||||
@@ -231,16 +212,16 @@ public sealed class AuthController : ControllerBase
|
||||
var user = await _usersContext.Users.FirstOrDefaultAsync();
|
||||
if (user is null)
|
||||
{
|
||||
return BadRequest(new { error = "Create an account first" });
|
||||
return this.ProblemResult(StatusCodes.Status400BadRequest, "Create an account first");
|
||||
}
|
||||
|
||||
if (user.SetupCompleted)
|
||||
{
|
||||
return Conflict(new { error = "Setup already completed" });
|
||||
return this.ProblemResult(StatusCodes.Status409Conflict, "Setup already completed");
|
||||
}
|
||||
|
||||
user.SetupCompleted = true;
|
||||
user.UpdatedAt = DateTime.UtcNow;
|
||||
user.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Setup completed for user {Username}", user.Username);
|
||||
@@ -258,61 +239,73 @@ public sealed class AuthController : ControllerBase
|
||||
{
|
||||
if (await IsOidcExclusiveModeActive())
|
||||
{
|
||||
return StatusCode(403, new { error = "Login with credentials is disabled. Use OIDC to sign in." });
|
||||
return this.ProblemResult(StatusCodes.Status403Forbidden, "Login with credentials is disabled. Use OIDC to sign in.");
|
||||
}
|
||||
|
||||
var user = await _usersContext.Users.AsNoTracking().FirstOrDefaultAsync();
|
||||
|
||||
// Always verify the submitted password to prevent timing-based username enumeration
|
||||
var userHasPassword = user?.PasswordHash is not null;
|
||||
var passwordHash = user?.PasswordHash ?? _passwordService.DummyHash;
|
||||
var passwordValid = _passwordService.VerifyPassword(request.Password, passwordHash) && userHasPassword;
|
||||
var verifiedHash = user?.PasswordHash ?? _passwordService.DummyHash;
|
||||
var passwordValid = _passwordService.VerifyPassword(request.Password, verifiedHash) && userHasPassword;
|
||||
|
||||
if (user is null || !user.SetupCompleted)
|
||||
{
|
||||
return Unauthorized(new { error = "Invalid credentials" });
|
||||
return this.ProblemResult(StatusCodes.Status401Unauthorized, "Invalid credentials");
|
||||
}
|
||||
|
||||
// Check lockout
|
||||
if (user.LockoutEnd.HasValue && user.LockoutEnd.Value > DateTime.UtcNow)
|
||||
await UsersContext.Lock.WaitAsync();
|
||||
|
||||
try
|
||||
{
|
||||
var remaining = (int)Math.Ceiling((user.LockoutEnd.Value - DateTime.UtcNow).TotalSeconds);
|
||||
return StatusCode(429, new { error = "Account is locked", retryAfterSeconds = remaining });
|
||||
}
|
||||
User current = await _usersContext.Users.FirstAsync(u => u.Id == user.Id);
|
||||
|
||||
if (!passwordValid || !string.Equals(user.Username, request.Username, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var retryAfterSeconds = await IncrementFailedAttempts(user.Id);
|
||||
return Unauthorized(new { error = "Invalid credentials", retryAfterSeconds });
|
||||
}
|
||||
// Check lockout
|
||||
if (LoginAttemptTracker.GetLockoutSecondsRemaining(current) is { } remaining)
|
||||
{
|
||||
throw new RateLimitException("Account is locked", remaining);
|
||||
}
|
||||
|
||||
// Reset failed attempts on successful password verification
|
||||
await ResetFailedAttempts(user.Id);
|
||||
bool credentialsValid = passwordValid
|
||||
&& string.Equals(current.Username, request.Username, StringComparison.OrdinalIgnoreCase)
|
||||
&& string.Equals(current.PasswordHash, verifiedHash, StringComparison.Ordinal);
|
||||
|
||||
// If 2FA is not enabled, issue tokens directly
|
||||
if (!user.TotpEnabled)
|
||||
{
|
||||
// Re-fetch with tracking since the query above used AsNoTracking
|
||||
var trackedUser = await _usersContext.Users.FirstAsync(u => u.Id == user.Id);
|
||||
var tokenResponse = await GenerateTokenResponse(trackedUser);
|
||||
if (!credentialsValid)
|
||||
{
|
||||
int retryAfterSeconds = await _loginAttemptTracker.IncrementFailedAttempts(current.Id);
|
||||
return this.ProblemResult(StatusCodes.Status401Unauthorized, "Invalid credentials",
|
||||
extensions: new Dictionary<string, object?> { ["retryAfterSeconds"] = retryAfterSeconds });
|
||||
}
|
||||
|
||||
_logger.LogInformation("User {Username} logged in (2FA disabled)", user.Username);
|
||||
// If 2FA is not enabled, issue tokens directly
|
||||
if (!current.TotpEnabled)
|
||||
{
|
||||
await _loginAttemptTracker.ResetFailedAttempts(current.Id);
|
||||
|
||||
var tokenResponse = await GenerateTokenResponse(current);
|
||||
|
||||
_logger.LogInformation("User {Username} logged in (2FA disabled)", current.Username);
|
||||
|
||||
return Ok(new LoginResponse
|
||||
{
|
||||
RequiresTwoFactor = false,
|
||||
Tokens = tokenResponse
|
||||
});
|
||||
}
|
||||
|
||||
// Password valid - require 2FA
|
||||
var loginToken = _jwtService.GenerateLoginToken(current.Id);
|
||||
|
||||
return Ok(new LoginResponse
|
||||
{
|
||||
RequiresTwoFactor = false,
|
||||
Tokens = tokenResponse
|
||||
RequiresTwoFactor = true,
|
||||
LoginToken = loginToken
|
||||
});
|
||||
}
|
||||
|
||||
// Password valid - require 2FA
|
||||
var loginToken = _jwtService.GenerateLoginToken(user.Id);
|
||||
|
||||
return Ok(new LoginResponse
|
||||
finally
|
||||
{
|
||||
RequiresTwoFactor = true,
|
||||
LoginToken = loginToken
|
||||
});
|
||||
UsersContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("login/2fa")]
|
||||
@@ -320,41 +313,59 @@ public sealed class AuthController : ControllerBase
|
||||
{
|
||||
if (await IsOidcExclusiveModeActive())
|
||||
{
|
||||
return StatusCode(403, new { error = "Login with credentials is disabled. Use OIDC to sign in." });
|
||||
return this.ProblemResult(StatusCodes.Status403Forbidden, "Login with credentials is disabled. Use OIDC to sign in.");
|
||||
}
|
||||
|
||||
var userId = _jwtService.ValidateLoginToken(request.LoginToken);
|
||||
if (userId is null)
|
||||
{
|
||||
return Unauthorized(new { error = "Invalid or expired login token" });
|
||||
return this.ProblemResult(StatusCodes.Status401Unauthorized, "Invalid or expired login token");
|
||||
}
|
||||
|
||||
var user = await _usersContext.Users
|
||||
.Include(u => u.RecoveryCodes)
|
||||
.FirstOrDefaultAsync(u => u.Id == userId.Value);
|
||||
await UsersContext.Lock.WaitAsync();
|
||||
|
||||
if (user is null)
|
||||
try
|
||||
{
|
||||
return Unauthorized(new { error = "Invalid login token" });
|
||||
var user = await _usersContext.Users
|
||||
.Include(u => u.RecoveryCodes)
|
||||
.FirstOrDefaultAsync(u => u.Id == userId.Value);
|
||||
|
||||
if (user is null)
|
||||
{
|
||||
return this.ProblemResult(StatusCodes.Status401Unauthorized, "Invalid login token");
|
||||
}
|
||||
|
||||
if (LoginAttemptTracker.GetLockoutSecondsRemaining(user) is { } remaining)
|
||||
{
|
||||
throw new RateLimitException("Account is locked", remaining);
|
||||
}
|
||||
|
||||
bool codeValid;
|
||||
|
||||
if (request.IsRecoveryCode)
|
||||
{
|
||||
codeValid = await TryUseRecoveryCode(user, request.Code);
|
||||
}
|
||||
else
|
||||
{
|
||||
codeValid = _totpService.ValidateCode(user.TotpSecret, request.Code);
|
||||
}
|
||||
|
||||
if (!codeValid)
|
||||
{
|
||||
int retryAfterSeconds = await _loginAttemptTracker.IncrementFailedAttempts(user.Id);
|
||||
return this.ProblemResult(StatusCodes.Status401Unauthorized, "Invalid verification code",
|
||||
extensions: new Dictionary<string, object?> { ["retryAfterSeconds"] = retryAfterSeconds });
|
||||
}
|
||||
|
||||
await _loginAttemptTracker.ResetFailedAttempts(user.Id);
|
||||
|
||||
return Ok(await GenerateTokenResponse(user));
|
||||
}
|
||||
|
||||
bool codeValid;
|
||||
|
||||
if (request.IsRecoveryCode)
|
||||
finally
|
||||
{
|
||||
codeValid = await TryUseRecoveryCode(user, request.Code);
|
||||
UsersContext.Lock.Release();
|
||||
}
|
||||
else
|
||||
{
|
||||
codeValid = _totpService.ValidateCode(user.TotpSecret, request.Code);
|
||||
}
|
||||
|
||||
if (!codeValid)
|
||||
{
|
||||
return Unauthorized(new { error = "Invalid verification code" });
|
||||
}
|
||||
|
||||
return Ok(await GenerateTokenResponse(user));
|
||||
}
|
||||
|
||||
[HttpPost("refresh")]
|
||||
@@ -369,13 +380,13 @@ public sealed class AuthController : ControllerBase
|
||||
.Include(r => r.User)
|
||||
.FirstOrDefaultAsync(r => r.TokenHash == tokenHash && r.RevokedAt == null);
|
||||
|
||||
if (storedToken is null || storedToken.ExpiresAt < DateTime.UtcNow)
|
||||
if (storedToken is null || storedToken.ExpiresAt < DateTimeOffset.UtcNow)
|
||||
{
|
||||
return Unauthorized(new { error = "Invalid or expired refresh token" });
|
||||
return this.ProblemResult(StatusCodes.Status401Unauthorized, "Invalid or expired refresh token");
|
||||
}
|
||||
|
||||
// Revoke the old token (rotation)
|
||||
storedToken.RevokedAt = DateTime.UtcNow;
|
||||
storedToken.RevokedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
// Generate new tokens
|
||||
var response = await GenerateTokenResponse(storedToken.User);
|
||||
@@ -402,7 +413,7 @@ public sealed class AuthController : ControllerBase
|
||||
|
||||
if (storedToken is not null)
|
||||
{
|
||||
storedToken.RevokedAt = DateTime.UtcNow;
|
||||
storedToken.RevokedAt = DateTimeOffset.UtcNow;
|
||||
await _usersContext.SaveChangesAsync();
|
||||
}
|
||||
|
||||
@@ -420,12 +431,12 @@ public sealed class AuthController : ControllerBase
|
||||
var user = await _usersContext.Users.AsNoTracking().FirstOrDefaultAsync();
|
||||
if (user is null)
|
||||
{
|
||||
return BadRequest(new { error = "Create an account first" });
|
||||
return this.ProblemResult(StatusCodes.Status400BadRequest, "Create an account first");
|
||||
}
|
||||
|
||||
if (user.SetupCompleted)
|
||||
{
|
||||
return Conflict(new { error = "Setup already completed. Use account settings to manage Plex." });
|
||||
return this.ProblemResult(StatusCodes.Status409Conflict, "Setup already completed. Use account settings to manage Plex.");
|
||||
}
|
||||
|
||||
var pin = await _plexAuthService.RequestPin();
|
||||
@@ -455,19 +466,19 @@ public sealed class AuthController : ControllerBase
|
||||
var user = await _usersContext.Users.FirstOrDefaultAsync();
|
||||
if (user is null)
|
||||
{
|
||||
return BadRequest(new { error = "Create an account first" });
|
||||
return this.ProblemResult(StatusCodes.Status400BadRequest, "Create an account first");
|
||||
}
|
||||
|
||||
if (user.SetupCompleted)
|
||||
{
|
||||
return Conflict(new { error = "Setup already completed. Use account settings to manage Plex." });
|
||||
return this.ProblemResult(StatusCodes.Status409Conflict, "Setup already completed. Use account settings to manage Plex.");
|
||||
}
|
||||
|
||||
user.PlexAccountId = plexAccount.AccountId;
|
||||
user.PlexUsername = plexAccount.Username;
|
||||
user.PlexEmail = plexAccount.Email;
|
||||
user.PlexAuthToken = pinResult.AuthToken;
|
||||
user.UpdatedAt = DateTime.UtcNow;
|
||||
user.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Plex account linked during setup for user {Username}: {PlexUsername}",
|
||||
@@ -486,16 +497,26 @@ public sealed class AuthController : ControllerBase
|
||||
{
|
||||
if (await IsOidcExclusiveModeActive())
|
||||
{
|
||||
return StatusCode(403, new { error = "Plex login is disabled. Use OIDC to sign in." });
|
||||
return this.ProblemResult(StatusCodes.Status403Forbidden, "Plex login is disabled. Use OIDC to sign in.");
|
||||
}
|
||||
|
||||
var user = await _usersContext.Users.AsNoTracking().FirstOrDefaultAsync();
|
||||
if (user is null || !user.SetupCompleted || user.PlexAccountId is null)
|
||||
{
|
||||
return BadRequest(new { error = "Plex login is not available" });
|
||||
return this.ProblemResult(StatusCodes.Status400BadRequest, "Plex login is not available");
|
||||
}
|
||||
|
||||
var pin = await _plexAuthService.RequestPin();
|
||||
string baseUrl = HttpContext.GetExternalBaseUrl();
|
||||
if (_environment.IsDevelopment())
|
||||
{
|
||||
string origin = Request.Headers.Origin.ToString();
|
||||
if (!string.IsNullOrEmpty(origin))
|
||||
{
|
||||
baseUrl = $"{origin}{Request.GetSafeBasePath()}";
|
||||
}
|
||||
}
|
||||
string forwardUrl = $"{baseUrl}/auth/plex/callback";
|
||||
PlexPinResult pin = await _plexAuthService.RequestPin(forwardUrl);
|
||||
|
||||
return Ok(new PlexPinStatusResponse
|
||||
{
|
||||
@@ -509,13 +530,13 @@ public sealed class AuthController : ControllerBase
|
||||
{
|
||||
if (await IsOidcExclusiveModeActive())
|
||||
{
|
||||
return StatusCode(403, new { error = "Plex login is disabled. Use OIDC to sign in." });
|
||||
return this.ProblemResult(StatusCodes.Status403Forbidden, "Plex login is disabled. Use OIDC to sign in.");
|
||||
}
|
||||
|
||||
var user = await _usersContext.Users.FirstOrDefaultAsync();
|
||||
if (user is null || !user.SetupCompleted || user.PlexAccountId is null)
|
||||
{
|
||||
return BadRequest(new { error = "Plex login is not available" });
|
||||
return this.ProblemResult(StatusCodes.Status400BadRequest, "Plex login is not available");
|
||||
}
|
||||
|
||||
var pinResult = await _plexAuthService.CheckPin(request.PinId);
|
||||
@@ -530,7 +551,7 @@ public sealed class AuthController : ControllerBase
|
||||
|
||||
if (plexAccount.AccountId != user.PlexAccountId)
|
||||
{
|
||||
return Unauthorized(new { error = "Plex account does not match the linked account" });
|
||||
return this.ProblemResult(StatusCodes.Status401Unauthorized, "Plex account does not match the linked account");
|
||||
}
|
||||
|
||||
// Plex OAuth acts as a trusted identity provider — the user explicitly linked their
|
||||
@@ -558,7 +579,7 @@ public sealed class AuthController : ControllerBase
|
||||
string.IsNullOrEmpty(oidcConfig.IssuerUrl) ||
|
||||
string.IsNullOrEmpty(oidcConfig.ClientId))
|
||||
{
|
||||
return BadRequest(new { error = "OIDC is not enabled or not configured" });
|
||||
return this.ProblemResult(StatusCodes.Status400BadRequest, "OIDC is not enabled or not configured");
|
||||
}
|
||||
|
||||
var redirectUri = GetOidcCallbackUrl(oidcConfig.RedirectUrl);
|
||||
@@ -571,8 +592,7 @@ public sealed class AuthController : ControllerBase
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to start OIDC authorization");
|
||||
return StatusCode(429, new { error = ex.Message });
|
||||
throw new RateLimitException(ex.Message, ex);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -642,7 +662,7 @@ public sealed class AuthController : ControllerBase
|
||||
|
||||
if (result is null)
|
||||
{
|
||||
return NotFound(new { error = "Invalid or expired code" });
|
||||
return this.ProblemResult(StatusCodes.Status404NotFound, "Invalid or expired code");
|
||||
}
|
||||
|
||||
return Ok(new TokenResponse
|
||||
@@ -671,8 +691,8 @@ public sealed class AuthController : ControllerBase
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = user.Id,
|
||||
TokenHash = HashRefreshToken(refreshToken),
|
||||
ExpiresAt = DateTime.UtcNow.AddDays(7),
|
||||
CreatedAt = DateTime.UtcNow
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddDays(7),
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
|
||||
await _usersContext.SaveChangesAsync();
|
||||
@@ -685,67 +705,30 @@ public sealed class AuthController : ControllerBase
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Claims an unused recovery code.
|
||||
/// Call this while holding <see cref="UsersContext.Lock"/>.
|
||||
/// </summary>
|
||||
private async Task<bool> TryUseRecoveryCode(User user, string code)
|
||||
{
|
||||
await UsersContext.Lock.WaitAsync();
|
||||
try
|
||||
List<RecoveryCode> unusedCodes = await _usersContext.RecoveryCodes
|
||||
.Where(r => r.UserId == user.Id && !r.IsUsed)
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var recoveryCode in unusedCodes)
|
||||
{
|
||||
foreach (var recoveryCode in user.RecoveryCodes.Where(r => !r.IsUsed))
|
||||
if (_totpService.VerifyRecoveryCode(code, recoveryCode.CodeHash))
|
||||
{
|
||||
if (_totpService.VerifyRecoveryCode(code, recoveryCode.CodeHash))
|
||||
{
|
||||
recoveryCode.IsUsed = true;
|
||||
recoveryCode.UsedAt = DateTime.UtcNow;
|
||||
await _usersContext.SaveChangesAsync();
|
||||
recoveryCode.IsUsed = true;
|
||||
recoveryCode.UsedAt = DateTimeOffset.UtcNow;
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogWarning("Recovery code used for user {Username}", user.Username);
|
||||
return true;
|
||||
}
|
||||
_logger.LogWarning("Recovery code used for user {Username}", user.Username);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
UsersContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<int> IncrementFailedAttempts(Guid userId)
|
||||
{
|
||||
await UsersContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var user = await _usersContext.Users.FirstAsync(u => u.Id == userId);
|
||||
user.FailedLoginAttempts++;
|
||||
user.LockoutEnd = DateTime.UtcNow.AddSeconds(user.FailedLoginAttempts * 2);
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogWarning("Failed login attempt {Attempts} for user {Username}, locked for {Seconds}s",
|
||||
user.FailedLoginAttempts, user.Username, user.FailedLoginAttempts * 2);
|
||||
|
||||
return user.FailedLoginAttempts * 2;
|
||||
}
|
||||
finally
|
||||
{
|
||||
UsersContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ResetFailedAttempts(Guid userId)
|
||||
{
|
||||
await UsersContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var user = await _usersContext.Users.FirstAsync(u => u.Id == userId);
|
||||
user.FailedLoginAttempts = 0;
|
||||
user.LockoutEnd = null;
|
||||
await _usersContext.SaveChangesAsync();
|
||||
}
|
||||
finally
|
||||
{
|
||||
UsersContext.Lock.Release();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string GenerateApiKey()
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Auth;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Counts failed credential attempts and locks the account for a growing window.
|
||||
/// </summary>
|
||||
public sealed class LoginAttemptTracker
|
||||
{
|
||||
private const int MaxLockoutSeconds = 300;
|
||||
|
||||
private readonly UsersContext _usersContext;
|
||||
private readonly ILogger<LoginAttemptTracker> _logger;
|
||||
|
||||
public LoginAttemptTracker(UsersContext usersContext, ILogger<LoginAttemptTracker> logger)
|
||||
{
|
||||
_usersContext = usersContext;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the seconds left on the lockout, or null when the account is not locked.
|
||||
/// </summary>
|
||||
public static int? GetLockoutSecondsRemaining(User user)
|
||||
{
|
||||
if (user.LockoutEnd is null || user.LockoutEnd.Value <= DateTimeOffset.UtcNow)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return (int)Math.Ceiling((user.LockoutEnd.Value - DateTimeOffset.UtcNow).TotalSeconds);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records one failed attempt and returns the new lockout length in seconds.
|
||||
/// The window grows by two seconds per attempt, up to five minutes.
|
||||
/// Call this while holding <see cref="UsersContext.Lock"/>.
|
||||
/// </summary>
|
||||
public async Task<int> IncrementFailedAttempts(Guid userId)
|
||||
{
|
||||
User user = await _usersContext.Users.FirstAsync(u => u.Id == userId);
|
||||
user.FailedLoginAttempts++;
|
||||
|
||||
int lockoutSeconds = Math.Min(user.FailedLoginAttempts * 2, MaxLockoutSeconds);
|
||||
user.LockoutEnd = DateTimeOffset.UtcNow.AddSeconds(lockoutSeconds);
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogWarning("Failed login attempt {Attempts} for user {Username}, locked for {Seconds}s",
|
||||
user.FailedLoginAttempts, user.Username, lockoutSeconds);
|
||||
|
||||
return lockoutSeconds;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears the counter and the lockout once every factor has been verified.
|
||||
/// Call this while holding <see cref="UsersContext.Lock"/>.
|
||||
/// </summary>
|
||||
public async Task ResetFailedAttempts(Guid userId)
|
||||
{
|
||||
User user = await _usersContext.Users.FirstAsync(u => u.Id == userId);
|
||||
|
||||
if (user.FailedLoginAttempts is 0 && user.LockoutEnd is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
user.FailedLoginAttempts = 0;
|
||||
user.LockoutEnd = null;
|
||||
await _usersContext.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using Cleanuparr.Api.Features.Auth.Contracts.Responses;
|
||||
using Cleanuparr.Infrastructure.Features.Auth;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Auth;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Auth;
|
||||
|
||||
internal static class TwoFactorSecretRotation
|
||||
{
|
||||
/// <summary>
|
||||
/// Replaces the user's TOTP secret and recovery codes.
|
||||
/// </summary>
|
||||
internal static TotpSetupResponse Rotate(ITotpService totpService, UsersContext usersContext, User user)
|
||||
{
|
||||
string secret = totpService.GenerateSecret();
|
||||
string qrUri = totpService.GetQrCodeUri(secret, user.Username);
|
||||
List<string> recoveryCodes = totpService.GenerateRecoveryCodes();
|
||||
|
||||
user.TotpSecret = secret;
|
||||
user.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
usersContext.RecoveryCodes.RemoveRange(user.RecoveryCodes);
|
||||
|
||||
foreach (string code in recoveryCodes)
|
||||
{
|
||||
usersContext.RecoveryCodes.Add(new RecoveryCode
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = user.Id,
|
||||
CodeHash = totpService.HashRecoveryCode(code),
|
||||
IsUsed = false
|
||||
});
|
||||
}
|
||||
|
||||
return new TotpSetupResponse
|
||||
{
|
||||
Secret = secret,
|
||||
QrCodeUri = qrUri,
|
||||
RecoveryCodes = recoveryCodes
|
||||
};
|
||||
}
|
||||
}
|
||||
-5
@@ -89,11 +89,6 @@ public sealed class BlacklistSyncConfigController : ControllerBase
|
||||
|
||||
return Ok(new { Message = "BlacklistSynchronizer configuration updated successfully" });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to save BlacklistSync configuration");
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
namespace Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests;
|
||||
|
||||
public sealed record DeadTorrentConfigRequest
|
||||
{
|
||||
public bool Enabled { get; init; }
|
||||
|
||||
public string TargetCategory { get; init; } = "cleanuparr-dead";
|
||||
|
||||
public bool UseTag { get; init; }
|
||||
|
||||
public ushort MaxStrikes { get; init; }
|
||||
|
||||
public List<string> Categories { get; init; } = [];
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests;
|
||||
|
||||
public sealed record OrphanedFilesConfigRequest
|
||||
{
|
||||
public bool Enabled { get; init; }
|
||||
|
||||
public List<string> ScanDirectories { get; init; } = [];
|
||||
|
||||
[Required]
|
||||
public string OrphanedDirectory { get; init; } = string.Empty;
|
||||
|
||||
public List<string> ExcludePatterns { get; init; } = [];
|
||||
|
||||
[Range(0, int.MaxValue)]
|
||||
public int MinFileAgeHours { get; init; } = 24;
|
||||
|
||||
[Range(1, int.MaxValue)]
|
||||
public int? PurgeAfterHours { get; init; }
|
||||
}
|
||||
Loaded 100 of 1294 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user