mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2026-09-09 11:59:02 -04:00
Compare commits
227
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad8c5f23cf | ||
|
|
9eebeed990 | ||
|
|
a79a60a339 | ||
|
|
d1bd9fddcc | ||
|
|
96823adcc3 | ||
|
|
e0e88147aa | ||
|
|
f278a0dad0 | ||
|
|
f61300b869 | ||
|
|
561c05778c | ||
|
|
60d273991d | ||
|
|
ddb1042ca5 | ||
|
|
9a31e86ad8 | ||
|
|
614e97313e | ||
|
|
a34a3d3c7e | ||
|
|
f9588d89c0 | ||
|
|
eacd9346a5 | ||
|
|
0561c64ddf | ||
|
|
304a8e78ee | ||
|
|
e008b64a1d | ||
|
|
4f7e2d33b4 | ||
|
|
b1b19e5f29 | ||
|
|
40ab0e9fad | ||
|
|
fa1801875e | ||
|
|
c6ef6ad979 | ||
|
|
74f11f5beb | ||
|
|
c0950537ab | ||
|
|
7cc079c61b | ||
|
|
7aa3224f4d | ||
|
|
1cc068c2ab | ||
|
|
28f22f1085 | ||
|
|
084f83efca | ||
|
|
26b76908eb | ||
|
|
ffc8a0a39a | ||
|
|
8ccd93dc97 | ||
|
|
1ca935b62b | ||
|
|
90a4909e57 | ||
|
|
ef8fb2dd0b | ||
|
|
20ad056400 | ||
|
|
ab792f5fad | ||
|
|
48c36fab8f | ||
|
|
3553fce597 | ||
|
|
c1d2790c8d | ||
|
|
18a9c66ce7 | ||
|
|
7ec60c7ea0 | ||
|
|
a96bc36a1e | ||
|
|
b575644d5f | ||
|
|
13bc71c3cd | ||
|
|
c3f3ee880d | ||
|
|
8ab4a55595 | ||
|
|
85de80a463 | ||
|
|
9f48d3565a | ||
|
|
db2e3e71db | ||
|
|
5d400ad854 | ||
|
|
02a07d4fa3 | ||
|
|
41ca55d615 | ||
|
|
8770a8b18e | ||
|
|
24ecd88cd0 | ||
|
|
7ed0f307be | ||
|
|
5b500c533e | ||
|
|
b1ef63ef43 | ||
|
|
f14e345b52 | ||
|
|
89a0d1281f | ||
|
|
ee5e7c0819 | ||
|
|
d875d88191 | ||
|
|
447db6990a | ||
|
|
4e9d20db0a | ||
|
|
53fc5eff3b | ||
|
|
69fa09e23a | ||
|
|
3360b7a849 | ||
|
|
80b46df8e5 | ||
|
|
88aa71c343 | ||
|
|
7b80e038cc | ||
|
|
ef280ec398 | ||
|
|
81f6de03e7 | ||
|
|
17c3a6b02a | ||
|
|
4903b3137b | ||
|
|
868406c95c | ||
|
|
7122b16a7a | ||
|
|
b9fbac4ddc | ||
|
|
33e948d1e7 | ||
|
|
9f551f151e | ||
|
|
9447cb37c0 | ||
|
|
8183b324a0 | ||
|
|
a6a25de19c | ||
|
|
51a2a1b391 | ||
|
|
d7ab81ddcf | ||
|
|
8da07d4e93 | ||
|
|
20b93f6853 | ||
|
|
2ce204c1bc | ||
|
|
d653b7fa3f | ||
|
|
0dcb42efa2 | ||
|
|
820d254553 | ||
|
|
715ef5711b | ||
|
|
ea3244367e | ||
|
|
f26768bcf7 | ||
|
|
87bb92fac0 | ||
|
|
01dc90bfa7 | ||
|
|
c37e6384a5 | ||
|
|
70fc955d37 | ||
|
|
a44f226e8a | ||
|
|
edafde5810 | ||
|
|
54cd037cd2 | ||
|
|
2ebf67d44d | ||
|
|
d2d294b93c | ||
|
|
39eb91ac48 | ||
|
|
53376d94d9 | ||
|
|
f51973bb7b | ||
|
|
33d1756fdd | ||
|
|
63931763c4 | ||
|
|
bdb956ec84 | ||
|
|
41b48d1104 | ||
|
|
57fef26726 | ||
|
|
ea94dc4548 | ||
|
|
13a7232bc5 | ||
|
|
5fea8a0041 | ||
|
|
2333c86a08 | ||
|
|
cbfc1b2875 | ||
|
|
62e10afe7b | ||
|
|
d542c716f9 | ||
|
|
7eeaefaa65 | ||
|
|
bd55356881 | ||
|
|
df986a2e36 | ||
|
|
8a2aca79f7 | ||
|
|
e1cc0dba28 | ||
|
|
660dce0fa7 | ||
|
|
ee343ba469 | ||
|
|
d038738008 | ||
|
|
70f9995041 | ||
|
|
6a1aaec7c2 | ||
|
|
50e486bf5f | ||
|
|
bc15a9a934 | ||
|
|
573dbcf882 | ||
|
|
94acd9afa4 | ||
|
|
65d25a72a9 | ||
|
|
97eb2fce44 | ||
|
|
701829001c | ||
|
|
8aeeca111c | ||
|
|
c43936ce81 | ||
|
|
f35eb0c922 | ||
|
|
b2b0626b44 | ||
|
|
40f108d7ca | ||
|
|
6570f74b7e | ||
|
|
16f216cf84 | ||
|
|
69551edeff | ||
|
|
7192796e89 | ||
|
|
1d1ee7972f | ||
|
|
8bd6b86018 | ||
|
|
6abb542271 | ||
|
|
2aceae3078 | ||
|
|
65b200a68e | ||
|
|
de0c881944 | ||
|
|
d0ef01d79b | ||
|
|
9457236e99 | ||
|
|
d43b4fc1c4 | ||
|
|
e9750429eb | ||
|
|
b71b268b08 | ||
|
|
a708d22b27 | ||
|
|
a9a3b08ad6 | ||
|
|
1d1e8679e4 | ||
|
|
142d445ed0 | ||
|
|
375094862c | ||
|
|
58a72cef0f | ||
|
|
4ceff127a7 | ||
|
|
c07b811cf8 | ||
|
|
b16fa70774 | ||
|
|
b343165644 | ||
|
|
02dff0bb9b | ||
|
|
ac3be75082 | ||
|
|
a1663b865a | ||
|
|
c97a416d1e | ||
|
|
d28ab42303 | ||
|
|
fbb2bba3b6 | ||
|
|
08eda22587 | ||
|
|
a4045eebd3 | ||
|
|
a57cbccbb4 | ||
|
|
2221f118bb | ||
|
|
2cc3eb4ebb | ||
|
|
3a064a22bd | ||
|
|
ee764ff215 | ||
|
|
402677b69b | ||
|
|
97f63434fd | ||
|
|
07d0cf07e3 | ||
|
|
4be107439a | ||
|
|
89ef03a859 | ||
|
|
905384034d | ||
|
|
bf826da1ae | ||
|
|
6aac35181b | ||
|
|
efbf60dcdd | ||
|
|
ebb166a7b9 | ||
|
|
7aced28262 | ||
|
|
ae3e793498 | ||
|
|
4eb98b18a1 | ||
|
|
128e7e5f11 | ||
|
|
d224b2dea0 | ||
|
|
16e823b8d3 | ||
|
|
f2f11e3472 | ||
|
|
a3549c80a9 | ||
|
|
2b9c347ed6 | ||
|
|
98ccee866d | ||
|
|
911849c6dd | ||
|
|
cce3bb2c4a | ||
|
|
bcc117cd0d | ||
|
|
8e20a68ae2 | ||
|
|
736c146f25 | ||
|
|
6398ef1cc6 | ||
|
|
83e6a289be | ||
|
|
5662118b01 | ||
|
|
22dfc7b40d | ||
|
|
a51e387453 | ||
|
|
c7d2ec7311 | ||
|
|
bb9ac5b67b | ||
|
|
f93494adb2 | ||
|
|
7201520411 | ||
|
|
2a1e65e1af | ||
|
|
da318c3339 | ||
|
|
7149b6243f | ||
|
|
11f5a28c04 | ||
|
|
9cc36c7a50 | ||
|
|
861c135cc6 | ||
|
|
3b0275c411 | ||
|
|
cad1b51202 | ||
|
|
f50acd29f4 | ||
|
|
af11d595d8 | ||
|
|
44994d5b21 | ||
|
|
592fd2d846 | ||
|
|
e96be1fca2 | ||
|
|
ee44e2b5ac |
No files matched your search
@@ -7,6 +7,13 @@ body:
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to improve Cleanuparr!
|
||||
- type: checkboxes
|
||||
id: duplicate-check
|
||||
attributes:
|
||||
label: "Duplicate check"
|
||||
options:
|
||||
- label: I have searched for existing issues and confirmed this is not a duplicate.
|
||||
required: true
|
||||
- type: checkboxes
|
||||
id: init
|
||||
attributes:
|
||||
|
||||
@@ -7,6 +7,25 @@ body:
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to improve Cleanuparr!
|
||||
- type: checkboxes
|
||||
id: duplicate-check
|
||||
attributes:
|
||||
label: "Duplicate check"
|
||||
options:
|
||||
- label: I have searched for existing issues and confirmed this is not a duplicate.
|
||||
required: true
|
||||
- type: checkboxes
|
||||
id: init
|
||||
attributes:
|
||||
label: Implementation & testing support
|
||||
description: The requester should help answer questions, provide support for the implementation and test changes.
|
||||
options:
|
||||
- label: I understand I must be available to assist with implementation questions and to test the feature before being released.
|
||||
required: true
|
||||
- label: I understand that joining the Discord server may be necessary for better coordination and faster communication.
|
||||
required: true
|
||||
- label: I understand that failure to assist in the development process of my request will result in the request being closed.
|
||||
required: true
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
|
||||
@@ -7,6 +7,13 @@ body:
|
||||
attributes:
|
||||
value: |
|
||||
If you are experiencing unexpected behavior, please consider submitting a bug report instead.
|
||||
- type: checkboxes
|
||||
id: duplicate-check
|
||||
attributes:
|
||||
label: "Duplicate check"
|
||||
options:
|
||||
- label: I have searched for existing issues and confirmed this is not a duplicate.
|
||||
required: true
|
||||
- type: checkboxes
|
||||
id: init
|
||||
attributes:
|
||||
|
||||
@@ -1,2 +1,8 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links: []
|
||||
contact_links:
|
||||
- name: Discord Community
|
||||
url: https://discord.gg/SCtMCgtsc4
|
||||
about: Join our Discord for real-time help and discussions
|
||||
- name: Documentation
|
||||
url: https://cleanuparr.github.io/Cleanuparr/
|
||||
about: Check the documentation for configurations and usage guidelines
|
||||
@@ -0,0 +1,24 @@
|
||||
## Description
|
||||
<!-- Brief description of what this PR does -->
|
||||
|
||||
## Related Issue
|
||||
Closes #ISSUE_NUMBER
|
||||
|
||||
## Type of Change
|
||||
- [ ] Bug fix
|
||||
- [ ] New feature
|
||||
- [ ] Breaking change
|
||||
- [ ] Documentation update
|
||||
|
||||
## Testing
|
||||
<!-- Describe how you tested your changes -->
|
||||
|
||||
## Screenshots (if applicable)
|
||||
<!-- Add screenshots here -->
|
||||
|
||||
## Checklist
|
||||
- [ ] I have read the [Contributing Guide](../CONTRIBUTING.md)
|
||||
- [ ] I have announced my intent to work on this and received approval
|
||||
- [ ] My code follows the project's code standards
|
||||
- [ ] I have tested my changes thoroughly
|
||||
- [ ] I have updated relevant documentation
|
||||
@@ -0,0 +1,30 @@
|
||||
name: 'Get Vault Secrets'
|
||||
description: 'Retrieves secrets from HashiCorp Vault using AppRole authentication'
|
||||
inputs:
|
||||
vault_host:
|
||||
description: 'Vault server URL'
|
||||
required: true
|
||||
vault_role_id:
|
||||
description: 'Vault AppRole Role ID'
|
||||
required: true
|
||||
vault_secret_id:
|
||||
description: 'Vault AppRole Secret ID'
|
||||
required: true
|
||||
secrets:
|
||||
description: 'Secrets to retrieve (multiline string, one per line in format: path | output_name)'
|
||||
required: true
|
||||
default: |
|
||||
secrets/data/github repo_readonly_pat | REPO_READONLY_PAT
|
||||
secrets/data/github packages_pat | PACKAGES_PAT
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Get vault secrets
|
||||
uses: hashicorp/vault-action@v2
|
||||
with:
|
||||
url: ${{ inputs.vault_host }}
|
||||
method: approle
|
||||
roleId: ${{ inputs.vault_role_id }}
|
||||
secretId: ${{ inputs.vault_secret_id }}
|
||||
secrets: ${{ inputs.secrets }}
|
||||
@@ -1,54 +1,87 @@
|
||||
name: Build Docker Images
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
pull_request:
|
||||
paths:
|
||||
- 'code/**'
|
||||
workflow_dispatch:
|
||||
workflow_call:
|
||||
inputs:
|
||||
push_docker:
|
||||
description: 'Push Docker image to registry'
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
app_version:
|
||||
description: 'Application version'
|
||||
type: string
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
# Cancel in-progress runs for the same PR
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
env:
|
||||
REGISTRY_IMAGE: ghcr.io/cleanuparr/cleanuparr
|
||||
|
||||
jobs:
|
||||
build_app:
|
||||
# Compute tags, version, and push decision for downstream jobs
|
||||
prepare:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
||||
outputs:
|
||||
tags: ${{ steps.build-info.outputs.tags }}
|
||||
version: ${{ steps.build-info.outputs.version }}
|
||||
version_docker_tag: ${{ steps.build-info.outputs.version_docker_tag }}
|
||||
branch: ${{ steps.build-info.outputs.branch }}
|
||||
push: ${{ steps.build-info.outputs.push }}
|
||||
github_sha: ${{ github.sha }}
|
||||
steps:
|
||||
|
||||
- name: Set github context
|
||||
timeout-minutes: 1
|
||||
run: |
|
||||
echo 'githubRepository=${{ github.repository }}' >> $GITHUB_ENV
|
||||
echo 'githubSha=${{ github.sha }}' >> $GITHUB_ENV
|
||||
echo 'githubRef=${{ github.ref }}' >> $GITHUB_ENV
|
||||
echo 'githubHeadRef=${{ github.head_ref }}' >> $GITHUB_ENV
|
||||
|
||||
- name: Initialize build info
|
||||
id: build-info
|
||||
timeout-minutes: 1
|
||||
run: |
|
||||
githubHeadRef=${{ env.githubHeadRef }}
|
||||
githubHeadRef="${{ github.head_ref }}"
|
||||
githubRef="${{ github.ref }}"
|
||||
inputVersion="${{ inputs.app_version }}"
|
||||
latestDockerTag=""
|
||||
versionDockerTag=""
|
||||
majorVersionDockerTag=""
|
||||
minorVersionDockerTag=""
|
||||
version="0.0.1"
|
||||
|
||||
if [[ "$githubRef" =~ ^"refs/tags/" ]]; then
|
||||
if [[ -n "$inputVersion" ]]; then
|
||||
# Version provided via input (manual release)
|
||||
branch="main"
|
||||
latestDockerTag="latest"
|
||||
versionDockerTag="$inputVersion"
|
||||
version="$inputVersion"
|
||||
|
||||
# Extract major and minor versions for additional tags
|
||||
if [[ "$versionDockerTag" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+) ]]; then
|
||||
majorVersionDockerTag="${BASH_REMATCH[1]}"
|
||||
minorVersionDockerTag="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}"
|
||||
fi
|
||||
elif [[ "$githubRef" =~ ^"refs/tags/" ]]; then
|
||||
# Tag push
|
||||
branch=${githubRef##*/}
|
||||
latestDockerTag="latest"
|
||||
versionDockerTag=${branch#v}
|
||||
version=${branch#v}
|
||||
|
||||
|
||||
# Extract major and minor versions for additional tags
|
||||
if [[ "$versionDockerTag" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+) ]]; then
|
||||
majorVersionDockerTag="${BASH_REMATCH[1]}"
|
||||
minorVersionDockerTag="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}"
|
||||
fi
|
||||
else
|
||||
# Determine if this run is for the main branch or another branch
|
||||
if [[ -z "$githubHeadRef" ]]; then
|
||||
# Main branch
|
||||
githubRef=${{ env.githubRef }}
|
||||
branch=${githubRef##*/}
|
||||
versionDockerTag="$branch"
|
||||
else
|
||||
@@ -59,25 +92,45 @@ jobs:
|
||||
fi
|
||||
|
||||
githubTags=""
|
||||
|
||||
if [ -n "$latestDockerTag" ]; then
|
||||
githubTags="$githubTags,ghcr.io/cleanuparr/cleanuparr:$latestDockerTag"
|
||||
githubTags="$githubTags,$REGISTRY_IMAGE:$latestDockerTag"
|
||||
fi
|
||||
if [ -n "$versionDockerTag" ]; then
|
||||
githubTags="$githubTags,ghcr.io/cleanuparr/cleanuparr:$versionDockerTag"
|
||||
githubTags="$githubTags,$REGISTRY_IMAGE:$versionDockerTag"
|
||||
fi
|
||||
if [ -n "$minorVersionDockerTag" ]; then
|
||||
githubTags="$githubTags,ghcr.io/cleanuparr/cleanuparr:$minorVersionDockerTag"
|
||||
githubTags="$githubTags,$REGISTRY_IMAGE:$minorVersionDockerTag"
|
||||
fi
|
||||
if [ -n "$majorVersionDockerTag" ]; then
|
||||
githubTags="$githubTags,ghcr.io/cleanuparr/cleanuparr:$majorVersionDockerTag"
|
||||
githubTags="$githubTags,$REGISTRY_IMAGE:$majorVersionDockerTag"
|
||||
fi
|
||||
|
||||
# set env vars
|
||||
echo "branch=$branch" >> $GITHUB_ENV
|
||||
echo "githubTags=$githubTags" >> $GITHUB_ENV
|
||||
echo "versionDockerTag=$versionDockerTag" >> $GITHUB_ENV
|
||||
echo "version=$version" >> $GITHUB_ENV
|
||||
githubTags="${githubTags#,}"
|
||||
|
||||
# Determine push decision
|
||||
push="${{ github.event_name == 'pull_request' || inputs.push_docker == true }}"
|
||||
|
||||
echo "tags=$githubTags" >> $GITHUB_OUTPUT
|
||||
echo "version=$version" >> $GITHUB_OUTPUT
|
||||
echo "version_docker_tag=$versionDockerTag" >> $GITHUB_OUTPUT
|
||||
echo "branch=$branch" >> $GITHUB_OUTPUT
|
||||
echo "push=$push" >> $GITHUB_OUTPUT
|
||||
|
||||
# Build each platform in parallel
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [prepare]
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
platform:
|
||||
- linux/amd64
|
||||
- linux/arm64
|
||||
steps:
|
||||
- name: Prepare platform pair
|
||||
run: |
|
||||
platform=${{ matrix.platform }}
|
||||
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
|
||||
|
||||
- name: Get vault secrets
|
||||
uses: hashicorp/vault-action@v2
|
||||
@@ -87,8 +140,6 @@ jobs:
|
||||
roleId: ${{ secrets.VAULT_ROLE_ID }}
|
||||
secretId: ${{ secrets.VAULT_SECRET_ID }}
|
||||
secrets:
|
||||
secrets/data/docker username | DOCKER_USERNAME;
|
||||
secrets/data/docker password | DOCKER_PASSWORD;
|
||||
secrets/data/github repo_readonly_pat | REPO_READONLY_PAT;
|
||||
secrets/data/github packages_pat | PACKAGES_PAT
|
||||
|
||||
@@ -96,16 +147,97 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
timeout-minutes: 1
|
||||
with:
|
||||
repository: ${{ env.githubRepository }}
|
||||
ref: ${{ env.branch }}
|
||||
repository: ${{ github.repository }}
|
||||
ref: ${{ needs.prepare.outputs.branch }}
|
||||
token: ${{ env.REPO_READONLY_PAT }}
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
timeout-minutes: 5
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Login to GitHub Container Registry
|
||||
if: needs.prepare.outputs.push == 'true'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push by digest
|
||||
if: needs.prepare.outputs.push == 'true'
|
||||
id: build-push
|
||||
timeout-minutes: 30
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ${{ github.workspace }}/code
|
||||
file: ${{ github.workspace }}/code/Dockerfile
|
||||
provenance: false
|
||||
labels: |
|
||||
commit=sha-${{ needs.prepare.outputs.github_sha }}
|
||||
version=${{ needs.prepare.outputs.version_docker_tag }}
|
||||
build-args: |
|
||||
VERSION=${{ needs.prepare.outputs.version }}
|
||||
PACKAGES_USERNAME=${{ secrets.PACKAGES_USERNAME }}
|
||||
PACKAGES_PAT=${{ env.PACKAGES_PAT }}
|
||||
platforms: ${{ matrix.platform }}
|
||||
outputs: type=image,"name=${{ env.REGISTRY_IMAGE }}",push-by-digest=true,name-canonical=true,push=true
|
||||
cache-from: type=gha,scope=build-${{ env.PLATFORM_PAIR }}
|
||||
cache-to: type=gha,scope=build-${{ env.PLATFORM_PAIR }},mode=max
|
||||
|
||||
- name: Build (no push)
|
||||
if: needs.prepare.outputs.push != 'true'
|
||||
timeout-minutes: 30
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ${{ github.workspace }}/code
|
||||
file: ${{ github.workspace }}/code/Dockerfile
|
||||
provenance: false
|
||||
labels: |
|
||||
commit=sha-${{ needs.prepare.outputs.github_sha }}
|
||||
version=${{ needs.prepare.outputs.version_docker_tag }}
|
||||
build-args: |
|
||||
VERSION=${{ needs.prepare.outputs.version }}
|
||||
PACKAGES_USERNAME=${{ secrets.PACKAGES_USERNAME }}
|
||||
PACKAGES_PAT=${{ env.PACKAGES_PAT }}
|
||||
platforms: ${{ matrix.platform }}
|
||||
push: false
|
||||
cache-from: type=gha,scope=build-${{ env.PLATFORM_PAIR }}
|
||||
cache-to: type=gha,scope=build-${{ env.PLATFORM_PAIR }},mode=max
|
||||
|
||||
- name: Export digest
|
||||
if: needs.prepare.outputs.push == 'true'
|
||||
run: |
|
||||
mkdir -p ${{ runner.temp }}/digests
|
||||
digest="${{ steps.build-push.outputs.digest }}"
|
||||
touch "${{ runner.temp }}/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
if: needs.prepare.outputs.push == 'true'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digests-${{ env.PLATFORM_PAIR }}
|
||||
path: ${{ runner.temp }}/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
# Create multi-platform manifest and push with final tags
|
||||
merge:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [prepare, build]
|
||||
if: needs.prepare.outputs.push == 'true'
|
||||
steps:
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: ${{ runner.temp }}/digests
|
||||
pattern: digests-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
@@ -114,25 +246,25 @@ jobs:
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push docker image
|
||||
timeout-minutes: 15
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ${{ github.workspace }}/code
|
||||
file: ${{ github.workspace }}/code/Dockerfile
|
||||
provenance: false
|
||||
labels: |
|
||||
commit=sha-${{ env.githubSha }}
|
||||
version=${{ env.versionDockerTag }}
|
||||
build-args: |
|
||||
VERSION=${{ env.version }}
|
||||
PACKAGES_USERNAME=${{ secrets.PACKAGES_USERNAME }}
|
||||
PACKAGES_PAT=${{ env.PACKAGES_PAT }}
|
||||
outputs: |
|
||||
type=image
|
||||
platforms: |
|
||||
linux/amd64
|
||||
linux/arm64
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.githubTags }}
|
||||
- name: Create manifest list and push
|
||||
timeout-minutes: 5
|
||||
working-directory: ${{ runner.temp }}/digests
|
||||
run: |
|
||||
tags="${{ needs.prepare.outputs.tags }}"
|
||||
tag_args=""
|
||||
IFS=',' read -ra TAG_ARRAY <<< "$tags"
|
||||
for tag in "${TAG_ARRAY[@]}"; do
|
||||
tag=$(echo "$tag" | xargs)
|
||||
if [ -n "$tag" ]; then
|
||||
tag_args="$tag_args -t $tag"
|
||||
fi
|
||||
done
|
||||
|
||||
docker buildx imagetools create $tag_args \
|
||||
$(printf '${{ env.REGISTRY_IMAGE }}@sha256:%s ' *)
|
||||
|
||||
- name: Inspect image
|
||||
run: |
|
||||
tags="${{ needs.prepare.outputs.tags }}"
|
||||
first_tag=$(echo "$tags" | tr ',' '\n' | grep -v '^$' | head -1 | xargs)
|
||||
docker buildx imagetools inspect "$first_tag"
|
||||
@@ -1,40 +1,55 @@
|
||||
name: Build Executables
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
workflow_dispatch:
|
||||
workflow_call:
|
||||
inputs:
|
||||
app_version:
|
||||
description: 'Application version'
|
||||
type: string
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
jobs:
|
||||
build:
|
||||
# Build for each platform in parallel using matrix strategy
|
||||
build-platform:
|
||||
runs-on: ubuntu-latest
|
||||
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
|
||||
|
||||
steps:
|
||||
|
||||
- name: Gate
|
||||
if: ${{ !startsWith(github.ref, 'refs/tags/') && github.event_name != 'workflow_dispatch' }}
|
||||
run: |
|
||||
echo "This workflow only runs on tag events or manual dispatch. Pipeline finished."
|
||||
exit 0
|
||||
|
||||
- name: Set variables
|
||||
run: |
|
||||
repoFullName=${{ github.repository }}
|
||||
ref=${{ github.ref }}
|
||||
|
||||
# Handle both tag events and manual dispatch
|
||||
if [[ "$ref" =~ ^refs/tags/ ]]; then
|
||||
|
||||
# Use input version if provided, otherwise determine from ref
|
||||
if [[ -n "${{ inputs.app_version }}" ]]; then
|
||||
appVersion="${{ inputs.app_version }}"
|
||||
releaseVersion="v$appVersion"
|
||||
elif [[ "$ref" =~ ^refs/tags/ ]]; then
|
||||
releaseVersion=${ref##refs/tags/}
|
||||
appVersion=${releaseVersion#v}
|
||||
else
|
||||
# For manual dispatch, use a default version
|
||||
releaseVersion="dev-$(date +%Y%m%d-%H%M%S)"
|
||||
appVersion="0.0.1-dev"
|
||||
fi
|
||||
|
||||
repoFullName=${{ github.repository }}
|
||||
repositoryName=${repoFullName#*/}
|
||||
|
||||
echo "githubRepository=${{ github.repository }}" >> $GITHUB_ENV
|
||||
echo "githubRepositoryName=${repoFullName#*/}" >> $GITHUB_ENV
|
||||
echo "githubRepositoryName=$repositoryName" >> $GITHUB_ENV
|
||||
echo "releaseVersion=$releaseVersion" >> $GITHUB_ENV
|
||||
echo "appVersion=$appVersion" >> $GITHUB_ENV
|
||||
echo "executableName=Cleanuparr.Api" >> $GITHUB_ENV
|
||||
@@ -58,27 +73,28 @@ jobs:
|
||||
ref: ${{ github.ref_name }}
|
||||
token: ${{ env.REPO_READONLY_PAT }}
|
||||
|
||||
- name: Setup Node.js for frontend build
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '18'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: code/frontend/package-lock.json
|
||||
|
||||
- name: Build frontend
|
||||
run: |
|
||||
cd code/frontend
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
- name: Setup dotnet
|
||||
uses: actions/setup-dotnet@v4
|
||||
uses: actions/setup-dotnet@v5
|
||||
with:
|
||||
dotnet-version: 9.0.x
|
||||
|
||||
dotnet-version: 10.0.200
|
||||
|
||||
- name: Cache NuGet packages
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.nuget/packages
|
||||
key: ${{ runner.os }}-nuget-${{ hashFiles('**/packages.lock.json', '**/*.csproj') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-nuget-
|
||||
|
||||
- name: Download frontend artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: frontend-dist
|
||||
path: code/frontend/dist/ui/browser
|
||||
|
||||
- name: Install dependencies and restore
|
||||
run: |
|
||||
dotnet nuget add source --username ${{ github.repository_owner }} --password ${{ secrets.PACKAGES_PAT }} --store-password-in-clear-text --name Cleanuparr https://nuget.pkg.github.com/Cleanuparr/index.json
|
||||
dotnet nuget add source --username ${{ github.repository_owner }} --password ${{ env.PACKAGES_PAT }} --store-password-in-clear-text --name Cleanuparr https://nuget.pkg.github.com/Cleanuparr/index.json
|
||||
dotnet restore code/backend/${{ env.executableName }}/${{ env.executableName }}.csproj
|
||||
|
||||
- name: Copy frontend to backend wwwroot
|
||||
@@ -86,52 +102,25 @@ jobs:
|
||||
mkdir -p code/backend/${{ env.executableName }}/wwwroot
|
||||
cp -r code/frontend/dist/ui/browser/* code/backend/${{ env.executableName }}/wwwroot/
|
||||
|
||||
- name: Build win-x64
|
||||
run: dotnet publish code/backend/${{ env.executableName }}/${{ env.executableName }}.csproj -c Release --runtime win-x64 --self-contained -o artifacts/${{ env.githubRepositoryName }}-${{ env.appVersion }}-win-amd64 /p:PublishSingleFile=true /p:Version=${{ env.appVersion }} /p:DebugSymbols=false
|
||||
- name: Build ${{ matrix.platform }}
|
||||
run: |
|
||||
dotnet publish code/backend/${{ env.executableName }}/${{ env.executableName }}.csproj \
|
||||
-c Release \
|
||||
--runtime ${{ matrix.runtime }} \
|
||||
--self-contained \
|
||||
-o artifacts/${{ env.githubRepositoryName }}-${{ env.appVersion }}-${{ matrix.platform }} \
|
||||
/p:PublishSingleFile=true \
|
||||
/p:Version=${{ env.appVersion }} \
|
||||
/p:DebugSymbols=false
|
||||
|
||||
- name: Build linux-x64
|
||||
run: dotnet publish code/backend/${{ env.executableName }}/${{ env.executableName }}.csproj -c Release --runtime linux-x64 --self-contained -o artifacts/${{ env.githubRepositoryName }}-${{ env.appVersion }}-linux-amd64 /p:PublishSingleFile=true /p:Version=${{ env.appVersion }} /p:DebugSymbols=false
|
||||
|
||||
- name: Build linux-arm64
|
||||
run: dotnet publish code/backend/${{ env.executableName }}/${{ env.executableName }}.csproj -c Release --runtime linux-arm64 --self-contained -o artifacts/${{ env.githubRepositoryName }}-${{ env.appVersion }}-linux-arm64 /p:PublishSingleFile=true /p:Version=${{ env.appVersion }} /p:DebugSymbols=false
|
||||
|
||||
- name: Build osx-x64
|
||||
run: dotnet publish code/backend/${{ env.executableName }}/${{ env.executableName }}.csproj -c Release --runtime osx-x64 --self-contained -o artifacts/${{ env.githubRepositoryName }}-${{ env.appVersion }}-osx-amd64 /p:PublishSingleFile=true /p:Version=${{ env.appVersion }} /p:DebugSymbols=false
|
||||
|
||||
- name: Build osx-arm64
|
||||
run: dotnet publish code/backend/${{ env.executableName }}/${{ env.executableName }}.csproj -c Release --runtime osx-arm64 --self-contained -o artifacts/${{ env.githubRepositoryName }}-${{ env.appVersion }}-osx-arm64 /p:PublishSingleFile=true /p:Version=${{ env.appVersion }} /p:DebugSymbols=false
|
||||
|
||||
- name: Zip win-x64
|
||||
- name: Zip artifact
|
||||
run: |
|
||||
cd ./artifacts
|
||||
zip -r ./${{ env.githubRepositoryName }}-${{ env.appVersion }}-win-amd64.zip ./${{ env.githubRepositoryName }}-${{ env.appVersion }}-win-amd64/
|
||||
zip -r ./${{ env.githubRepositoryName }}-${{ env.appVersion }}-${{ matrix.platform }}.zip ./${{ env.githubRepositoryName }}-${{ env.appVersion }}-${{ matrix.platform }}/
|
||||
|
||||
- name: Zip linux-x64
|
||||
run: |
|
||||
cd ./artifacts
|
||||
zip -r ./${{ env.githubRepositoryName }}-${{ env.appVersion }}-linux-amd64.zip ./${{ env.githubRepositoryName }}-${{ env.appVersion }}-linux-amd64/
|
||||
|
||||
- name: Zip linux-arm64
|
||||
run: |
|
||||
cd ./artifacts
|
||||
zip -r ./${{ env.githubRepositoryName }}-${{ env.appVersion }}-linux-arm64.zip ./${{ env.githubRepositoryName }}-${{ env.appVersion }}-linux-arm64/
|
||||
|
||||
- name: Zip osx-x64
|
||||
run: |
|
||||
cd ./artifacts
|
||||
zip -r ./${{ env.githubRepositoryName }}-${{ env.appVersion }}-osx-amd64.zip ./${{ env.githubRepositoryName }}-${{ env.appVersion }}-osx-amd64/
|
||||
|
||||
- name: Zip osx-arm64
|
||||
run: |
|
||||
cd ./artifacts
|
||||
zip -r ./${{ env.githubRepositoryName }}-${{ env.appVersion }}-osx-arm64.zip ./${{ env.githubRepositoryName }}-${{ env.appVersion }}-osx-arm64/
|
||||
|
||||
- name: Upload artifacts
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: cleanuparr-executables
|
||||
path: |
|
||||
./artifacts/*.zip
|
||||
name: executable-${{ matrix.platform }}
|
||||
path: ./artifacts/*.zip
|
||||
retention-days: 30
|
||||
|
||||
# Removed individual release step - handled by main release workflow
|
||||
@@ -0,0 +1,52 @@
|
||||
name: Build Frontend
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
ref:
|
||||
description: 'Git ref to checkout (branch, tag, or SHA). Defaults to github.ref_name.'
|
||||
type: string
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
jobs:
|
||||
build-frontend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Get vault secrets
|
||||
uses: hashicorp/vault-action@v2
|
||||
with:
|
||||
url: ${{ secrets.VAULT_HOST }}
|
||||
method: approle
|
||||
roleId: ${{ secrets.VAULT_ROLE_ID }}
|
||||
secretId: ${{ secrets.VAULT_SECRET_ID }}
|
||||
secrets:
|
||||
secrets/data/github repo_readonly_pat | REPO_READONLY_PAT
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
timeout-minutes: 1
|
||||
with:
|
||||
repository: ${{ github.repository }}
|
||||
ref: ${{ inputs.ref || github.ref_name }}
|
||||
token: ${{ env.REPO_READONLY_PAT }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: code/frontend/package-lock.json
|
||||
|
||||
- name: Build frontend
|
||||
run: |
|
||||
cd code/frontend
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
- name: Upload frontend artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: frontend-dist
|
||||
path: code/frontend/dist/ui/browser
|
||||
retention-days: 1
|
||||
+87
-77
@@ -1,28 +1,47 @@
|
||||
name: Build macOS ARM Installer
|
||||
name: Build macOS Installers
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
workflow_dispatch:
|
||||
workflow_call:
|
||||
inputs:
|
||||
app_version:
|
||||
description: 'Application version'
|
||||
type: string
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
jobs:
|
||||
build-macos-arm-installer:
|
||||
name: Build macOS ARM Installer
|
||||
runs-on: macos-14 # ARM runner for Apple Silicon
|
||||
|
||||
build-macos-installer:
|
||||
name: Build macOS ${{ matrix.arch }} Installer
|
||||
runs-on: ${{ matrix.runner }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- arch: Intel
|
||||
runner: macos-15-intel
|
||||
runtime: osx-x64
|
||||
min_os_version: "10.15"
|
||||
artifact_suffix: intel
|
||||
- arch: ARM
|
||||
runner: macos-15
|
||||
runtime: osx-arm64
|
||||
min_os_version: "11.0"
|
||||
artifact_suffix: arm64
|
||||
|
||||
steps:
|
||||
- name: Set variables
|
||||
run: |
|
||||
repoFullName=${{ github.repository }}
|
||||
ref=${{ github.ref }}
|
||||
|
||||
# Handle both tag events and manual dispatch
|
||||
if [[ "$ref" =~ ^refs/tags/ ]]; then
|
||||
|
||||
# Use input version if provided, otherwise determine from ref
|
||||
if [[ -n "${{ inputs.app_version }}" ]]; then
|
||||
appVersion="${{ inputs.app_version }}"
|
||||
releaseVersion="v$appVersion"
|
||||
elif [[ "$ref" =~ ^refs/tags/ ]]; then
|
||||
releaseVersion=${ref##refs/tags/}
|
||||
appVersion=${releaseVersion#v}
|
||||
else
|
||||
@@ -30,9 +49,9 @@ jobs:
|
||||
releaseVersion="dev-$(date +%Y%m%d-%H%M%S)"
|
||||
appVersion="0.0.1-dev"
|
||||
fi
|
||||
|
||||
|
||||
repositoryName=${repoFullName#*/}
|
||||
|
||||
|
||||
echo "githubRepository=${{ github.repository }}" >> $GITHUB_ENV
|
||||
echo "githubRepositoryName=$repositoryName" >> $GITHUB_ENV
|
||||
echo "releaseVersion=$releaseVersion" >> $GITHUB_ENV
|
||||
@@ -58,39 +77,32 @@ jobs:
|
||||
token: ${{ env.REPO_READONLY_PAT }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js for frontend build
|
||||
uses: actions/setup-node@v4
|
||||
- name: Download frontend artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
node-version: '18'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: code/frontend/package-lock.json
|
||||
|
||||
- name: Build frontend
|
||||
run: |
|
||||
cd code/frontend
|
||||
npm ci
|
||||
npm run build
|
||||
name: frontend-dist
|
||||
path: code/frontend/dist/ui/browser
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
uses: actions/setup-dotnet@v5
|
||||
with:
|
||||
dotnet-version: 9.0.x
|
||||
dotnet-version: 10.0.200
|
||||
|
||||
- name: Restore .NET dependencies
|
||||
run: |
|
||||
dotnet nuget add source --username ${{ github.repository_owner }} --password ${{ env.PACKAGES_PAT }} --store-password-in-clear-text --name Cleanuparr https://nuget.pkg.github.com/Cleanuparr/index.json
|
||||
dotnet restore code/backend/${{ env.executableName }}/${{ env.executableName }}.csproj
|
||||
|
||||
- name: Build macOS ARM executable
|
||||
- name: Build macOS ${{ matrix.arch }} executable
|
||||
run: |
|
||||
# Clean any existing output directory
|
||||
rm -rf dist
|
||||
mkdir -p dist/temp
|
||||
|
||||
|
||||
# Build to a temporary location
|
||||
dotnet publish code/backend/${{ env.executableName }}/${{ env.executableName }}.csproj \
|
||||
-c Release \
|
||||
--runtime osx-arm64 \
|
||||
--runtime ${{ matrix.runtime }} \
|
||||
--self-contained true \
|
||||
-o dist/temp \
|
||||
/p:PublishSingleFile=true \
|
||||
@@ -103,17 +115,17 @@ jobs:
|
||||
/p:_CodeSignDuringBuild=false \
|
||||
/p:PublishTrimmed=false \
|
||||
/p:TrimMode=link
|
||||
|
||||
|
||||
# Create proper app bundle structure
|
||||
mkdir -p dist/Cleanuparr.app/Contents/MacOS
|
||||
|
||||
|
||||
# Copy the built executable (note: AssemblyName is "Cleanuparr" not "Cleanuparr.Api")
|
||||
cp dist/temp/Cleanuparr dist/Cleanuparr.app/Contents/MacOS/Cleanuparr
|
||||
|
||||
|
||||
# Copy frontend directly to where it belongs in the app bundle
|
||||
mkdir -p dist/Cleanuparr.app/Contents/MacOS/wwwroot
|
||||
cp -r code/frontend/dist/ui/browser/* dist/Cleanuparr.app/Contents/MacOS/wwwroot/
|
||||
|
||||
|
||||
# Copy any additional runtime files if they exist
|
||||
if [ -d "dist/temp" ]; then
|
||||
find dist/temp -name "*.dylib" -exec cp {} dist/Cleanuparr.app/Contents/MacOS/ \; 2>/dev/null || true
|
||||
@@ -124,16 +136,16 @@ jobs:
|
||||
run: |
|
||||
# Make sure the executable is actually executable
|
||||
chmod +x dist/Cleanuparr.app/Contents/MacOS/Cleanuparr
|
||||
|
||||
|
||||
# Remove any .pdb files that might have been created
|
||||
find dist/Cleanuparr.app/Contents/MacOS -name "*.pdb" -delete 2>/dev/null || true
|
||||
|
||||
|
||||
echo "Checking architecture of built binary:"
|
||||
file dist/Cleanuparr.app/Contents/MacOS/Cleanuparr
|
||||
if command -v lipo >/dev/null 2>&1; then
|
||||
lipo -info dist/Cleanuparr.app/Contents/MacOS/Cleanuparr
|
||||
fi
|
||||
|
||||
|
||||
echo "Files in MacOS directory:"
|
||||
ls -la dist/Cleanuparr.app/Contents/MacOS/
|
||||
|
||||
@@ -141,12 +153,12 @@ jobs:
|
||||
run: |
|
||||
# Create proper app bundle structure
|
||||
mkdir -p dist/Cleanuparr.app/Contents/{MacOS,Resources,Frameworks}
|
||||
|
||||
|
||||
# Convert ICO to ICNS for macOS app bundle
|
||||
if command -v iconutil >/dev/null 2>&1; then
|
||||
# Create iconset directory structure
|
||||
mkdir -p Cleanuparr.iconset
|
||||
|
||||
|
||||
# Use existing PNG files from Logo directory for different sizes
|
||||
cp Logo/16.png Cleanuparr.iconset/icon_16x16.png
|
||||
cp Logo/32.png Cleanuparr.iconset/icon_16x16@2x.png
|
||||
@@ -158,14 +170,14 @@ jobs:
|
||||
cp Logo/512.png Cleanuparr.iconset/icon_256x256@2x.png
|
||||
cp Logo/512.png Cleanuparr.iconset/icon_512x512.png
|
||||
cp Logo/1024.png Cleanuparr.iconset/icon_512x512@2x.png
|
||||
|
||||
|
||||
# Create ICNS file
|
||||
iconutil -c icns Cleanuparr.iconset -o dist/Cleanuparr.app/Contents/Resources/Cleanuparr.icns
|
||||
|
||||
|
||||
# Clean up iconset directory
|
||||
rm -rf Cleanuparr.iconset
|
||||
fi
|
||||
|
||||
|
||||
# Create Launch Daemon plist
|
||||
cat > dist/Cleanuparr.app/Contents/Resources/com.cleanuparr.daemon.plist << EOF
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
@@ -196,7 +208,7 @@ jobs:
|
||||
</dict>
|
||||
</plist>
|
||||
EOF
|
||||
|
||||
|
||||
# Create Info.plist with proper configuration
|
||||
cat > dist/Cleanuparr.app/Contents/Info.plist << EOF
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
@@ -228,7 +240,7 @@ jobs:
|
||||
<key>NSRequiresAquaSystemAppearance</key>
|
||||
<false/>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>11.0</string>
|
||||
<string>${{ matrix.min_os_version }}</string>
|
||||
<key>LSApplicationCategoryType</key>
|
||||
<string>public.app-category.productivity</string>
|
||||
<key>NSSupportsAutomaticTermination</key>
|
||||
@@ -245,7 +257,7 @@ jobs:
|
||||
</dict>
|
||||
</plist>
|
||||
EOF
|
||||
|
||||
|
||||
# Clean up temp directory
|
||||
rm -rf dist/temp
|
||||
|
||||
@@ -255,96 +267,96 @@ jobs:
|
||||
mkdir -p scripts
|
||||
cat > scripts/preinstall << 'EOF'
|
||||
#!/bin/bash
|
||||
|
||||
|
||||
# Stop and unload existing launch daemon if it exists
|
||||
if launchctl list | grep -q "com.cleanuparr.daemon"; then
|
||||
launchctl stop com.cleanuparr.daemon 2>/dev/null || true
|
||||
launchctl unload /Library/LaunchDaemons/com.cleanuparr.daemon.plist 2>/dev/null || true
|
||||
fi
|
||||
|
||||
|
||||
# Stop any running instances of Cleanuparr
|
||||
pkill -f "Cleanuparr" || true
|
||||
sleep 2
|
||||
|
||||
|
||||
# Remove old installation if it exists
|
||||
if [[ -d "/Applications/Cleanuparr.app" ]]; then
|
||||
rm -rf "/Applications/Cleanuparr.app"
|
||||
fi
|
||||
|
||||
|
||||
# Remove old launch daemon plist if it exists
|
||||
if [[ -f "/Library/LaunchDaemons/com.cleanuparr.daemon.plist" ]]; then
|
||||
rm -f "/Library/LaunchDaemons/com.cleanuparr.daemon.plist"
|
||||
fi
|
||||
|
||||
|
||||
exit 0
|
||||
EOF
|
||||
|
||||
|
||||
chmod +x scripts/preinstall
|
||||
|
||||
|
||||
# Create postinstall script
|
||||
cat > scripts/postinstall << 'EOF'
|
||||
#!/bin/bash
|
||||
|
||||
|
||||
# Set proper permissions for the app bundle
|
||||
chmod -R 755 /Applications/Cleanuparr.app
|
||||
chmod +x /Applications/Cleanuparr.app/Contents/MacOS/Cleanuparr
|
||||
|
||||
|
||||
# Install the launch daemon
|
||||
cp /Applications/Cleanuparr.app/Contents/Resources/com.cleanuparr.daemon.plist /Library/LaunchDaemons/
|
||||
chown root:wheel /Library/LaunchDaemons/com.cleanuparr.daemon.plist
|
||||
chmod 644 /Library/LaunchDaemons/com.cleanuparr.daemon.plist
|
||||
|
||||
|
||||
# Load and start the service
|
||||
launchctl load /Library/LaunchDaemons/com.cleanuparr.daemon.plist
|
||||
launchctl start com.cleanuparr.daemon
|
||||
|
||||
|
||||
# Wait a moment for service to start
|
||||
sleep 3
|
||||
|
||||
|
||||
# Display as system notification
|
||||
osascript -e 'display notification "Cleanuparr service started! Visit http://localhost:11011 in your browser." with title "Installation Complete"' 2>/dev/null || true
|
||||
|
||||
|
||||
exit 0
|
||||
EOF
|
||||
|
||||
|
||||
chmod +x scripts/postinstall
|
||||
|
||||
|
||||
# Create uninstall script (optional, for user reference)
|
||||
cat > scripts/uninstall_cleanuparr.sh << 'EOF'
|
||||
#!/bin/bash
|
||||
# Cleanuparr Uninstall Script
|
||||
# Run this script with sudo to completely remove Cleanuparr
|
||||
|
||||
|
||||
echo "Stopping Cleanuparr service..."
|
||||
launchctl stop com.cleanuparr.daemon 2>/dev/null || true
|
||||
launchctl unload /Library/LaunchDaemons/com.cleanuparr.daemon.plist 2>/dev/null || true
|
||||
|
||||
|
||||
echo "Removing service files..."
|
||||
rm -f /Library/LaunchDaemons/com.cleanuparr.daemon.plist
|
||||
|
||||
|
||||
echo "Removing application..."
|
||||
rm -rf /Applications/Cleanuparr.app
|
||||
|
||||
|
||||
echo "Removing logs..."
|
||||
rm -f /var/log/cleanuparr.log
|
||||
rm -f /var/log/cleanuparr.error.log
|
||||
|
||||
|
||||
echo "Cleanuparr has been completely removed."
|
||||
echo "Note: Configuration files in /Applications/Cleanuparr.app/Contents/MacOS/config/ have been removed with the app."
|
||||
EOF
|
||||
|
||||
|
||||
chmod +x scripts/uninstall_cleanuparr.sh
|
||||
|
||||
|
||||
# Copy uninstall script to app bundle for user access
|
||||
cp scripts/uninstall_cleanuparr.sh dist/Cleanuparr.app/Contents/Resources/
|
||||
|
||||
# Determine package name
|
||||
if [[ "${{ github.ref }}" =~ ^refs/tags/ ]]; then
|
||||
pkg_name="Cleanuparr-${{ env.appVersion }}-macos-arm64.pkg"
|
||||
|
||||
# Determine package name - if app_version input was provided, it's a release build
|
||||
if [[ -n "${{ inputs.app_version }}" ]] || [[ "${{ github.ref }}" =~ ^refs/tags/ ]]; then
|
||||
pkg_name="Cleanuparr-${{ env.appVersion }}-macos-${{ matrix.artifact_suffix }}.pkg"
|
||||
else
|
||||
pkg_name="Cleanuparr-${{ env.appVersion }}-macos-arm64-dev.pkg"
|
||||
pkg_name="Cleanuparr-${{ env.appVersion }}-macos-${{ matrix.artifact_suffix }}-dev.pkg"
|
||||
fi
|
||||
|
||||
|
||||
# Create PKG installer with better metadata
|
||||
pkgbuild --root dist/ \
|
||||
--scripts scripts/ \
|
||||
@@ -353,14 +365,12 @@ jobs:
|
||||
--install-location /Applications \
|
||||
--ownership preserve \
|
||||
${pkg_name}
|
||||
|
||||
|
||||
echo "pkgName=${pkg_name}" >> $GITHUB_ENV
|
||||
|
||||
- name: Upload installer as artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: Cleanuparr-macos-arm64-installer
|
||||
name: Cleanuparr-macos-${{ matrix.artifact_suffix }}-installer
|
||||
path: '${{ env.pkgName }}'
|
||||
retention-days: 30
|
||||
|
||||
# Removed individual release step - handled by main release workflow
|
||||
@@ -1,366 +0,0 @@
|
||||
name: Build macOS Intel Installer
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
workflow_dispatch:
|
||||
workflow_call:
|
||||
|
||||
jobs:
|
||||
build-macos-intel-installer:
|
||||
name: Build macOS Intel Installer
|
||||
runs-on: macos-13 # Intel runner
|
||||
|
||||
steps:
|
||||
- name: Set variables
|
||||
run: |
|
||||
repoFullName=${{ github.repository }}
|
||||
ref=${{ github.ref }}
|
||||
|
||||
# Handle both tag events and manual dispatch
|
||||
if [[ "$ref" =~ ^refs/tags/ ]]; then
|
||||
releaseVersion=${ref##refs/tags/}
|
||||
appVersion=${releaseVersion#v}
|
||||
else
|
||||
# For manual dispatch, use a default version
|
||||
releaseVersion="dev-$(date +%Y%m%d-%H%M%S)"
|
||||
appVersion="0.0.1-dev"
|
||||
fi
|
||||
|
||||
repositoryName=${repoFullName#*/}
|
||||
|
||||
echo "githubRepository=${{ github.repository }}" >> $GITHUB_ENV
|
||||
echo "githubRepositoryName=$repositoryName" >> $GITHUB_ENV
|
||||
echo "releaseVersion=$releaseVersion" >> $GITHUB_ENV
|
||||
echo "appVersion=$appVersion" >> $GITHUB_ENV
|
||||
echo "executableName=Cleanuparr.Api" >> $GITHUB_ENV
|
||||
|
||||
- name: Get vault secrets
|
||||
uses: hashicorp/vault-action@v2
|
||||
with:
|
||||
url: ${{ secrets.VAULT_HOST }}
|
||||
method: approle
|
||||
roleId: ${{ secrets.VAULT_ROLE_ID }}
|
||||
secretId: ${{ secrets.VAULT_SECRET_ID }}
|
||||
secrets:
|
||||
secrets/data/github repo_readonly_pat | REPO_READONLY_PAT;
|
||||
secrets/data/github packages_pat | PACKAGES_PAT
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: ${{ env.githubRepository }}
|
||||
ref: ${{ github.ref_name }}
|
||||
token: ${{ env.REPO_READONLY_PAT }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js for frontend build
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '18'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: code/frontend/package-lock.json
|
||||
|
||||
- name: Build frontend
|
||||
run: |
|
||||
cd code/frontend
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: 9.0.x
|
||||
|
||||
- name: Restore .NET dependencies
|
||||
run: |
|
||||
dotnet nuget add source --username ${{ github.repository_owner }} --password ${{ env.PACKAGES_PAT }} --store-password-in-clear-text --name Cleanuparr https://nuget.pkg.github.com/Cleanuparr/index.json
|
||||
dotnet restore code/backend/${{ env.executableName }}/${{ env.executableName }}.csproj
|
||||
|
||||
- name: Build macOS Intel executable
|
||||
run: |
|
||||
# Clean any existing output directory
|
||||
rm -rf dist
|
||||
mkdir -p dist/temp
|
||||
|
||||
# Build to a temporary location
|
||||
dotnet publish code/backend/${{ env.executableName }}/${{ env.executableName }}.csproj \
|
||||
-c Release \
|
||||
--runtime osx-x64 \
|
||||
--self-contained true \
|
||||
-o dist/temp \
|
||||
/p:PublishSingleFile=true \
|
||||
/p:Version=${{ env.appVersion }} \
|
||||
/p:DebugType=None \
|
||||
/p:DebugSymbols=false \
|
||||
/p:UseAppHost=true \
|
||||
/p:EnableMacOSCodeSign=false \
|
||||
/p:CodeSignOnCopy=false \
|
||||
/p:_CodeSignDuringBuild=false \
|
||||
/p:PublishTrimmed=false \
|
||||
/p:TrimMode=link
|
||||
|
||||
# Create proper app bundle structure
|
||||
mkdir -p dist/Cleanuparr.app/Contents/MacOS
|
||||
|
||||
# Copy the built executable (note: AssemblyName is "Cleanuparr" not "Cleanuparr.Api")
|
||||
cp dist/temp/Cleanuparr dist/Cleanuparr.app/Contents/MacOS/Cleanuparr
|
||||
|
||||
# Copy frontend directly to where it belongs in the app bundle
|
||||
mkdir -p dist/Cleanuparr.app/Contents/MacOS/wwwroot
|
||||
cp -r code/frontend/dist/ui/browser/* dist/Cleanuparr.app/Contents/MacOS/wwwroot/
|
||||
|
||||
# Copy any additional runtime files if they exist
|
||||
if [ -d "dist/temp" ]; then
|
||||
find dist/temp -name "*.dylib" -exec cp {} dist/Cleanuparr.app/Contents/MacOS/ \; 2>/dev/null || true
|
||||
find dist/temp -name "createdump" -exec cp {} dist/Cleanuparr.app/Contents/MacOS/ \; 2>/dev/null || true
|
||||
fi
|
||||
|
||||
- name: Post-build setup
|
||||
run: |
|
||||
# Make sure the executable is actually executable
|
||||
chmod +x dist/Cleanuparr.app/Contents/MacOS/Cleanuparr
|
||||
|
||||
# Remove any .pdb files that might have been created
|
||||
find dist/Cleanuparr.app/Contents/MacOS -name "*.pdb" -delete 2>/dev/null || true
|
||||
|
||||
echo "Checking architecture of built binary:"
|
||||
file dist/Cleanuparr.app/Contents/MacOS/Cleanuparr
|
||||
if command -v lipo >/dev/null 2>&1; then
|
||||
lipo -info dist/Cleanuparr.app/Contents/MacOS/Cleanuparr
|
||||
fi
|
||||
|
||||
echo "Files in MacOS directory:"
|
||||
ls -la dist/Cleanuparr.app/Contents/MacOS/
|
||||
|
||||
- name: Create macOS app bundle structure
|
||||
run: |
|
||||
# Create proper app bundle structure
|
||||
mkdir -p dist/Cleanuparr.app/Contents/{MacOS,Resources,Frameworks}
|
||||
|
||||
# Convert ICO to ICNS for macOS app bundle
|
||||
if command -v iconutil >/dev/null 2>&1; then
|
||||
# Create iconset directory structure
|
||||
mkdir -p Cleanuparr.iconset
|
||||
|
||||
# Use existing PNG files from Logo directory for different sizes
|
||||
cp Logo/16.png Cleanuparr.iconset/icon_16x16.png
|
||||
cp Logo/32.png Cleanuparr.iconset/icon_16x16@2x.png
|
||||
cp Logo/32.png Cleanuparr.iconset/icon_32x32.png
|
||||
cp Logo/64.png Cleanuparr.iconset/icon_32x32@2x.png
|
||||
cp Logo/128.png Cleanuparr.iconset/icon_128x128.png
|
||||
cp Logo/256.png Cleanuparr.iconset/icon_128x128@2x.png
|
||||
cp Logo/256.png Cleanuparr.iconset/icon_256x256.png
|
||||
cp Logo/512.png Cleanuparr.iconset/icon_256x256@2x.png
|
||||
cp Logo/512.png Cleanuparr.iconset/icon_512x512.png
|
||||
cp Logo/1024.png Cleanuparr.iconset/icon_512x512@2x.png
|
||||
|
||||
# Create ICNS file
|
||||
iconutil -c icns Cleanuparr.iconset -o dist/Cleanuparr.app/Contents/Resources/Cleanuparr.icns
|
||||
|
||||
# Clean up iconset directory
|
||||
rm -rf Cleanuparr.iconset
|
||||
fi
|
||||
|
||||
# Create Launch Daemon plist
|
||||
cat > dist/Cleanuparr.app/Contents/Resources/com.cleanuparr.daemon.plist << EOF
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>com.cleanuparr.daemon</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>/Applications/Cleanuparr.app/Contents/MacOS/Cleanuparr</string>
|
||||
</array>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
<key>StandardOutPath</key>
|
||||
<string>/var/log/cleanuparr.log</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>/var/log/cleanuparr.error.log</string>
|
||||
<key>WorkingDirectory</key>
|
||||
<string>/Applications/Cleanuparr.app/Contents/MacOS</string>
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
<key>HTTP_PORTS</key>
|
||||
<string>11011</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
EOF
|
||||
|
||||
# Create Info.plist with proper configuration
|
||||
cat > dist/Cleanuparr.app/Contents/Info.plist << EOF
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>Cleanuparr</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>com.Cleanuparr</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>Cleanuparr</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Cleanuparr</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>${{ env.appVersion }}</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>${{ env.appVersion }}</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>CLNR</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>Cleanuparr</string>
|
||||
<key>NSHighResolutionCapable</key>
|
||||
<true/>
|
||||
<key>NSRequiresAquaSystemAppearance</key>
|
||||
<false/>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>10.15</string>
|
||||
<key>LSApplicationCategoryType</key>
|
||||
<string>public.app-category.productivity</string>
|
||||
<key>NSSupportsAutomaticTermination</key>
|
||||
<false/>
|
||||
<key>NSSupportsSuddenTermination</key>
|
||||
<false/>
|
||||
<key>LSBackgroundOnly</key>
|
||||
<false/>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
EOF
|
||||
|
||||
# Clean up temp directory
|
||||
rm -rf dist/temp
|
||||
|
||||
- name: Create PKG installer
|
||||
run: |
|
||||
# Create preinstall script to handle existing installations
|
||||
mkdir -p scripts
|
||||
cat > scripts/preinstall << 'EOF'
|
||||
#!/bin/bash
|
||||
|
||||
# Stop and unload existing launch daemon if it exists
|
||||
if launchctl list | grep -q "com.cleanuparr.daemon"; then
|
||||
launchctl stop com.cleanuparr.daemon 2>/dev/null || true
|
||||
launchctl unload /Library/LaunchDaemons/com.cleanuparr.daemon.plist 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Stop any running instances of Cleanuparr
|
||||
pkill -f "Cleanuparr" || true
|
||||
sleep 2
|
||||
|
||||
# Remove old installation if it exists
|
||||
if [[ -d "/Applications/Cleanuparr.app" ]]; then
|
||||
rm -rf "/Applications/Cleanuparr.app"
|
||||
fi
|
||||
|
||||
# Remove old launch daemon plist if it exists
|
||||
if [[ -f "/Library/LaunchDaemons/com.cleanuparr.daemon.plist" ]]; then
|
||||
rm -f "/Library/LaunchDaemons/com.cleanuparr.daemon.plist"
|
||||
fi
|
||||
|
||||
exit 0
|
||||
EOF
|
||||
|
||||
chmod +x scripts/preinstall
|
||||
|
||||
# Create postinstall script
|
||||
cat > scripts/postinstall << 'EOF'
|
||||
#!/bin/bash
|
||||
|
||||
# Set proper permissions for the app bundle
|
||||
chmod -R 755 /Applications/Cleanuparr.app
|
||||
chmod +x /Applications/Cleanuparr.app/Contents/MacOS/Cleanuparr
|
||||
|
||||
# Install the launch daemon
|
||||
cp /Applications/Cleanuparr.app/Contents/Resources/com.cleanuparr.daemon.plist /Library/LaunchDaemons/
|
||||
chown root:wheel /Library/LaunchDaemons/com.cleanuparr.daemon.plist
|
||||
chmod 644 /Library/LaunchDaemons/com.cleanuparr.daemon.plist
|
||||
|
||||
# Load and start the service
|
||||
launchctl load /Library/LaunchDaemons/com.cleanuparr.daemon.plist
|
||||
launchctl start com.cleanuparr.daemon
|
||||
|
||||
# Wait a moment for service to start
|
||||
sleep 3
|
||||
|
||||
# Display as system notification
|
||||
osascript -e 'display notification "Cleanuparr service started! Visit http://localhost:11011 in your browser." with title "Installation Complete"' 2>/dev/null || true
|
||||
|
||||
exit 0
|
||||
EOF
|
||||
|
||||
chmod +x scripts/postinstall
|
||||
|
||||
# Create uninstall script (optional, for user reference)
|
||||
cat > scripts/uninstall_cleanuparr.sh << 'EOF'
|
||||
#!/bin/bash
|
||||
# Cleanuparr Uninstall Script
|
||||
# Run this script with sudo to completely remove Cleanuparr
|
||||
|
||||
echo "Stopping Cleanuparr service..."
|
||||
launchctl stop com.cleanuparr.daemon 2>/dev/null || true
|
||||
launchctl unload /Library/LaunchDaemons/com.cleanuparr.daemon.plist 2>/dev/null || true
|
||||
|
||||
echo "Removing service files..."
|
||||
rm -f /Library/LaunchDaemons/com.cleanuparr.daemon.plist
|
||||
|
||||
echo "Removing application..."
|
||||
rm -rf /Applications/Cleanuparr.app
|
||||
|
||||
echo "Removing logs..."
|
||||
rm -f /var/log/cleanuparr.log
|
||||
rm -f /var/log/cleanuparr.error.log
|
||||
|
||||
echo "Cleanuparr has been completely removed."
|
||||
echo "Note: Configuration files in /Applications/Cleanuparr.app/Contents/MacOS/config/ have been removed with the app."
|
||||
EOF
|
||||
|
||||
chmod +x scripts/uninstall_cleanuparr.sh
|
||||
|
||||
# Copy uninstall script to app bundle for user access
|
||||
cp scripts/uninstall_cleanuparr.sh dist/Cleanuparr.app/Contents/Resources/
|
||||
|
||||
# Determine package name
|
||||
if [[ "${{ github.ref }}" =~ ^refs/tags/ ]]; then
|
||||
pkg_name="Cleanuparr-${{ env.appVersion }}-macos-intel.pkg"
|
||||
else
|
||||
pkg_name="Cleanuparr-${{ env.appVersion }}-macos-intel-dev.pkg"
|
||||
fi
|
||||
|
||||
# Create PKG installer with better metadata
|
||||
pkgbuild --root dist/ \
|
||||
--scripts scripts/ \
|
||||
--identifier com.Cleanuparr \
|
||||
--version ${{ env.appVersion }} \
|
||||
--install-location /Applications \
|
||||
--ownership preserve \
|
||||
${pkg_name}
|
||||
|
||||
echo "pkgName=${pkg_name}" >> $GITHUB_ENV
|
||||
|
||||
- name: Upload installer as artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: Cleanuparr-macos-intel-installer
|
||||
path: '${{ env.pkgName }}'
|
||||
retention-days: 30
|
||||
|
||||
# Removed individual release step - handled by main release workflow
|
||||
@@ -1,11 +1,18 @@
|
||||
name: Build Windows Installer
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
workflow_dispatch:
|
||||
workflow_call:
|
||||
inputs:
|
||||
app_version:
|
||||
description: 'Application version'
|
||||
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: ''
|
||||
|
||||
jobs:
|
||||
build-windows-installer:
|
||||
@@ -17,9 +24,13 @@ jobs:
|
||||
run: |
|
||||
$repoFullName = "${{ github.repository }}"
|
||||
$ref = "${{ github.ref }}"
|
||||
|
||||
# Handle both tag events and manual dispatch
|
||||
if ($ref -match "^refs/tags/") {
|
||||
$inputVersion = "${{ inputs.app_version }}"
|
||||
|
||||
# Use input version if provided, otherwise determine from ref
|
||||
if ($inputVersion -ne "") {
|
||||
$appVersion = $inputVersion
|
||||
$releaseVersion = "v$appVersion"
|
||||
} elseif ($ref -match "^refs/tags/") {
|
||||
$releaseVersion = $ref -replace "refs/tags/", ""
|
||||
$appVersion = $releaseVersion -replace "^v", ""
|
||||
} else {
|
||||
@@ -27,15 +38,15 @@ jobs:
|
||||
$releaseVersion = "dev-$(Get-Date -Format 'yyyyMMdd-HHmmss')"
|
||||
$appVersion = "0.0.1-dev"
|
||||
}
|
||||
|
||||
|
||||
$repositoryName = $repoFullName.Split("/")[1]
|
||||
|
||||
|
||||
echo "githubRepository=${{ github.repository }}" >> $env:GITHUB_ENV
|
||||
echo "githubRepositoryName=$repositoryName" >> $env:GITHUB_ENV
|
||||
echo "releaseVersion=$releaseVersion" >> $env:GITHUB_ENV
|
||||
echo "appVersion=$appVersion" >> $env:GITHUB_ENV
|
||||
echo "executableName=Cleanuparr.Api" >> $env:GITHUB_ENV
|
||||
echo "APP_VERSION=$appVersion" >> $env:GITHUB_ENV
|
||||
echo "executableName=Cleanuparr.Api" >> $env:GITHUB_ENV
|
||||
|
||||
- name: Get vault secrets
|
||||
uses: hashicorp/vault-action@v2
|
||||
@@ -52,26 +63,19 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: ${{ env.githubRepository }}
|
||||
ref: ${{ github.ref_name }}
|
||||
ref: ${{ inputs.ref || github.ref_name }}
|
||||
token: ${{ env.REPO_READONLY_PAT }}
|
||||
|
||||
- name: Setup Node.js for frontend build
|
||||
uses: actions/setup-node@v4
|
||||
- name: Download frontend artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
node-version: '18'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: code/frontend/package-lock.json
|
||||
|
||||
- name: Build frontend
|
||||
run: |
|
||||
cd code/frontend
|
||||
npm ci
|
||||
npm run build
|
||||
name: frontend-dist
|
||||
path: code/frontend/dist/ui/browser
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
uses: actions/setup-dotnet@v5
|
||||
with:
|
||||
dotnet-version: 9.0.x
|
||||
dotnet-version: 10.0.200
|
||||
|
||||
- name: Restore .NET dependencies
|
||||
run: |
|
||||
|
||||
+4
@@ -20,6 +20,10 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Create directory for static files
|
||||
run: |
|
||||
mkdir -p Cloudflare/static
|
||||
|
||||
- name: Copy root static files to Cloudflare static directory
|
||||
run: |
|
||||
@@ -0,0 +1,30 @@
|
||||
name: Deploy to Cloudflare Pages
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
name: Deploy to Cloudflare Pages
|
||||
|
||||
steps:
|
||||
- name: Create status files
|
||||
run: |
|
||||
mkdir -p status
|
||||
echo "{ \"version\": \"${GITHUB_REF_NAME}\" }" > status/status.json
|
||||
|
||||
# Cache static files for 10 minutes
|
||||
cat > status/_headers << 'EOF'
|
||||
/*
|
||||
Cache-Control: public, max-age=600, s-maxage=600
|
||||
EOF
|
||||
|
||||
- name: Deploy to Cloudflare Pages
|
||||
uses: cloudflare/wrangler-action@v3
|
||||
with:
|
||||
apiToken: ${{ secrets.CLOUDFLARE_PAGES_TOKEN }}
|
||||
workingDirectory: "status"
|
||||
command: pages deploy . --project-name=cleanuparr-status
|
||||
@@ -0,0 +1,45 @@
|
||||
name: Dependency Review
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
# Cancel in-progress runs for the same PR
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
dependency-review:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Dependency Review
|
||||
uses: actions/dependency-review-action@v4
|
||||
with:
|
||||
# Fail on critical and high severity vulnerabilities
|
||||
fail-on-severity: high
|
||||
# Warn on moderate vulnerabilities
|
||||
warn-on-severity: moderate
|
||||
# Allow licenses
|
||||
# allow-licenses: MIT, Apache-2.0, BSD-2-Clause, BSD-3-Clause, ISC, 0BSD
|
||||
# Comment summarizes the vulnerabilities found
|
||||
comment-summary-in-pr: on-failure
|
||||
# Show dependency changes in PR
|
||||
show-openssf-scorecard: true
|
||||
vulnerability-check: true
|
||||
|
||||
- name: Upload dependency review results
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: dependency-review-results
|
||||
path: dependency-review-*.json
|
||||
if-no-files-found: ignore
|
||||
retention-days: 30
|
||||
@@ -2,9 +2,9 @@ name: Deploy Docusaurus to GitHub Pages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'docs/**'
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
workflow_dispatch: {}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -22,11 +22,12 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: main
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20.x
|
||||
node-version: 24.x
|
||||
cache: yarn
|
||||
cache-dependency-path: docs/yarn.lock
|
||||
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
name: E2E Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'code/**'
|
||||
- 'e2e/**'
|
||||
- '.github/workflows/e2e.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'code/**'
|
||||
- 'e2e/**'
|
||||
- '.github/workflows/e2e.yml'
|
||||
workflow_call:
|
||||
|
||||
concurrency:
|
||||
group: E2E Tests-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
e2e:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
suite:
|
||||
- name: api
|
||||
make-target: up-api
|
||||
- name: download-clients
|
||||
make-target: up-dc
|
||||
|
||||
name: e2e (${{ matrix.suite.name }})
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
timeout-minutes: 1
|
||||
|
||||
- name: Get vault secrets
|
||||
uses: hashicorp/vault-action@v2
|
||||
with:
|
||||
url: ${{ secrets.VAULT_HOST }}
|
||||
method: approle
|
||||
roleId: ${{ secrets.VAULT_ROLE_ID }}
|
||||
secretId: ${{ secrets.VAULT_SECRET_ID }}
|
||||
secrets:
|
||||
secrets/data/github packages_pat | PACKAGES_PAT
|
||||
|
||||
- name: Start services
|
||||
working-directory: e2e
|
||||
run: 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
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Install E2E dependencies
|
||||
working-directory: e2e
|
||||
run: npm ci
|
||||
|
||||
- name: Install Playwright browsers
|
||||
working-directory: e2e
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: Wait for Keycloak
|
||||
run: |
|
||||
echo "Waiting for Keycloak realm to be ready..."
|
||||
timeout 120 bash -c 'until curl -sf http://localhost:8080/realms/cleanuparr-test/.well-known/openid-configuration; do sleep 3; done'
|
||||
echo "Keycloak ready!"
|
||||
|
||||
- name: Wait for app
|
||||
run: |
|
||||
echo "Waiting for Cleanuparr to be ready..."
|
||||
timeout 120 bash -c 'until curl -sf http://localhost:5000/health; do sleep 3; done'
|
||||
echo "App ready!"
|
||||
|
||||
- name: Run E2E tests
|
||||
working-directory: e2e
|
||||
run: npx playwright test --project=${{ matrix.suite.name }}
|
||||
|
||||
- name: Upload test results
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: e2e-test-results-${{ matrix.suite.name }}
|
||||
path: |
|
||||
e2e/playwright-report/
|
||||
e2e/test-results/
|
||||
retention-days: 7
|
||||
|
||||
- name: Stop services
|
||||
if: always()
|
||||
working-directory: e2e
|
||||
run: docker compose -f docker-compose.e2e.yml down
|
||||
@@ -0,0 +1,181 @@
|
||||
name: PR Build (Comment Triggered)
|
||||
|
||||
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
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event.issue.pull_request != null
|
||||
outputs:
|
||||
build_windows: ${{ steps.parse.outputs.build_windows }}
|
||||
pr_ref: ${{ steps.parse.outputs.pr_ref }}
|
||||
pr_sha: ${{ steps.parse.outputs.pr_sha }}
|
||||
pr_number: ${{ steps.parse.outputs.pr_number }}
|
||||
|
||||
steps:
|
||||
- name: Parse command and check permissions
|
||||
id: parse
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const comment = context.payload.comment.body.trim();
|
||||
|
||||
// Parse supported commands
|
||||
const commands = {
|
||||
'/build-windows': 'build_windows'
|
||||
};
|
||||
|
||||
const command = commands[comment];
|
||||
if (!command) {
|
||||
console.log(`Comment "${comment}" is not a recognized build command, skipping.`);
|
||||
core.setOutput('build_windows', 'false');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await github.rest.reactions.createForIssueComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: context.payload.comment.id,
|
||||
content: 'eyes'
|
||||
});
|
||||
} catch (e) {
|
||||
console.log(`Could not add reaction: ${e}`);
|
||||
}
|
||||
|
||||
// Fetch PR details
|
||||
const pr = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: context.issue.number
|
||||
});
|
||||
|
||||
// Verify PR is open
|
||||
if (pr.data.state !== 'open') {
|
||||
console.log('PR is not open, skipping.');
|
||||
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;
|
||||
}
|
||||
|
||||
// Verify commenter has write access
|
||||
let permission = 'none';
|
||||
try {
|
||||
const resp = await github.rest.repos.getCollaboratorPermissionLevel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
username: context.payload.comment.user.login
|
||||
});
|
||||
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;
|
||||
}
|
||||
|
||||
console.log(`User ${context.payload.comment.user.login} has '${permission}' permission — proceeding with ${command}.`);
|
||||
core.setOutput(command, 'true');
|
||||
|
||||
// Export PR details for downstream jobs
|
||||
core.setOutput('pr_ref', pr.data.head.ref);
|
||||
core.setOutput('pr_sha', pr.data.head.sha);
|
||||
core.setOutput('pr_number', String(pr.data.number));
|
||||
|
||||
build-frontend:
|
||||
needs: validate
|
||||
if: needs.validate.outputs.build_windows == 'true'
|
||||
uses: ./.github/workflows/build-frontend.yml
|
||||
with:
|
||||
ref: ${{ needs.validate.outputs.pr_ref }}
|
||||
secrets: inherit
|
||||
|
||||
build-windows:
|
||||
needs: [validate, build-frontend]
|
||||
if: needs.validate.outputs.build_windows == 'true'
|
||||
uses: ./.github/workflows/build-windows-installer.yml
|
||||
with:
|
||||
ref: ${{ needs.validate.outputs.pr_ref }}
|
||||
secrets: inherit
|
||||
|
||||
post-result:
|
||||
needs: [validate, build-windows]
|
||||
if: always() && needs.validate.outputs.build_windows == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Post result comment
|
||||
uses: actions/github-script@v7
|
||||
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);
|
||||
|
||||
// Skip comment for skipped builds
|
||||
if (buildResult === 'skipped') {
|
||||
console.log('Build was skipped, no comment needed.');
|
||||
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');
|
||||
} else {
|
||||
body = [
|
||||
`Windows installer build **failed** for \`${prRef}\` (\`${shortSha}\`).`,
|
||||
``,
|
||||
`See the [workflow run](${runUrl}) for details.`
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: parseInt('${{ needs.validate.outputs.pr_number }}'),
|
||||
body
|
||||
});
|
||||
+195
-59
@@ -8,8 +8,32 @@ on:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Version to release (e.g., 1.0.0)'
|
||||
required: true
|
||||
runTests:
|
||||
description: 'Run test suite'
|
||||
type: boolean
|
||||
required: false
|
||||
default: ''
|
||||
default: true
|
||||
buildDocker:
|
||||
description: 'Build Docker image'
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
pushDocker:
|
||||
description: 'Push Docker image to registry'
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
buildBinaries:
|
||||
description: 'Build executables and installers'
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
createRelease:
|
||||
description: 'Create GitHub release'
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
|
||||
jobs:
|
||||
# Validate release
|
||||
@@ -19,7 +43,7 @@ jobs:
|
||||
app_version: ${{ steps.version.outputs.app_version }}
|
||||
release_version: ${{ steps.version.outputs.release_version }}
|
||||
is_tag: ${{ steps.version.outputs.is_tag }}
|
||||
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -32,55 +56,139 @@ jobs:
|
||||
release_version=${GITHUB_REF##refs/tags/}
|
||||
app_version=${release_version#v}
|
||||
is_tag=true
|
||||
elif [[ -n "${{ github.event.inputs.version }}" ]]; then
|
||||
else
|
||||
# Manual workflow with version
|
||||
app_version="${{ github.event.inputs.version }}"
|
||||
release_version="v$app_version"
|
||||
is_tag=false
|
||||
else
|
||||
# Manual workflow without version
|
||||
app_version="0.0.1-dev-$(date +%Y%m%d-%H%M%S)"
|
||||
|
||||
# Validate version format (x.x.x)
|
||||
if ! [[ "$app_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "Error: Version must be in format x.x.x (e.g., 1.0.0)"
|
||||
echo "Provided version: $app_version"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
release_version="v$app_version"
|
||||
is_tag=false
|
||||
fi
|
||||
|
||||
|
||||
echo "app_version=$app_version" >> $GITHUB_OUTPUT
|
||||
echo "release_version=$release_version" >> $GITHUB_OUTPUT
|
||||
echo "is_tag=$is_tag" >> $GITHUB_OUTPUT
|
||||
|
||||
echo "🏷️ Release Version: $release_version"
|
||||
echo "📱 App Version: $app_version"
|
||||
echo "🔖 Is Tag: $is_tag"
|
||||
|
||||
echo "Release Version: $release_version"
|
||||
echo "App Version: $app_version"
|
||||
echo "Is Tag: $is_tag"
|
||||
|
||||
- name: Check if release already exists
|
||||
run: |
|
||||
if gh release view "${{ steps.version.outputs.release_version }}" &>/dev/null; then
|
||||
echo "❌ Release ${{ steps.version.outputs.release_version }} already exists. Stopping workflow."
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Release ${{ steps.version.outputs.release_version }} does not exist. Proceeding."
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Run tests
|
||||
test:
|
||||
needs: validate
|
||||
if: ${{ needs.validate.outputs.is_tag == 'true' || github.event.inputs.runTests == 'true' }}
|
||||
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]
|
||||
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
|
||||
needs: [validate, test, e2e, build-frontend]
|
||||
if: |
|
||||
always() &&
|
||||
needs.validate.result == 'success' &&
|
||||
(needs.test.result == 'success' || needs.test.result == 'skipped') &&
|
||||
(needs.e2e.result == 'success' || needs.e2e.result == 'skipped') &&
|
||||
needs.build-frontend.result == 'success' &&
|
||||
(needs.validate.outputs.is_tag == 'true' || github.event.inputs.buildBinaries == 'true')
|
||||
uses: ./.github/workflows/build-executable.yml
|
||||
with:
|
||||
app_version: ${{ needs.validate.outputs.app_version }}
|
||||
secrets: inherit
|
||||
|
||||
# Build Windows installer
|
||||
build-windows-installer:
|
||||
needs: validate
|
||||
needs: [validate, test, e2e, build-frontend]
|
||||
if: |
|
||||
always() &&
|
||||
needs.validate.result == 'success' &&
|
||||
(needs.test.result == 'success' || needs.test.result == 'skipped') &&
|
||||
(needs.e2e.result == 'success' || needs.e2e.result == 'skipped') &&
|
||||
needs.build-frontend.result == 'success' &&
|
||||
(needs.validate.outputs.is_tag == 'true' || github.event.inputs.buildBinaries == 'true')
|
||||
uses: ./.github/workflows/build-windows-installer.yml
|
||||
with:
|
||||
app_version: ${{ needs.validate.outputs.app_version }}
|
||||
secrets: inherit
|
||||
|
||||
# Build macOS Intel installer
|
||||
build-macos-intel:
|
||||
needs: validate
|
||||
uses: ./.github/workflows/build-macos-intel-installer.yml
|
||||
# Build macOS installers (Intel and ARM)
|
||||
build-macos:
|
||||
needs: [validate, test, e2e, build-frontend]
|
||||
if: |
|
||||
always() &&
|
||||
needs.validate.result == 'success' &&
|
||||
(needs.test.result == 'success' || needs.test.result == 'skipped') &&
|
||||
(needs.e2e.result == 'success' || needs.e2e.result == 'skipped') &&
|
||||
needs.build-frontend.result == 'success' &&
|
||||
(needs.validate.outputs.is_tag == 'true' || github.event.inputs.buildBinaries == 'true')
|
||||
uses: ./.github/workflows/build-macos-installer.yml
|
||||
with:
|
||||
app_version: ${{ needs.validate.outputs.app_version }}
|
||||
secrets: inherit
|
||||
|
||||
# Build macOS ARM installer
|
||||
build-macos-arm:
|
||||
needs: validate
|
||||
uses: ./.github/workflows/build-macos-arm-installer.yml
|
||||
# Build and push Docker image(s)
|
||||
build-docker:
|
||||
needs: [validate, test, e2e]
|
||||
if: |
|
||||
always() &&
|
||||
needs.validate.result == 'success' &&
|
||||
(needs.test.result == 'success' || needs.test.result == 'skipped') &&
|
||||
(needs.e2e.result == 'success' || needs.e2e.result == 'skipped') &&
|
||||
(needs.validate.outputs.is_tag == 'true' || github.event.inputs.buildDocker == 'true')
|
||||
uses: ./.github/workflows/build-docker.yml
|
||||
with:
|
||||
push_docker: ${{ needs.validate.outputs.is_tag == 'true' || github.event.inputs.pushDocker == 'true' }}
|
||||
app_version: ${{ needs.validate.outputs.app_version }}
|
||||
secrets: inherit
|
||||
|
||||
# Create GitHub release
|
||||
create-release:
|
||||
needs: [validate, build-executables, build-windows-installer, build-macos-intel, build-macos-arm]
|
||||
needs: [validate, build-executables, build-windows-installer, build-macos]
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
|
||||
if: |
|
||||
always() &&
|
||||
needs.validate.result == 'success' &&
|
||||
needs.build-executables.result == 'success' &&
|
||||
needs.build-windows-installer.result == 'success' &&
|
||||
needs.build-macos.result == 'success' &&
|
||||
(
|
||||
needs.validate.outputs.is_tag == 'true' ||
|
||||
(github.event.inputs.createRelease == 'true' && github.event.inputs.buildBinaries == 'true')
|
||||
)
|
||||
|
||||
steps:
|
||||
- name: Get vault secrets
|
||||
@@ -93,15 +201,32 @@ jobs:
|
||||
secrets:
|
||||
secrets/data/github repo_readonly_pat | REPO_READONLY_PAT
|
||||
|
||||
- name: Download all artifacts
|
||||
- name: Download executable artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: executable-*
|
||||
path: ./artifacts
|
||||
merge-multiple: true
|
||||
|
||||
- name: Download Windows installer
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: Cleanuparr-windows-installer
|
||||
path: ./artifacts
|
||||
|
||||
- name: Download macOS installers
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: Cleanuparr-macos-*-installer
|
||||
path: ./artifacts
|
||||
merge-multiple: true
|
||||
|
||||
- name: List downloaded artifacts
|
||||
run: |
|
||||
echo "📦 Downloaded artifacts:"
|
||||
find ./artifacts -type f -name "*.zip" -o -name "*.pkg" -o -name "*.exe" | sort
|
||||
echo "Downloaded artifacts:"
|
||||
find ./artifacts -type f \( -name "*.zip" -o -name "*.pkg" -o -name "*.exe" \) | sort
|
||||
echo ""
|
||||
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
|
||||
@@ -113,52 +238,63 @@ jobs:
|
||||
target_commitish: main
|
||||
generate_release_notes: true
|
||||
files: |
|
||||
./artifacts/**/*.zip
|
||||
./artifacts/**/*.pkg
|
||||
./artifacts/**/*.exe
|
||||
./artifacts/*.zip
|
||||
./artifacts/*.pkg
|
||||
./artifacts/*.exe
|
||||
|
||||
# Summary job
|
||||
summary:
|
||||
needs: [validate, build-executables, build-windows-installer, build-macos-intel, build-macos-arm]
|
||||
needs: [validate, test, e2e, build-frontend, build-executables, build-windows-installer, build-macos, build-docker]
|
||||
runs-on: ubuntu-latest
|
||||
if: always()
|
||||
|
||||
|
||||
steps:
|
||||
- name: Record workflow start time
|
||||
id: workflow-start
|
||||
run: |
|
||||
# Get workflow start time from GitHub API
|
||||
workflow_start=$(gh api repos/${{ github.repository }}/actions/runs/${{ github.run_id }} --jq '.run_started_at')
|
||||
start_epoch=$(date -d "$workflow_start" +%s 2>/dev/null || date -j -f "%Y-%m-%dT%H:%M:%SZ" "$workflow_start" +%s)
|
||||
echo "start=$start_epoch" >> $GITHUB_OUTPUT
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build Summary
|
||||
run: |
|
||||
# Calculate total workflow duration
|
||||
start_time=${{ steps.workflow-start.outputs.start }}
|
||||
end_time=$(date +%s)
|
||||
duration=$((end_time - start_time))
|
||||
minutes=$((duration / 60))
|
||||
seconds=$((duration % 60))
|
||||
echo "## 🏗️ Cleanuparr Build Summary" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Version**: ${{ needs.validate.outputs.release_version }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**App Version**: ${{ needs.validate.outputs.app_version }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Is Tag**: ${{ needs.validate.outputs.is_tag }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Total Duration**: ${minutes}m ${seconds}s" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### Build Results" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# Check job results
|
||||
if [[ "${{ needs.build-executables.result }}" == "success" ]]; then
|
||||
echo "✅ **Portable Executables**: Success" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "❌ **Portable Executables**: ${{ needs.build-executables.result }}" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
if [[ "${{ needs.build-windows-installer.result }}" == "success" ]]; then
|
||||
echo "✅ **Windows Installer**: Success" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "❌ **Windows Installer**: ${{ needs.build-windows-installer.result }}" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
if [[ "${{ needs.build-macos-intel.result }}" == "success" ]]; then
|
||||
echo "✅ **macOS Intel Installer**: Success" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "❌ **macOS Intel Installer**: ${{ needs.build-macos-intel.result }}" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
if [[ "${{ needs.build-macos-arm.result }}" == "success" ]]; then
|
||||
echo "✅ **macOS ARM Installer**: Success" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "❌ **macOS ARM Installer**: ${{ needs.build-macos-arm.result }}" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
|
||||
# Helper function to print job result
|
||||
print_result() {
|
||||
local name="$1"
|
||||
local result="$2"
|
||||
case "$result" in
|
||||
success) echo "✅ **$name**: Success" >> $GITHUB_STEP_SUMMARY ;;
|
||||
skipped) echo "⏭️ **$name**: Skipped" >> $GITHUB_STEP_SUMMARY ;;
|
||||
*) echo "❌ **$name**: $result" >> $GITHUB_STEP_SUMMARY ;;
|
||||
esac
|
||||
}
|
||||
|
||||
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 }}"
|
||||
print_result "macOS Installers (Intel & ARM)" "${{ needs.build-macos.result }}"
|
||||
print_result "Docker Image Build" "${{ needs.build-docker.result }}"
|
||||
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "🎉 **Build completed!**" >> $GITHUB_STEP_SUMMARY
|
||||
@@ -0,0 +1,99 @@
|
||||
name: Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'code/backend/**'
|
||||
- '.github/workflows/test.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'code/backend/**'
|
||||
- '.github/workflows/test.yml'
|
||||
workflow_call:
|
||||
|
||||
# Cancel in-progress runs for the same PR
|
||||
concurrency:
|
||||
group: Tests-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
timeout-minutes: 1
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v5
|
||||
with:
|
||||
dotnet-version: 10.0.200
|
||||
|
||||
- name: Cache NuGet packages
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.nuget/packages
|
||||
key: ${{ runner.os }}-nuget-${{ hashFiles('**/packages.lock.json', '**/*.csproj') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-nuget-
|
||||
|
||||
- name: Get vault secrets
|
||||
uses: hashicorp/vault-action@v2
|
||||
with:
|
||||
url: ${{ secrets.VAULT_HOST }}
|
||||
method: approle
|
||||
roleId: ${{ secrets.VAULT_ROLE_ID }}
|
||||
secretId: ${{ secrets.VAULT_SECRET_ID }}
|
||||
secrets:
|
||||
secrets/data/github packages_pat | PACKAGES_PAT
|
||||
|
||||
- name: Restore dependencies
|
||||
run: |
|
||||
dotnet nuget add source --username ${{ github.repository_owner }} --password ${{ env.PACKAGES_PAT }} --store-password-in-clear-text --name Cleanuparr https://nuget.pkg.github.com/Cleanuparr/index.json
|
||||
dotnet restore code/backend/cleanuparr.sln
|
||||
|
||||
- name: Build solution
|
||||
run: dotnet build code/backend/cleanuparr.sln --configuration Release --no-restore
|
||||
|
||||
- name: Run tests
|
||||
id: run-tests
|
||||
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
|
||||
with:
|
||||
name: test-results
|
||||
path: ./coverage/*.trx
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload coverage reports
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: coverage-report
|
||||
path: ./coverage/**/coverage.cobertura.xml
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
files: ./coverage/**/coverage.cobertura.xml
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
fail_ci_if_error: false
|
||||
flags: backend
|
||||
name: backend-coverage
|
||||
|
||||
- name: Test Summary
|
||||
run: |
|
||||
echo "## Test Results" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
if [ "${{ steps.run-tests.outcome }}" == "success" ]; then
|
||||
echo "✅ All tests passed!" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "❌ Tests failed or were cancelled. Status: ${{ steps.run-tests.outcome }}" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Test artifacts have been uploaded for detailed analysis." >> $GITHUB_STEP_SUMMARY
|
||||
@@ -0,0 +1,66 @@
|
||||
name: Get Version Info
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
manual_version:
|
||||
description: 'Manual version override (e.g., 1.0.0)'
|
||||
required: false
|
||||
type: string
|
||||
default: ''
|
||||
outputs:
|
||||
app_version:
|
||||
description: 'Application version (without v prefix)'
|
||||
value: ${{ jobs.version.outputs.app_version }}
|
||||
release_version:
|
||||
description: 'Release version (with v prefix)'
|
||||
value: ${{ jobs.version.outputs.release_version }}
|
||||
is_tag:
|
||||
description: 'Whether this is a tag event'
|
||||
value: ${{ jobs.version.outputs.is_tag }}
|
||||
repository_name:
|
||||
description: 'Repository name without owner'
|
||||
value: ${{ jobs.version.outputs.repository_name }}
|
||||
|
||||
jobs:
|
||||
version:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
app_version: ${{ steps.version.outputs.app_version }}
|
||||
release_version: ${{ steps.version.outputs.release_version }}
|
||||
is_tag: ${{ steps.version.outputs.is_tag }}
|
||||
repository_name: ${{ steps.version.outputs.repository_name }}
|
||||
|
||||
steps:
|
||||
- name: Calculate version info
|
||||
id: version
|
||||
run: |
|
||||
repoFullName="${{ github.repository }}"
|
||||
repositoryName="${repoFullName#*/}"
|
||||
|
||||
if [[ "${{ github.ref }}" =~ ^refs/tags/ ]]; then
|
||||
# Tag event
|
||||
release_version="${GITHUB_REF##refs/tags/}"
|
||||
app_version="${release_version#v}"
|
||||
is_tag="true"
|
||||
elif [[ -n "${{ inputs.manual_version }}" ]]; then
|
||||
# Manual workflow with version
|
||||
app_version="${{ inputs.manual_version }}"
|
||||
release_version="v${app_version}"
|
||||
is_tag="false"
|
||||
else
|
||||
# Development build
|
||||
app_version="0.0.1-dev-$(date +%Y%m%d-%H%M%S)"
|
||||
release_version="v${app_version}"
|
||||
is_tag="false"
|
||||
fi
|
||||
|
||||
echo "app_version=${app_version}" >> $GITHUB_OUTPUT
|
||||
echo "release_version=${release_version}" >> $GITHUB_OUTPUT
|
||||
echo "is_tag=${is_tag}" >> $GITHUB_OUTPUT
|
||||
echo "repository_name=${repositoryName}" >> $GITHUB_OUTPUT
|
||||
|
||||
echo "📦 Repository: ${repositoryName}"
|
||||
echo "🏷️ Release Version: ${release_version}"
|
||||
echo "📱 App Version: ${app_version}"
|
||||
echo "🔖 Is Tag: ${is_tag}"
|
||||
@@ -0,0 +1,189 @@
|
||||
# Cleanuparr - Claude AI Rules
|
||||
|
||||
## Rules
|
||||
|
||||
1. **DO NOT break existing functionality** - All features are critical and must continue to work
|
||||
2. **When in doubt, ASK** - Don't assume, clarify with the maintainer first
|
||||
3. **Always read existing code before making changes** - Understand the current architecture and patterns
|
||||
4. **Follow existing patterns** - Study the codebase style and match it exactly
|
||||
5. **Ask before introducing new patterns** - Use current coding standards or get approval first
|
||||
6. **Prefer editing existing files over creating new ones** - Build on existing work
|
||||
7. **Flag potential gotchas or issues immediately** - Document and report anything unexpected
|
||||
8. **If unsure about an approach, ask before implementing**
|
||||
|
||||
## Project Overview
|
||||
|
||||
Cleanuparr is a tool for automating the cleanup of unwanted or blocked files in Sonarr, Radarr, Lidarr, Readarr, Whisparr and supported download clients (qBittorrent, Transmission, Deluge, uTorrent, rTorrent). It provides malware protection, automated cleanup, and queue management for *arr applications.
|
||||
|
||||
**Key Features:**
|
||||
- Strike system for bad downloads
|
||||
- Malware detection and blocking
|
||||
- Automatic search triggering after removal (Seeker)
|
||||
- Missing and upgrade search
|
||||
- Orphaned download cleanup with cross-seed support
|
||||
- Authentication (OIDC, 2FA)
|
||||
- Notification providers (Apprise, Discord, Gotify, Notifiarr, Ntfy, Pushover, Telegram)
|
||||
|
||||
## Architecture & Tech Stack
|
||||
|
||||
### Backend
|
||||
- **.NET 10.0** (C#) with ASP.NET Core
|
||||
- **Architecture**: Clean Architecture with `Features/` subdirectory pattern
|
||||
- `Cleanuparr.Api` - REST API and web host (`Features/` for endpoint groups)
|
||||
- `Cleanuparr.Application` - Application services and use cases
|
||||
- `Cleanuparr.Domain` - Domain models (Entities, Enums, Exceptions)
|
||||
- `Cleanuparr.Infrastructure` - External integrations (`Features/` for Arr, DownloadClient, Notifications, etc.)
|
||||
- `Cleanuparr.Persistence` - Data access with EF Core (SQLite)
|
||||
- `Cleanuparr.Shared` - Shared utilities
|
||||
- **Database**: SQLite with Entity Framework Core
|
||||
- Three separate contexts: `DataContext`, `EventsContext`, `UsersContext`
|
||||
- **Key Libraries**:
|
||||
- MassTransit (messaging)
|
||||
- Quartz.NET (scheduling)
|
||||
- Serilog (logging)
|
||||
- SignalR (real-time communication)
|
||||
- **Testing**: xUnit + NSubstitute + Shouldly
|
||||
- Always use **NSubstitute** for mocking in new tests (Moq is being phased out)
|
||||
|
||||
### Frontend
|
||||
- **Angular 21** with TypeScript 5.9 (standalone components, zoneless, OnPush)
|
||||
- **UI**: Custom glassmorphism design system with 33 custom components — no external UI frameworks
|
||||
- **Icons**: @ng-icons/core + @ng-icons/tabler-icons
|
||||
- **Design System**: 3-layer SCSS (`_variables` -> `_tokens` -> `_themes`), dark/light themes
|
||||
- **State Management**: @ngrx/signals (Angular signals-based)
|
||||
- **Real-time Updates**: @microsoft/signalr 10.0.0
|
||||
- **PWA**: Service Worker support enabled
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
Cleanuparr/
|
||||
├── code/
|
||||
│ ├── backend/
|
||||
│ │ ├── Cleanuparr.Api/ # REST API (Features/ for endpoint groups)
|
||||
│ │ ├── Cleanuparr.Api.Tests/ # API layer tests
|
||||
│ │ ├── Cleanuparr.Application/ # Business logic layer
|
||||
│ │ ├── Cleanuparr.Domain/ # Domain models
|
||||
│ │ ├── Cleanuparr.Infrastructure/ # External integrations (Features/ subdirs)
|
||||
│ │ ├── Cleanuparr.Infrastructure.Tests/
|
||||
│ │ ├── Cleanuparr.Persistence/ # SQLite data access
|
||||
│ │ ├── Cleanuparr.Persistence.Tests/
|
||||
│ │ └── Cleanuparr.Shared/ # Shared utilities
|
||||
│ ├── frontend/ # Angular 21 application
|
||||
│ ├── e2e/ # Playwright E2E tests
|
||||
│ ├── Dockerfile # Multi-stage Docker build
|
||||
│ ├── entrypoint.sh # Docker entrypoint
|
||||
│ └── Makefile # Build & migration helpers
|
||||
├── docs/ # Docusaurus documentation
|
||||
├── .github/workflows/ # CI/CD pipelines
|
||||
├── blacklist # Default malware patterns (strict)
|
||||
├── blacklist_permissive # Less strict malware patterns
|
||||
├── whitelist # Safe file extensions
|
||||
└── whitelist_with_subtitles # Includes subtitle formats
|
||||
```
|
||||
|
||||
## Code Standards & Conventions
|
||||
|
||||
**IMPORTANT:** Always study existing code in the relevant area before making changes. Match the existing style exactly.
|
||||
|
||||
### Backend (C#)
|
||||
- Follow Microsoft C# Coding Conventions
|
||||
- Use nullable reference types (`<Nullable>enable</Nullable>`)
|
||||
- Add XML documentation comments for public APIs
|
||||
- Use meaningful names - avoid abbreviations unless widely understood
|
||||
- Keep services focused - single responsibility principle
|
||||
- New integrations go under `Features/` subdirectories (e.g., `Infrastructure/Features/Arr/`)
|
||||
|
||||
### 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()`)
|
||||
- Follow the 3-layer SCSS design system (`_variables` -> `_tokens` -> `_themes`)
|
||||
- **Do not introduce external UI frameworks** (no PrimeNG, Material, Tailwind, etc.)
|
||||
- Component naming: `{feature}.component.ts`
|
||||
- Service naming: `{feature}.service.ts`
|
||||
- **Look at similar existing components before creating new ones**
|
||||
|
||||
### Testing
|
||||
- **Backend**: xUnit + NSubstitute + Shouldly
|
||||
- Always use **NSubstitute** for mocking (Moq is being phased out)
|
||||
- Write unit tests for new features and bug fixes
|
||||
- Use descriptive test names that explain what is being tested
|
||||
- No frontend unit tests currently
|
||||
|
||||
### Git Commit Messages
|
||||
- Use clear, descriptive messages in imperative mood
|
||||
- Examples: "Add Discord notification support", "Fix memory leak in download client polling"
|
||||
- Reference issue numbers when applicable: "Fix #123: Handle null response from Radarr API"
|
||||
|
||||
## Development Setup
|
||||
|
||||
### Running the Backend
|
||||
```bash
|
||||
cd code/backend
|
||||
dotnet build Cleanuparr.Api/Cleanuparr.Api.csproj
|
||||
dotnet run --project Cleanuparr.Api/Cleanuparr.Api.csproj
|
||||
```
|
||||
API runs at http://localhost:5000
|
||||
|
||||
### Running the Frontend
|
||||
```bash
|
||||
cd code/frontend
|
||||
npm install
|
||||
npm start
|
||||
```
|
||||
UI runs at http://localhost:4200
|
||||
|
||||
### Running Tests
|
||||
```bash
|
||||
cd code/backend
|
||||
dotnet test
|
||||
```
|
||||
|
||||
## Database Migrations
|
||||
|
||||
Three separate database contexts, all commands run from the `code` directory:
|
||||
|
||||
```bash
|
||||
# Data migrations (DataContext)
|
||||
make migrate-data name=YourMigrationName
|
||||
|
||||
# Events migrations (EventsContext)
|
||||
make migrate-events name=YourMigrationName
|
||||
|
||||
# Users migrations (UsersContext)
|
||||
make migrate-users name=YourMigrationName
|
||||
```
|
||||
|
||||
## Common Development Workflows
|
||||
|
||||
### Adding a New *arr Application Integration
|
||||
1. Add integration in `Cleanuparr.Infrastructure/Features/Arr/`
|
||||
2. Update domain models in `Cleanuparr.Domain/`
|
||||
3. Create/update services in `Cleanuparr.Application/`
|
||||
4. Add API endpoints in `Cleanuparr.Api/Features/Arr/`
|
||||
5. Update frontend in `code/frontend/src/app/`
|
||||
|
||||
### Adding a New Download Client
|
||||
1. Add client implementation in `Cleanuparr.Infrastructure/Features/DownloadClient/`
|
||||
2. Follow existing patterns (qBittorrent, Transmission, etc.)
|
||||
3. Add configuration models to `Cleanuparr.Domain/`
|
||||
4. Update API and frontend as above
|
||||
|
||||
### Adding a New Notification Provider
|
||||
1. Add provider in `Cleanuparr.Infrastructure/Features/Notifications/`
|
||||
2. Update configuration models
|
||||
3. Add UI configuration in frontend
|
||||
|
||||
## Key Gotchas
|
||||
|
||||
- **Custom glassmorphism design system** - Do not introduce external UI frameworks (no PrimeNG, Material, Tailwind)
|
||||
- **All frontend components** must be standalone with OnPush change detection
|
||||
- **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)
|
||||
- **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()`)
|
||||
+332
@@ -0,0 +1,332 @@
|
||||
# Contributing to Cleanuparr
|
||||
|
||||
Thanks for your interest in contributing to Cleanuparr! This guide will help you get started with development.
|
||||
|
||||
## Before You Start
|
||||
|
||||
### AI usage
|
||||
|
||||
In this ever-evolving field of work, AI is now the shiny new tool to help programmers work faster and there's nothing wrong with that.
|
||||
But it is **very** wrong to rely solely on AI tools to write, review and test your code.
|
||||
|
||||
**If you do not have a background in programming and you do not intend to test your code properly, please do not submit AI-generated code.** If you still want to help in other ways such as testing features, that would also help a lot!
|
||||
|
||||
### Announce Your Intent
|
||||
|
||||
Before starting any work, please let us know what you want to contribute:
|
||||
|
||||
- For existing issues: Comment on the issue stating you'd like to work on it
|
||||
- For new features/changes: Create a new issue first and mention that you want to work on it
|
||||
|
||||
This helps us avoid redundant work, git conflicts, and contributions that may not align with the project's direction.
|
||||
|
||||
**Wait for approval from the maintainers before proceeding with your contribution.**
|
||||
|
||||
## Development Setup
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- [.NET 10.0 SDK](https://dotnet.microsoft.com/download/dotnet/10.0)
|
||||
- [Node.js 18+](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/)
|
||||
|
||||
### Repository Setup
|
||||
|
||||
1. Fork the repository on GitHub
|
||||
2. Clone your fork locally:
|
||||
```bash
|
||||
git clone https://github.com/YOUR_USERNAME/Cleanuparr.git
|
||||
cd Cleanuparr
|
||||
```
|
||||
3. Add the upstream repository:
|
||||
```bash
|
||||
git remote add upstream https://github.com/Cleanuparr/Cleanuparr.git
|
||||
```
|
||||
|
||||
## Backend Development
|
||||
|
||||
### Initial Setup
|
||||
|
||||
#### 1. Create a GitHub Personal Access Token (PAT)
|
||||
|
||||
Cleanuparr uses GitHub Packages for NuGet dependencies. You'll need a PAT with `read:packages` permission:
|
||||
|
||||
1. Go to [GitHub Settings > Developer Settings > Personal Access Tokens > Tokens (classic)](https://github.com/settings/tokens)
|
||||
2. Click "Generate new token" → "Generate new token (classic)"
|
||||
3. Give it a descriptive name (e.g., "Cleanuparr NuGet Access")
|
||||
4. Set an expiration (recommend 90 days or longer for development)
|
||||
5. Select only the `read:packages` scope
|
||||
6. Click "Generate token" and copy it
|
||||
|
||||
#### 2. Configure NuGet Source
|
||||
|
||||
Add the Cleanuparr NuGet repository:
|
||||
|
||||
```bash
|
||||
dotnet nuget add source \
|
||||
--username YOUR_GITHUB_USERNAME \
|
||||
--password YOUR_GITHUB_PAT \
|
||||
--store-password-in-clear-text \
|
||||
--name Cleanuparr \
|
||||
https://nuget.pkg.github.com/Cleanuparr/index.json
|
||||
```
|
||||
|
||||
Replace `YOUR_GITHUB_USERNAME` and `YOUR_GITHUB_PAT` with your GitHub username and the PAT you created.
|
||||
|
||||
### Running the Backend
|
||||
|
||||
#### Option 1: Using .NET CLI
|
||||
|
||||
Navigate to the backend directory:
|
||||
```bash
|
||||
cd code/backend
|
||||
```
|
||||
|
||||
Build the application:
|
||||
```bash
|
||||
dotnet build Cleanuparr.Api/Cleanuparr.Api.csproj
|
||||
```
|
||||
|
||||
Run the application:
|
||||
```bash
|
||||
dotnet run --project Cleanuparr.Api/Cleanuparr.Api.csproj
|
||||
```
|
||||
|
||||
Run tests:
|
||||
```bash
|
||||
dotnet test
|
||||
```
|
||||
|
||||
The API will be available at http://localhost:5000
|
||||
|
||||
#### Option 2: Using an IDE
|
||||
|
||||
For JetBrains Rider or Visual Studio:
|
||||
1. Open the solution file: `code/backend/cleanuparr.sln`
|
||||
2. Set `Cleanuparr.Api` as the startup project
|
||||
3. Press `F5` to start the application
|
||||
|
||||
### Database Migrations
|
||||
|
||||
Cleanuparr uses two separate database contexts: `DataContext` and `EventsContext`.
|
||||
|
||||
#### Prerequisites
|
||||
|
||||
Install Make if not already installed:
|
||||
- Windows: Install via [Chocolatey](https://chocolatey.org/) (`choco install make`) or use [WSL](https://docs.microsoft.com/windows/wsl/)
|
||||
- macOS: Install via Homebrew (`brew install make`)
|
||||
- Linux: Usually pre-installed, or install via package manager (`apt install make`, `yum install make`, etc.)
|
||||
|
||||
#### Creating Migrations
|
||||
|
||||
From the `code` directory:
|
||||
|
||||
For data migrations (DataContext):
|
||||
```bash
|
||||
make migrate-data name=YourMigrationName
|
||||
```
|
||||
|
||||
For events migrations (EventsContext):
|
||||
```bash
|
||||
make migrate-events name=YourMigrationName
|
||||
```
|
||||
|
||||
Example:
|
||||
```bash
|
||||
make migrate-data name=AddUserPreferences
|
||||
make migrate-events name=AddAuditLogEvents
|
||||
```
|
||||
|
||||
## Frontend Development
|
||||
|
||||
### Setup
|
||||
|
||||
1. Navigate to the frontend directory:
|
||||
```bash
|
||||
cd code/frontend
|
||||
```
|
||||
|
||||
2. Install dependencies:
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
3. Start the development server:
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
The UI will be available at http://localhost:4200
|
||||
|
||||
## Documentation Development
|
||||
|
||||
### Setup
|
||||
|
||||
1. Navigate to the docs directory:
|
||||
```bash
|
||||
cd docs
|
||||
```
|
||||
|
||||
2. Install dependencies:
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
3. Start the development server:
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
The documentation site will be available at http://localhost:3000
|
||||
|
||||
## Building with Docker
|
||||
|
||||
### Building a Local Docker Image
|
||||
|
||||
To build the Docker image locally for testing:
|
||||
|
||||
1. Navigate to the `code` directory:
|
||||
```bash
|
||||
cd code
|
||||
```
|
||||
|
||||
2. Build the image:
|
||||
```bash
|
||||
docker build \
|
||||
--build-arg PACKAGES_USERNAME=YOUR_GITHUB_USERNAME \
|
||||
--build-arg PACKAGES_PAT=YOUR_GITHUB_PAT \
|
||||
-t cleanuparr:local \
|
||||
-f Dockerfile .
|
||||
```
|
||||
|
||||
Replace `YOUR_GITHUB_USERNAME` and `YOUR_GITHUB_PAT` with your credentials.
|
||||
|
||||
3. Run the container:
|
||||
```bash
|
||||
docker run -d \
|
||||
--name cleanuparr-dev \
|
||||
-p 11011:11011 \
|
||||
-v /path/to/config:/config \
|
||||
-e PUID=1000 \
|
||||
-e PGID=1000 \
|
||||
-e TZ=Etc/UTC \
|
||||
cleanuparr:local
|
||||
```
|
||||
|
||||
4. Access the application at http://localhost:11011
|
||||
|
||||
### Building for Multiple Architectures
|
||||
|
||||
Use Docker Buildx for multi-platform builds:
|
||||
|
||||
```bash
|
||||
docker buildx build \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--build-arg PACKAGES_USERNAME=YOUR_GITHUB_USERNAME \
|
||||
--build-arg PACKAGES_PAT=YOUR_GITHUB_PAT \
|
||||
-t cleanuparr:local \
|
||||
-f Dockerfile .
|
||||
```
|
||||
|
||||
## Code Standards
|
||||
|
||||
### Backend (.NET/C#)
|
||||
- Follow existing conventions and [Microsoft C# Coding Conventions](https://docs.microsoft.com/dotnet/csharp/fundamentals/coding-style/coding-conventions)
|
||||
- Use meaningful variable and method names
|
||||
- Add XML documentation comments for public APIs
|
||||
- Write unit tests whenever possible
|
||||
|
||||
### 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
|
||||
|
||||
### Documentation
|
||||
- Use clear, concise language
|
||||
- Include code examples where appropriate
|
||||
- Update relevant documentation when adding/changing features
|
||||
- Check for spelling and grammar
|
||||
|
||||
## Submitting Your Contribution
|
||||
|
||||
### 1. Create a Feature Branch
|
||||
|
||||
```bash
|
||||
git checkout -b feature/your-feature-name
|
||||
# or
|
||||
git checkout -b fix/your-bug-fix-name
|
||||
```
|
||||
|
||||
### 2. Make Your Changes
|
||||
|
||||
- Write clean, well-documented code
|
||||
- Follow the code standards outlined above
|
||||
- **Test your changes thoroughly!**
|
||||
|
||||
### 3. Commit Your Changes
|
||||
|
||||
Write clear, descriptive commit messages:
|
||||
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "Add feature: brief description of your changes"
|
||||
```
|
||||
|
||||
### 4. Keep Your Branch Updated
|
||||
|
||||
```bash
|
||||
git fetch upstream
|
||||
git rebase upstream/main
|
||||
```
|
||||
|
||||
### 5. Push to Your Fork
|
||||
|
||||
```bash
|
||||
git push origin feature/your-feature-name
|
||||
```
|
||||
|
||||
### 6. Create a Pull Request
|
||||
|
||||
1. Go to the [Cleanuparr repository](https://github.com/Cleanuparr/Cleanuparr)
|
||||
2. Click "New Pull Request"
|
||||
3. Select your fork and branch
|
||||
4. Fill out the PR template with:
|
||||
- A descriptive title (e.g., "Add support for Prowlarr integration" or "Fix memory leak in download client polling")
|
||||
- Description of changes
|
||||
- Related issue number
|
||||
- Testing performed
|
||||
- Screenshots (if applicable)
|
||||
|
||||
### 7. Code Review Process
|
||||
|
||||
- Maintainers will review your PR
|
||||
- Address any feedback or requested changes
|
||||
- Once approved, your PR will be merged
|
||||
|
||||
## Other Ways to Contribute
|
||||
|
||||
### Help Test New Features
|
||||
|
||||
We're always looking for testers to help validate new features before they are released. If you'd like to help test upcoming changes:
|
||||
|
||||
1. Join our [Discord community](https://discord.gg/SCtMCgtsc4)
|
||||
2. Let us know you're interested in testing
|
||||
3. We'll provide you with pre-release builds and testing instructions
|
||||
|
||||
Your feedback helps us catch issues early and deliver better releases.
|
||||
|
||||
## Getting Help
|
||||
|
||||
- Discord: Join our [Discord community](https://discord.gg/SCtMCgtsc4) for real-time help
|
||||
- Issues: Check existing [GitHub issues](https://github.com/Cleanuparr/Cleanuparr/issues) or create a new one
|
||||
- Documentation: Review the [complete documentation](https://cleanuparr.github.io/Cleanuparr/)
|
||||
|
||||
## License
|
||||
|
||||
By contributing to Cleanuparr, you agree that your contributions will be licensed under the same license as the project.
|
||||
|
||||
---
|
||||
|
||||
Thanks for contributing to Cleanuparr!
|
||||
@@ -1,2 +0,0 @@
|
||||
thepirateheaven.org
|
||||
RARBG.work
|
||||
+12
-9
File diff suppressed because one or more lines are too long.
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 69 KiB |
@@ -1,10 +1,21 @@
|
||||
<div align="center">
|
||||
|
||||
_Love this project? Give it a ⭐️ and let others know!_
|
||||
|
||||
# <img width="24px" src="./Logo/256.png" alt="Cleanuparr"></img> Cleanuparr
|
||||
|
||||
_/kliː.nʌp.ər/ — like "cleanuper", someone who does the cleanup. Not "CleanupArr" or "CleanUpArr"._
|
||||
|
||||

|
||||

|
||||
[](https://github.com/Cleanuparr/Cleanuparr/actions/workflows/test.yml)
|
||||
|
||||
|
||||
[](https://discord.gg/SCtMCgtsc4)
|
||||
|
||||
Cleanuparr is a tool for automating the cleanup of unwanted or blocked files in Sonarr, Radarr, and supported download clients like qBittorrent. It removes incomplete or blocked downloads, updates queues, and enforces blacklists or whitelists to manage file selection. After removing blocked content, Cleanuparr can also trigger a search to replace the deleted shows/movies.
|
||||
</div>
|
||||
|
||||
Cleanuparr is an advanced download manager for the Servarr ecosystem. It works with Sonarr, Radarr, Lidarr, Readarr, and Whisparr alongside download clients like qBittorrent, Transmission, and Deluge. Beyond cleaning up stalled, blocked, and malicious downloads, it searches for missing content and quality upgrades, manages seeding, and removes orphaned files.
|
||||
|
||||
Cleanuparr was created primarily to address malicious files, such as `*.lnk` or `*.zipx`, that were getting stuck in Sonarr/Radarr and required manual intervention. Some of the reddit posts that made Cleanuparr come to life can be found [here](https://www.reddit.com/r/sonarr/comments/1gqnx16/psa_sonarr_downloaded_a_virus/), [here](https://www.reddit.com/r/sonarr/comments/1gqwklr/sonar_downloaded_a_mkv_file_which_looked_like_a/), [here](https://www.reddit.com/r/sonarr/comments/1gpw2wa/downloaded_waiting_to_import/) and [here](https://www.reddit.com/r/sonarr/comments/1gpi344/downloads_not_importing_no_files_found/).
|
||||
|
||||
@@ -15,27 +26,41 @@ Cleanuparr was created primarily to address malicious files, such as `*.lnk` or
|
||||
> - Remove and block downloads that are **failing to be imported** by the arrs.
|
||||
> - Remove and block downloads that are **stalled** or in **metadata downloading** state.
|
||||
> - Remove and block downloads that have a **low download speed** or **high estimated completion time**.
|
||||
> - Remove and block downloads blocked by qBittorrent or by Cleanuparr's **Content Blocker**.
|
||||
> - Remove and block downloads blocked by qBittorrent or by Cleanuparr's **Malware Blocker**.
|
||||
> - Remove and block known malware based on patterns found by the community.
|
||||
> - Automatically trigger a search for downloads removed from the arrs.
|
||||
> - Proactively search for **missing** items across your Radarr and Sonarr libraries.
|
||||
> - Search for **quality upgrades** for items that haven't met their quality profile's cutoff (a.k.a. **Cutoff Unmet**).
|
||||
> - Search for **custom format score upgrades** with automatic score tracking.
|
||||
> - Clean up downloads that have been **seeding** for a certain amount of time.
|
||||
> - Remove downloads that are **orphaned**/have no **hardlinks**/are not referenced by the arrs anymore (with [cross-seed](https://www.cross-seed.org/) support).
|
||||
> - Scan configured directories for **files not claimed by any active torrent**, move them to a dedicated orphaned directory, and optionally auto-purge.
|
||||
> - Notify on strike or download removal.
|
||||
> - Ignore certain torrent hashes, categories, tags or trackers from being processed by Cleanuparr.
|
||||
|
||||
## Sponsored by GitAds
|
||||
[](https://gitads.dev/v1/ad-track?source=cleanuparr/cleanuparr@github)
|
||||
|
||||
## Screenshots
|
||||
|
||||
https://cleanuparr.github.io/Cleanuparr/docs/screenshots
|
||||
|
||||
## 🎯 Supported Applications
|
||||
|
||||
### *Arr Applications
|
||||
### *Arr Applications (latest version)
|
||||
- **Sonarr**
|
||||
- **Radarr**
|
||||
- **Lidarr**
|
||||
- **Readarr**
|
||||
- **Whisparr**
|
||||
- **Whisparr v2**
|
||||
- **Whisparr v3**
|
||||
|
||||
### Download Clients
|
||||
### Download Clients (latest version)
|
||||
- **qBittorrent**
|
||||
- **Transmission**
|
||||
- **Deluge**
|
||||
- **µTorrent**
|
||||
- **rTorrent**
|
||||
|
||||
### Platforms
|
||||
- **Docker**
|
||||
@@ -58,7 +83,9 @@ docker run -d --name cleanuparr \
|
||||
ghcr.io/cleanuparr/cleanuparr:latest
|
||||
```
|
||||
|
||||
For Docker Compose, health checks, and other installation methods, see the [Complete Installation Guide](https://cleanuparr.github.io/Cleanuparr/docs/installation/detailed).
|
||||
For Docker Compose, health checks, and other installation methods, see the [Complete Installation Guide](https://cleanuparr.github.io/Cleanuparr/docs/installation/detailed), but not before reading the [Prerequisites](https://cleanuparr.github.io/Cleanuparr/docs/installation/).
|
||||
|
||||
> Prefer not to self-host? A managed Cleanuparr instance is available via [ElfHosted](https://store.elfhosted.com/product-category/personal-stacks/?utm_source=github&utm_medium=readme&utm_campaign=cleanuparr-readme), bundled alongside Sonarr/Radarr to keep your queues tidy (7-day trial).
|
||||
|
||||
### 🌐 Access the Web Interface
|
||||
|
||||
@@ -77,13 +104,16 @@ http://localhost:11011
|
||||
- **💬 [Discord Community](https://discord.gg/SCtMCgtsc4)** - Get help and discuss with other users
|
||||
- **🔗 [GitHub Releases](https://github.com/Cleanuparr/Cleanuparr/releases)** - Download binaries and view changelog
|
||||
|
||||
# <img style="vertical-align: middle;" width="24px" src="./Logo/256.png" alt="Cleanuparr"> <span style="vertical-align: middle;">Cleanuparr</span> <img src="https://raw.githubusercontent.com/FortAwesome/Font-Awesome/6.x/svgs/solid/x.svg" height="24px" width="30px" style="vertical-align: middle;"> <span style="vertical-align: middle;">Huntarr</span> <img style="vertical-align: middle;" width="24px" src="https://github.com/plexguide/Huntarr.io/blob/main/frontend/static/logo/512.png?raw=true" alt Huntarr></img>
|
||||
## 🤝 Contributing
|
||||
|
||||
Think of **Cleanuparr** as the janitor of your server; it keeps your download queue spotless, removes clutter, and blocks malicious files. Now imagine combining that with **Huntarr**, the compulsive librarian who finds missing and upgradable media to complete your collection
|
||||
We welcome contributions from the community! Whether it's bug fixes, new features, documentation improvements, or testing, your help is appreciated.
|
||||
|
||||
While **Huntarr** fills in the blanks and improves what you already have, **Cleanuparr** makes sure that only clean downloads get through. If you're aiming for a reliable and self-sufficient setup, **Cleanuparr** and **Huntarr** will take your automated media stack to another level.
|
||||
**Before contributing:** Please read our [Contributing Guide](CONTRIBUTING.md) and announce your intent to work on an issue before starting.
|
||||
|
||||
<span style="font-size:24px"> ➡️ [**Huntarr**](https://github.com/plexguide/Huntarr.io) <span style="vertical-align: middle"></span></span>
|
||||
- **[Contributing Guide](CONTRIBUTING.md)** - Learn how to set up your development environment and submit contributions
|
||||
- **[Report Issues](https://github.com/Cleanuparr/Cleanuparr/issues/new/choose)** - Found a bug? Let us know!
|
||||
- **[Feature Requests](https://github.com/Cleanuparr/Cleanuparr/issues/new/choose)** - Share your ideas for new features
|
||||
- **[Help Test Features](https://discord.gg/SCtMCgtsc4)** - Join Discord to test pre-release features and provide feedback
|
||||
|
||||
# Credits
|
||||
Special thanks for inspiration go to:
|
||||
@@ -95,4 +125,4 @@ Special thanks for inspiration go to:
|
||||
# Buy me a coffee
|
||||
If I made your life just a tiny bit easier, consider buying me a coffee!
|
||||
|
||||
<a href="https://buymeacoffee.com/flaminel" target="_blank"><img src="https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png" alt="Buy Me A Coffee" style="height: 41px !important;width: 174px !important;box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;-webkit-box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;" ></a>
|
||||
<a href="https://buymeacoffee.com/flaminel" target="_blank"><img src="https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png" alt="Buy Me A Coffee" style="height: 41px !important;width: 174px !important;box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;-webkit-box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;" ></a>
|
||||
+47
-1
@@ -38,4 +38,50 @@ backend/**/Tests/
|
||||
|
||||
# Development files
|
||||
docker-compose*.yml
|
||||
test/
|
||||
test/
|
||||
|
||||
# ================================
|
||||
# Node and build output
|
||||
# ================================
|
||||
node_modules
|
||||
dist
|
||||
out-tsc
|
||||
.angular
|
||||
.cache
|
||||
.tmp
|
||||
|
||||
# ================================
|
||||
# Testing & Coverage
|
||||
# ================================
|
||||
coverage
|
||||
jest
|
||||
cypress
|
||||
cypress/screenshots
|
||||
cypress/videos
|
||||
reports
|
||||
playwright-report
|
||||
.vite
|
||||
.vitepress
|
||||
|
||||
# ================================
|
||||
# Environment & log files
|
||||
# ================================
|
||||
*.env*
|
||||
!*.env.production
|
||||
*.log
|
||||
*.tsbuildinfo
|
||||
|
||||
# ================================
|
||||
# Docker & local orchestration
|
||||
# ================================
|
||||
Dockerfile
|
||||
Dockerfile.*
|
||||
.dockerignore
|
||||
docker-compose.yml
|
||||
docker-compose*.yml
|
||||
|
||||
# ================================
|
||||
# Miscellaneous
|
||||
# ================================
|
||||
*.bak
|
||||
*.old
|
||||
+22
-13
@@ -1,10 +1,12 @@
|
||||
# Build Angular frontend
|
||||
FROM --platform=$BUILDPLATFORM node:18-alpine AS frontend-build
|
||||
FROM --platform=$BUILDPLATFORM node:25-alpine AS frontend-build
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files first for better layer caching
|
||||
COPY frontend/package*.json ./
|
||||
RUN npm ci && npm install -g @angular/cli
|
||||
# Use cache mount for npm to speed up builds
|
||||
RUN --mount=type=cache,target=/root/.npm \
|
||||
npm ci && npm install -g @angular/cli
|
||||
|
||||
# Copy source code
|
||||
COPY frontend/ .
|
||||
@@ -13,7 +15,7 @@ COPY frontend/ .
|
||||
RUN npm run build
|
||||
|
||||
# Build .NET backend
|
||||
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:9.0-bookworm-slim AS build
|
||||
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
ARG TARGETARCH
|
||||
ARG VERSION=0.0.1
|
||||
ARG PACKAGES_USERNAME
|
||||
@@ -21,35 +23,42 @@ ARG PACKAGES_PAT
|
||||
WORKDIR /app
|
||||
EXPOSE 11011
|
||||
|
||||
# Copy solution and project files first for better layer caching
|
||||
# COPY backend/*.sln ./backend/
|
||||
# COPY backend/*/*.csproj ./backend/*/
|
||||
|
||||
# Copy source code
|
||||
COPY backend/ ./backend/
|
||||
|
||||
# Restore dependencies
|
||||
# Add NuGet source
|
||||
RUN dotnet nuget add source --username ${PACKAGES_USERNAME} --password ${PACKAGES_PAT} --store-password-in-clear-text --name Cleanuparr https://nuget.pkg.github.com/Cleanuparr/index.json
|
||||
|
||||
# Build and publish
|
||||
RUN dotnet publish ./backend/Cleanuparr.Api/Cleanuparr.Api.csproj \
|
||||
# Restore and publish with cache mount
|
||||
RUN --mount=type=cache,target=/root/.nuget/packages,sharing=locked \
|
||||
dotnet restore ./backend/Cleanuparr.Api/Cleanuparr.Api.csproj -a $TARGETARCH && \
|
||||
dotnet publish ./backend/Cleanuparr.Api/Cleanuparr.Api.csproj \
|
||||
-a $TARGETARCH \
|
||||
-c Release \
|
||||
-o /app/publish \
|
||||
--no-restore \
|
||||
/p:Version=${VERSION} \
|
||||
/p:PublishSingleFile=true \
|
||||
/p:DebugSymbols=false
|
||||
|
||||
# Runtime stage
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:9.0-bookworm-slim
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0
|
||||
|
||||
# Install required packages for user management and timezone support
|
||||
RUN apt-get update && apt-get install -y \
|
||||
# Install required packages for user management, timezone support, and Python for Apprise CLI
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
curl \
|
||||
tzdata \
|
||||
gosu \
|
||||
python3 \
|
||||
python3-venv \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Create virtual environment and install Apprise CLI
|
||||
ENV VIRTUAL_ENV=/opt/apprise-venv
|
||||
RUN python3 -m venv $VIRTUAL_ENV
|
||||
ENV PATH="$VIRTUAL_ENV/bin:$PATH"
|
||||
RUN pip install --no-cache-dir apprise==1.9.6
|
||||
|
||||
ENV PUID=1000 \
|
||||
PGID=1000 \
|
||||
UMASK=022 \
|
||||
|
||||
+27
-1
@@ -13,4 +13,30 @@ 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/Cleanuparr.Persistence.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
|
||||
|
||||
docker-build:
|
||||
ifndef tag
|
||||
$(error tag is required. Usage: make docker-build tag=latest version=1.0.0 user=... pat=...)
|
||||
endif
|
||||
ifndef version
|
||||
$(error version is required. Usage: make docker-build tag=latest version=1.0.0 user=... pat=...)
|
||||
endif
|
||||
ifndef user
|
||||
$(error user is required. Usage: make docker-build tag=latest version=1.0.0 user=... pat=...)
|
||||
endif
|
||||
ifndef pat
|
||||
$(error pat is required. Usage: make docker-build tag=latest version=1.0.0 user=... pat=...)
|
||||
endif
|
||||
DOCKER_BUILDKIT=1 docker build \
|
||||
--build-arg VERSION=$(version) \
|
||||
--build-arg PACKAGES_USERNAME=$(user) \
|
||||
--build-arg PACKAGES_PAT=$(pat) \
|
||||
-t cleanuparr:$(tag) \
|
||||
.
|
||||
@@ -0,0 +1,32 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="NSubstitute" Version="5.3.0" />
|
||||
<PackageReference Include="Shouldly" Version="4.3.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Cleanuparr.Api\Cleanuparr.Api.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,52 @@
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Xunit;
|
||||
|
||||
// Integration tests share file-system state (config-dir used by SetupGuardMiddleware),
|
||||
// so they must be run sequentially to avoid interference between factories.
|
||||
[assembly: CollectionBehavior(DisableTestParallelization = true)]
|
||||
|
||||
namespace Cleanuparr.Api.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Custom WebApplicationFactory that redirects all database contexts to an isolated temp directory
|
||||
/// </summary>
|
||||
public class CustomWebApplicationFactory : WebApplicationFactory<Program>
|
||||
{
|
||||
private readonly string _tempDir;
|
||||
|
||||
public CustomWebApplicationFactory()
|
||||
{
|
||||
_tempDir = Path.Combine(Path.GetTempPath(), $"cleanuparr-test-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_tempDir);
|
||||
|
||||
ConfigurationPathProvider.SetConfigPath(_tempDir);
|
||||
}
|
||||
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
builder.UseEnvironment("Testing");
|
||||
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
// Remove all hosted services (Quartz scheduler, BackgroundJobManager) to prevent
|
||||
// Quartz.Logging.LogProvider.ResolvedLogProvider (a cached Lazy<T>) from being accessed
|
||||
foreach (var hostedService in services.Where(d => d.ServiceType == typeof(IHostedService)).ToList())
|
||||
{
|
||||
services.Remove(hostedService);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
base.Dispose(disposing);
|
||||
|
||||
if (disposing && Directory.Exists(_tempDir))
|
||||
{
|
||||
try { Directory.Delete(_tempDir, true); } catch { /* best effort cleanup */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
+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,423 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Cleanuparr.Infrastructure.Features.Auth;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Auth;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Shouldly;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Features.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for the OIDC account linking flow (POST /api/account/oidc/link and
|
||||
/// GET /api/account/oidc/link/callback). Uses a mock IOidcAuthService that tracks the
|
||||
/// initiatorUserId passed from StartOidcLink so OidcLinkCallback can complete the flow.
|
||||
/// </summary>
|
||||
[Collection("Auth Integration Tests")]
|
||||
[TestCaseOrderer("Cleanuparr.Api.Tests.PriorityOrderer", "Cleanuparr.Api.Tests")]
|
||||
public class AccountControllerOidcTests : IClassFixture<AccountControllerOidcTests.OidcLinkWebApplicationFactory>
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
private readonly OidcLinkWebApplicationFactory _factory;
|
||||
|
||||
// Shared across ordered tests
|
||||
private static string? _accessToken;
|
||||
|
||||
public AccountControllerOidcTests(OidcLinkWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
_client = factory.CreateClient(new WebApplicationFactoryClientOptions
|
||||
{
|
||||
AllowAutoRedirect = false
|
||||
});
|
||||
|
||||
if (_accessToken is not null)
|
||||
{
|
||||
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _accessToken);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact, TestPriority(0)]
|
||||
public async Task Setup_CreateAccountAndComplete()
|
||||
{
|
||||
var createResponse = await _client.PostAsJsonAsync("/api/auth/setup/account", new
|
||||
{
|
||||
username = "linkadmin",
|
||||
password = "LinkPassword123!"
|
||||
});
|
||||
createResponse.StatusCode.ShouldBe(HttpStatusCode.Created);
|
||||
|
||||
var completeResponse = await _client.PostAsJsonAsync("/api/auth/setup/complete", new { });
|
||||
completeResponse.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(1)]
|
||||
public async Task Login_StoreAccessToken()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/login", new
|
||||
{
|
||||
username = "linkadmin",
|
||||
password = "LinkPassword123!"
|
||||
});
|
||||
|
||||
var bodyText = await response.Content.ReadAsStringAsync();
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK, $"Login failed. Body: {bodyText}");
|
||||
|
||||
var body = JsonSerializer.Deserialize<JsonElement>(bodyText);
|
||||
body.TryGetProperty("requiresTwoFactor", out var rtf)
|
||||
.ShouldBeTrue($"Missing 'requiresTwoFactor' in body: {bodyText}");
|
||||
rtf.GetBoolean().ShouldBeFalse();
|
||||
// Tokens are nested: { "requiresTwoFactor": false, "tokens": { "accessToken": "..." } }
|
||||
_accessToken = body.GetProperty("tokens").GetProperty("accessToken").GetString();
|
||||
_accessToken.ShouldNotBeNullOrEmpty();
|
||||
|
||||
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _accessToken);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(2)]
|
||||
public async Task OidcLink_WhenOidcDisabled_ReturnsBadRequest()
|
||||
{
|
||||
var response = await _client.PostAsync("/api/account/oidc/link", null);
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
body.GetProperty("detail").GetString().ShouldContain("OIDC is not enabled");
|
||||
}
|
||||
|
||||
[Fact, TestPriority(3)]
|
||||
public async Task EnableOidcConfig_ViaDirectDbUpdate()
|
||||
{
|
||||
await _factory.EnableOidcAsync();
|
||||
|
||||
var statusResponse = await _client.GetAsync("/api/auth/status");
|
||||
statusResponse.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var body = await statusResponse.Content.ReadFromJsonAsync<JsonElement>();
|
||||
body.GetProperty("oidcEnabled").GetBoolean().ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact, TestPriority(4)]
|
||||
public async Task OidcLink_WhenAuthenticated_ReturnsAuthorizationUrl()
|
||||
{
|
||||
var response = await _client.PostAsync("/api/account/oidc/link", null);
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
var authUrl = body.GetProperty("authorizationUrl").GetString();
|
||||
authUrl.ShouldNotBeNullOrEmpty();
|
||||
authUrl.ShouldContain("authorize");
|
||||
}
|
||||
|
||||
[Fact, TestPriority(5)]
|
||||
public async Task OidcLinkCallback_WithErrorParam_RedirectsToSettingsWithError()
|
||||
{
|
||||
var response = await _client.GetAsync("/api/account/oidc/link/callback?error=access_denied");
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Redirect);
|
||||
var location = response.Headers.Location?.ToString();
|
||||
location.ShouldNotBeNull();
|
||||
location.ShouldContain("/settings/account");
|
||||
location.ShouldContain("oidc_link_error=failed");
|
||||
}
|
||||
|
||||
[Fact, TestPriority(6)]
|
||||
public async Task OidcLinkCallback_MissingCodeOrState_RedirectsWithError()
|
||||
{
|
||||
var noParams = await _client.GetAsync("/api/account/oidc/link/callback");
|
||||
noParams.StatusCode.ShouldBe(HttpStatusCode.Redirect);
|
||||
noParams.Headers.Location?.ToString().ShouldContain("oidc_link_error=failed");
|
||||
|
||||
var onlyCode = await _client.GetAsync("/api/account/oidc/link/callback?code=some-code");
|
||||
onlyCode.StatusCode.ShouldBe(HttpStatusCode.Redirect);
|
||||
onlyCode.Headers.Location?.ToString().ShouldContain("oidc_link_error=failed");
|
||||
}
|
||||
|
||||
[Fact, TestPriority(7)]
|
||||
public async Task OidcLinkCallback_ValidFlow_SavesSubjectAndRedirectsToSuccess()
|
||||
{
|
||||
// First trigger StartOidcLink so the mock captures the initiatorUserId
|
||||
var linkResponse = await _client.PostAsync("/api/account/oidc/link", null);
|
||||
linkResponse.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
// Now simulate the IdP callback with the mock's success state
|
||||
var callbackResponse = await _client.GetAsync(
|
||||
$"/api/account/oidc/link/callback?code=valid-code&state={MockOidcAuthService.LinkSuccessState}");
|
||||
|
||||
callbackResponse.StatusCode.ShouldBe(HttpStatusCode.Redirect);
|
||||
var location = callbackResponse.Headers.Location?.ToString();
|
||||
location.ShouldNotBeNull();
|
||||
location.ShouldContain("/settings/account");
|
||||
location.ShouldContain("oidc_link=success");
|
||||
location.ShouldNotContain("oidc_link_error");
|
||||
|
||||
// Verify the subject was saved to config
|
||||
var savedSubject = await _factory.GetAuthorizedSubjectAsync();
|
||||
savedSubject.ShouldBe(MockOidcAuthService.LinkedSubject);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(8)]
|
||||
public async Task OidcLinkCallback_NoInitiatorUserId_RedirectsWithError()
|
||||
{
|
||||
var response = await _client.GetAsync(
|
||||
$"/api/account/oidc/link/callback?code=valid-code&state={MockOidcAuthService.NoInitiatorState}");
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Redirect);
|
||||
var location = response.Headers.Location?.ToString();
|
||||
location.ShouldNotBeNull();
|
||||
location.ShouldContain("oidc_link_error=failed");
|
||||
}
|
||||
|
||||
[Fact, TestPriority(9)]
|
||||
public async Task OidcLink_WhenUnauthenticated_ReturnsUnauthorized()
|
||||
{
|
||||
// Create a fresh unauthenticated client
|
||||
var unauthClient = _factory.CreateClient(new WebApplicationFactoryClientOptions
|
||||
{
|
||||
AllowAutoRedirect = false
|
||||
});
|
||||
|
||||
var response = await unauthClient.PostAsync("/api/account/oidc/link", null);
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
|
||||
}
|
||||
|
||||
#region Exclusive Mode
|
||||
|
||||
[Fact, TestPriority(10)]
|
||||
public async Task EnableExclusiveMode_ViaDirectDbUpdate()
|
||||
{
|
||||
await _factory.SetOidcExclusiveModeAsync(true);
|
||||
|
||||
var response = await _client.GetAsync("/api/auth/status");
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
body.GetProperty("oidcExclusiveMode").GetBoolean().ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact, TestPriority(11)]
|
||||
public async Task ChangePassword_Blocked_WhenExclusiveModeActive()
|
||||
{
|
||||
var response = await _client.PutAsJsonAsync("/api/account/password", new
|
||||
{
|
||||
currentPassword = "LinkPassword123!",
|
||||
newPassword = "NewPassword456!"
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Forbidden);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(12)]
|
||||
public async Task PlexLink_Blocked_WhenExclusiveModeActive()
|
||||
{
|
||||
var response = await _client.PostAsync("/api/account/plex/link", null);
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Forbidden);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(13)]
|
||||
public async Task PlexUnlink_Blocked_WhenExclusiveModeActive()
|
||||
{
|
||||
var response = await _client.DeleteAsync("/api/account/plex/link");
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Forbidden);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(14)]
|
||||
public async Task OidcConfigUpdate_StillWorks_WhenExclusiveModeActive()
|
||||
{
|
||||
var response = await _client.PutAsJsonAsync("/api/account/oidc", new
|
||||
{
|
||||
enabled = true,
|
||||
issuerUrl = "https://mock-oidc-provider.test",
|
||||
clientId = "test-client",
|
||||
clientSecret = "test-secret",
|
||||
scopes = "openid profile email",
|
||||
authorizedSubject = MockOidcAuthService.LinkedSubject,
|
||||
providerName = "TestProvider",
|
||||
redirectUrl = "",
|
||||
exclusiveMode = true
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(15)]
|
||||
public async Task OidcUnlink_ResetsExclusiveMode()
|
||||
{
|
||||
var response = await _client.DeleteAsync("/api/account/oidc/link");
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
// Verify exclusive mode was reset
|
||||
var exclusiveMode = await _factory.GetExclusiveModeAsync();
|
||||
exclusiveMode.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact, TestPriority(16)]
|
||||
public async Task DisableExclusiveMode_PasswordChangeWorks_Again()
|
||||
{
|
||||
// Re-enable OIDC with a linked subject but without exclusive mode
|
||||
await _factory.EnableOidcAsync();
|
||||
await _factory.SetOidcExclusiveModeAsync(false);
|
||||
|
||||
var response = await _client.PutAsJsonAsync("/api/account/password", new
|
||||
{
|
||||
currentPassword = "LinkPassword123!",
|
||||
newPassword = "NewPassword789!"
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Test Infrastructure
|
||||
|
||||
public class OidcLinkWebApplicationFactory : CustomWebApplicationFactory
|
||||
{
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
base.ConfigureWebHost(builder);
|
||||
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
var oidcDescriptor = services.SingleOrDefault(d => d.ServiceType == typeof(IOidcAuthService));
|
||||
if (oidcDescriptor != null) services.Remove(oidcDescriptor);
|
||||
|
||||
services.AddSingleton<IOidcAuthService, MockOidcAuthService>();
|
||||
});
|
||||
}
|
||||
|
||||
public async Task EnableOidcAsync()
|
||||
{
|
||||
using var scope = Services.CreateScope();
|
||||
var usersContext = scope.ServiceProvider.GetRequiredService<UsersContext>();
|
||||
|
||||
var user = await usersContext.Users.FirstOrDefaultAsync();
|
||||
if (user is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
user.Oidc = new OidcConfig
|
||||
{
|
||||
Enabled = true,
|
||||
IssuerUrl = "https://mock-oidc-provider.test",
|
||||
ClientId = "test-client",
|
||||
ClientSecret = "test-secret",
|
||||
Scopes = "openid profile email",
|
||||
AuthorizedSubject = "initial-subject",
|
||||
ProviderName = "TestProvider"
|
||||
};
|
||||
|
||||
await usersContext.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task<string?> GetAuthorizedSubjectAsync()
|
||||
{
|
||||
using var scope = Services.CreateScope();
|
||||
var usersContext = scope.ServiceProvider.GetRequiredService<UsersContext>();
|
||||
|
||||
var user = await usersContext.Users.AsNoTracking().FirstOrDefaultAsync();
|
||||
return user?.Oidc.AuthorizedSubject;
|
||||
}
|
||||
|
||||
public async Task SetOidcExclusiveModeAsync(bool enabled)
|
||||
{
|
||||
using var scope = Services.CreateScope();
|
||||
var usersContext = scope.ServiceProvider.GetRequiredService<UsersContext>();
|
||||
|
||||
var user = await usersContext.Users.FirstOrDefaultAsync();
|
||||
if (user is not null)
|
||||
{
|
||||
user.Oidc.ExclusiveMode = enabled;
|
||||
await usersContext.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> GetExclusiveModeAsync()
|
||||
{
|
||||
using var scope = Services.CreateScope();
|
||||
var usersContext = scope.ServiceProvider.GetRequiredService<UsersContext>();
|
||||
|
||||
var user = await usersContext.Users.AsNoTracking().FirstOrDefaultAsync();
|
||||
return user?.Oidc.ExclusiveMode ?? false;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private sealed class MockOidcAuthService : IOidcAuthService
|
||||
{
|
||||
public const string LinkSuccessState = "mock-link-success-state";
|
||||
public const string NoInitiatorState = "mock-no-initiator-state";
|
||||
public const string LinkedSubject = "newly-linked-subject-123";
|
||||
|
||||
private string? _lastInitiatorUserId;
|
||||
private readonly ConcurrentDictionary<string, OidcTokenExchangeResult> _oneTimeCodes = new();
|
||||
|
||||
public Task<OidcAuthorizationResult> StartAuthorization(string redirectUri, string? initiatorUserId = null)
|
||||
{
|
||||
_lastInitiatorUserId = initiatorUserId;
|
||||
return Task.FromResult(new OidcAuthorizationResult
|
||||
{
|
||||
AuthorizationUrl = $"https://mock-oidc-provider.test/authorize?state={LinkSuccessState}",
|
||||
State = LinkSuccessState
|
||||
});
|
||||
}
|
||||
|
||||
public Task<OidcCallbackResult> HandleCallback(string code, string state, string redirectUri)
|
||||
{
|
||||
if (state == LinkSuccessState)
|
||||
{
|
||||
return Task.FromResult(new OidcCallbackResult
|
||||
{
|
||||
Success = true,
|
||||
Subject = LinkedSubject,
|
||||
PreferredUsername = "linkuser",
|
||||
Email = "link@example.com",
|
||||
InitiatorUserId = _lastInitiatorUserId
|
||||
});
|
||||
}
|
||||
|
||||
if (state == NoInitiatorState)
|
||||
{
|
||||
return Task.FromResult(new OidcCallbackResult
|
||||
{
|
||||
Success = true,
|
||||
Subject = LinkedSubject,
|
||||
InitiatorUserId = null // No initiator — controller should redirect with error
|
||||
});
|
||||
}
|
||||
|
||||
return Task.FromResult(new OidcCallbackResult
|
||||
{
|
||||
Success = false,
|
||||
Error = "Invalid or expired OIDC state"
|
||||
});
|
||||
}
|
||||
|
||||
public string StoreOneTimeCode(string accessToken, string refreshToken, int expiresIn)
|
||||
{
|
||||
var code = Guid.NewGuid().ToString("N");
|
||||
_oneTimeCodes.TryAdd(code, new OidcTokenExchangeResult
|
||||
{
|
||||
AccessToken = accessToken,
|
||||
RefreshToken = refreshToken,
|
||||
ExpiresIn = expiresIn
|
||||
});
|
||||
return code;
|
||||
}
|
||||
|
||||
public OidcTokenExchangeResult? ExchangeOneTimeCode(string code) =>
|
||||
_oneTimeCodes.TryRemove(code, out var result) ? result : null;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Shouldly;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Features.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for the authentication flow.
|
||||
/// Uses a single shared factory to avoid static state conflicts.
|
||||
/// Tests are ordered to build on each other: setup → login → protected endpoints.
|
||||
/// </summary>
|
||||
[Collection("Auth Integration Tests")]
|
||||
[TestCaseOrderer("Cleanuparr.Api.Tests.PriorityOrderer", "Cleanuparr.Api.Tests")]
|
||||
public class AuthControllerTests : IClassFixture<CustomWebApplicationFactory>
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
|
||||
public AuthControllerTests(CustomWebApplicationFactory factory)
|
||||
{
|
||||
_client = factory.CreateClient();
|
||||
}
|
||||
|
||||
[Fact, TestPriority(0)]
|
||||
public async Task GetStatus_BeforeSetup_ReturnsNotCompleted()
|
||||
{
|
||||
var response = await _client.GetAsync("/api/auth/status");
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
body.GetProperty("setupCompleted").GetBoolean().ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact, TestPriority(0)]
|
||||
public async Task AuthEndpoints_AlwaysReturnNoCacheHeaders()
|
||||
{
|
||||
var response = await _client.GetAsync("/api/auth/status");
|
||||
|
||||
response.Headers.CacheControl.ShouldNotBeNull();
|
||||
response.Headers.CacheControl!.NoCache.ShouldBeTrue();
|
||||
response.Headers.CacheControl!.NoStore.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact, TestPriority(1)]
|
||||
public async Task Setup_CreateAccount_ReturnsCreated()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/setup/account", new
|
||||
{
|
||||
username = "admin",
|
||||
password = "TestPassword123!"
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Created);
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
body.GetProperty("userId").GetString().ShouldNotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact, TestPriority(2)]
|
||||
public async Task Setup_CreateDuplicateAccount_ReturnsConflict()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/setup/account", new
|
||||
{
|
||||
username = "another",
|
||||
password = "TestPassword123!"
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Conflict);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(3)]
|
||||
public async Task Setup_Generate2FA_ReturnsSecretAndRecoveryCodes()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/setup/2fa/generate", new { });
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
body.GetProperty("secret").GetString().ShouldNotBeNullOrEmpty();
|
||||
body.GetProperty("qrCodeUri").GetString().ShouldNotBeNullOrEmpty();
|
||||
body.GetProperty("recoveryCodes").GetArrayLength().ShouldBeGreaterThan(0);
|
||||
|
||||
// Store the secret for the next test
|
||||
_totpSecret = body.GetProperty("secret").GetString()!;
|
||||
}
|
||||
|
||||
[Fact, TestPriority(4)]
|
||||
public async Task Setup_Verify2FA_WithValidCode_Succeeds()
|
||||
{
|
||||
// If we don't have the secret from the previous test, generate it again
|
||||
if (string.IsNullOrEmpty(_totpSecret))
|
||||
{
|
||||
var genResponse = await _client.PostAsJsonAsync("/api/auth/setup/2fa/generate", new { });
|
||||
var genBody = await genResponse.Content.ReadFromJsonAsync<JsonElement>();
|
||||
_totpSecret = genBody.GetProperty("secret").GetString()!;
|
||||
}
|
||||
|
||||
var code = GenerateTotpCode(_totpSecret);
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/setup/2fa/verify", new { code });
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(5)]
|
||||
public async Task Setup_Complete_Succeeds()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/setup/complete", new { });
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(6)]
|
||||
public async Task Login_ValidCredentials_RequiresTwoFactor()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/login", new
|
||||
{
|
||||
username = "admin",
|
||||
password = "TestPassword123!"
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
body.GetProperty("requiresTwoFactor").GetBoolean().ShouldBeTrue();
|
||||
body.GetProperty("loginToken").GetString().ShouldNotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact, TestPriority(7)]
|
||||
public async Task Login_InvalidCredentials_ReturnsUnauthorized()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/login", new
|
||||
{
|
||||
username = "admin",
|
||||
password = "WrongPassword!"
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(8)]
|
||||
public async Task Login_BruteForce_ReturnsRetryAfter()
|
||||
{
|
||||
// Make multiple failed attempts
|
||||
for (int i = 0; i < 3; i++)
|
||||
{
|
||||
await _client.PostAsJsonAsync("/api/auth/login", new
|
||||
{
|
||||
username = "admin",
|
||||
password = "WrongPassword!"
|
||||
});
|
||||
}
|
||||
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/login", new
|
||||
{
|
||||
username = "admin",
|
||||
password = "WrongPassword!"
|
||||
});
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.TooManyRequests)
|
||||
{
|
||||
body.GetProperty("retryAfterSeconds").GetInt32().ShouldBeGreaterThan(0);
|
||||
}
|
||||
else
|
||||
{
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
|
||||
body.TryGetProperty("retryAfterSeconds", out var retry).ShouldBeTrue();
|
||||
retry.GetInt32().ShouldBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact, TestPriority(9)]
|
||||
public async Task ProtectedEndpoint_WithoutAuth_DeniesAccess()
|
||||
{
|
||||
var response = await _client.GetAsync("/api/account");
|
||||
|
||||
// 401 (FallbackPolicy) or 403 (SetupGuardMiddleware) - both deny unauthenticated access
|
||||
new[] { HttpStatusCode.Unauthorized, HttpStatusCode.Forbidden }
|
||||
.ShouldContain(response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(10)]
|
||||
public async Task HealthEndpoint_WithoutAuth_Returns200()
|
||||
{
|
||||
var response = await _client.GetAsync("/health");
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(11)]
|
||||
public async Task Setup_2FAGenerate_AfterCompletion_IsBlocked()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/setup/2fa/generate", new { });
|
||||
|
||||
// Blocked by middleware (403) or controller defense-in-depth (409)
|
||||
new[] { HttpStatusCode.Forbidden, HttpStatusCode.Conflict }
|
||||
.ShouldContain(response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(12)]
|
||||
public async Task Setup_PlexPin_AfterCompletion_IsBlocked()
|
||||
{
|
||||
var response = await _client.PostAsync("/api/auth/setup/plex/pin", null);
|
||||
|
||||
// Blocked by middleware (403) or controller defense-in-depth (409)
|
||||
new[] { HttpStatusCode.Forbidden, HttpStatusCode.Conflict }
|
||||
.ShouldContain(response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(13)]
|
||||
public async Task Setup_Complete_AfterCompletion_IsBlocked()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/setup/complete", new { });
|
||||
|
||||
// Blocked by middleware (403) or controller defense-in-depth (409)
|
||||
new[] { HttpStatusCode.Forbidden, HttpStatusCode.Conflict }
|
||||
.ShouldContain(response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(14)]
|
||||
public async Task Login_NotBlockedByMiddleware_AfterSetupEndpointsBlocked()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/login", new
|
||||
{
|
||||
username = "admin",
|
||||
password = "TestPassword123!"
|
||||
});
|
||||
|
||||
// Login endpoint must NOT be blocked by the middleware (403).
|
||||
// It may return OK (200) or TooManyRequests (429) due to brute force lockout from earlier tests.
|
||||
response.StatusCode.ShouldNotBe(HttpStatusCode.Forbidden);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(15)]
|
||||
public async Task AuthStatus_StillWorks_AfterSetupEndpointsBlocked()
|
||||
{
|
||||
var response = await _client.GetAsync("/api/auth/status");
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
body.GetProperty("setupCompleted").GetBoolean().ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact, TestPriority(16)]
|
||||
public async Task OidcExchange_WithNonexistentCode_ReturnsNotFound()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/oidc/exchange", new
|
||||
{
|
||||
code = "nonexistent-one-time-code"
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.NotFound);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(17)]
|
||||
public async Task AuthStatus_IncludesOidcFields()
|
||||
{
|
||||
var response = await _client.GetAsync("/api/auth/status");
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
// Verify OIDC fields exist in the response (values depend on shared static DB state)
|
||||
body.TryGetProperty("oidcEnabled", out _).ShouldBeTrue();
|
||||
body.TryGetProperty("oidcProviderName", out _).ShouldBeTrue();
|
||||
}
|
||||
|
||||
#region TOTP helpers
|
||||
|
||||
private static string _totpSecret = "";
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using Cleanuparr.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Shouldly;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Features.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Tests that the login endpoint always runs BCrypt verification regardless of
|
||||
/// username validity, preventing timing-based username enumeration.
|
||||
/// </summary>
|
||||
[Collection("Login Timing Tests")]
|
||||
[TestCaseOrderer("Cleanuparr.Api.Tests.PriorityOrderer", "Cleanuparr.Api.Tests")]
|
||||
public class LoginTimingTests : IClassFixture<TimingTestWebApplicationFactory>
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
private readonly TimingTestWebApplicationFactory _factory;
|
||||
|
||||
public LoginTimingTests(TimingTestWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
_client = factory.CreateClient();
|
||||
}
|
||||
|
||||
[Fact, TestPriority(0)]
|
||||
public async Task Login_NoUserExists_StillCallsPasswordVerification()
|
||||
{
|
||||
_factory.TrackingPasswordService.Reset();
|
||||
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/login", new
|
||||
{
|
||||
username = "nouser",
|
||||
password = "SomePassword123!"
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
|
||||
_factory.TrackingPasswordService.VerifyPasswordCallCount.ShouldBeGreaterThanOrEqualTo(1);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(1)]
|
||||
public async Task Setup_CreateAccountAndComplete()
|
||||
{
|
||||
var createResponse = await _client.PostAsJsonAsync("/api/auth/setup/account", new
|
||||
{
|
||||
username = "timingtest",
|
||||
password = "TimingTestPassword123!"
|
||||
});
|
||||
createResponse.StatusCode.ShouldBe(HttpStatusCode.Created);
|
||||
|
||||
var completeResponse = await _client.PostAsJsonAsync("/api/auth/setup/complete", new { });
|
||||
completeResponse.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(2)]
|
||||
public async Task Login_ValidUsername_CallsPasswordVerification()
|
||||
{
|
||||
_factory.TrackingPasswordService.Reset();
|
||||
|
||||
await _client.PostAsJsonAsync("/api/auth/login", new
|
||||
{
|
||||
username = "timingtest",
|
||||
password = "TimingTestPassword123!"
|
||||
});
|
||||
|
||||
_factory.TrackingPasswordService.VerifyPasswordCallCount.ShouldBeGreaterThanOrEqualTo(1);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(3)]
|
||||
public async Task Login_NonexistentUsername_StillCallsPasswordVerification()
|
||||
{
|
||||
_factory.TrackingPasswordService.Reset();
|
||||
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/login", new
|
||||
{
|
||||
username = "doesnotexist",
|
||||
password = "SomePassword123!"
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
|
||||
_factory.TrackingPasswordService.VerifyPasswordCallCount.ShouldBeGreaterThanOrEqualTo(1);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(4)]
|
||||
public async Task Login_LockedOutUser_StillCallsPasswordVerification()
|
||||
{
|
||||
// Set lockout state directly in the database to avoid timing sensitivity
|
||||
using (var scope = _factory.Services.CreateScope())
|
||||
{
|
||||
var context = scope.ServiceProvider.GetRequiredService<UsersContext>();
|
||||
var user = await context.Users.FirstAsync();
|
||||
user.FailedLoginAttempts = 5;
|
||||
user.LockoutEnd = DateTime.UtcNow.AddMinutes(5);
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
_factory.TrackingPasswordService.Reset();
|
||||
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/login", new
|
||||
{
|
||||
username = "timingtest",
|
||||
password = "WrongPassword!"
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.TooManyRequests);
|
||||
_factory.TrackingPasswordService.VerifyPasswordCallCount.ShouldBeGreaterThanOrEqualTo(1);
|
||||
|
||||
// Reset lockout for subsequent tests
|
||||
using (var scope = _factory.Services.CreateScope())
|
||||
{
|
||||
var context = scope.ServiceProvider.GetRequiredService<UsersContext>();
|
||||
var user = await context.Users.FirstAsync();
|
||||
user.FailedLoginAttempts = 0;
|
||||
user.LockoutEnd = null;
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact, TestPriority(5)]
|
||||
public async Task Login_TimingConsistency_InvalidAndValidUsernamesTakeSimilarTime()
|
||||
{
|
||||
const int iterations = 10;
|
||||
|
||||
// Warm up the server and BCrypt static init
|
||||
await _client.PostAsJsonAsync("/api/auth/login", new
|
||||
{
|
||||
username = "warmup",
|
||||
password = "WarmupPassword123!"
|
||||
});
|
||||
|
||||
var invalidTimings = new List<long>(iterations);
|
||||
var validTimings = new List<long>(iterations);
|
||||
|
||||
for (var i = 0; i < iterations; i++)
|
||||
{
|
||||
// Alternate to avoid ordering bias
|
||||
var invalidSw = Stopwatch.StartNew();
|
||||
await _client.PostAsJsonAsync("/api/auth/login", new
|
||||
{
|
||||
username = $"nonexistent_{i}",
|
||||
password = "SomePassword123!"
|
||||
});
|
||||
invalidSw.Stop();
|
||||
invalidTimings.Add(invalidSw.ElapsedMilliseconds);
|
||||
|
||||
var validSw = Stopwatch.StartNew();
|
||||
await _client.PostAsJsonAsync("/api/auth/login", new
|
||||
{
|
||||
username = "timingtest",
|
||||
password = "WrongPasswordForTiming!"
|
||||
});
|
||||
validSw.Stop();
|
||||
validTimings.Add(validSw.ElapsedMilliseconds);
|
||||
}
|
||||
|
||||
var invalidMedian = Median(invalidTimings);
|
||||
var validMedian = Median(validTimings);
|
||||
|
||||
// The invalid-username path must not be suspiciously fast
|
||||
invalidMedian.ShouldBeGreaterThan(50,
|
||||
$"Non-existent username median too fast ({invalidMedian}ms) — BCrypt may have been skipped");
|
||||
|
||||
// Medians should be in the same ballpark
|
||||
var ratio = invalidMedian > validMedian
|
||||
? (double)invalidMedian / validMedian
|
||||
: (double)validMedian / invalidMedian;
|
||||
|
||||
ratio.ShouldBeLessThan(3.0,
|
||||
$"Timing difference too large: invalid median={invalidMedian}ms, valid median={validMedian}ms (ratio={ratio:F1}x)");
|
||||
}
|
||||
|
||||
private static long Median(List<long> values)
|
||||
{
|
||||
values.Sort();
|
||||
var mid = values.Count / 2;
|
||||
return values.Count % 2 == 0
|
||||
? (values[mid - 1] + values[mid]) / 2
|
||||
: values[mid];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,630 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Cleanuparr.Infrastructure.Features.Auth;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Auth;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Shouldly;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Features.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for the OIDC authentication flow.
|
||||
/// Uses a mock IOidcAuthService to simulate IdP behavior.
|
||||
/// Tests are ordered to build on each other: setup → enable OIDC → test flow.
|
||||
/// </summary>
|
||||
[Collection("Auth Integration Tests")]
|
||||
[TestCaseOrderer("Cleanuparr.Api.Tests.PriorityOrderer", "Cleanuparr.Api.Tests")]
|
||||
public class OidcAuthControllerTests : IClassFixture<OidcAuthControllerTests.OidcWebApplicationFactory>
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
private readonly OidcWebApplicationFactory _factory;
|
||||
|
||||
public OidcAuthControllerTests(OidcWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
_client = factory.CreateClient(new WebApplicationFactoryClientOptions
|
||||
{
|
||||
AllowAutoRedirect = false // We want to inspect redirects
|
||||
});
|
||||
}
|
||||
|
||||
[Fact, TestPriority(0)]
|
||||
public async Task OidcStart_BeforeSetup_ReturnsBadRequest()
|
||||
{
|
||||
var response = await _client.PostAsync("/api/auth/oidc/start", null);
|
||||
|
||||
// OIDC start is on /api/auth/ path (not blocked by SetupGuardMiddleware)
|
||||
// but the controller returns BadRequest because OIDC is not configured
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(1)]
|
||||
public async Task Setup_CreateAccountAndComplete()
|
||||
{
|
||||
// Create account
|
||||
var createResponse = await _client.PostAsJsonAsync("/api/auth/setup/account", new
|
||||
{
|
||||
username = "admin",
|
||||
password = "TestPassword123!"
|
||||
});
|
||||
createResponse.StatusCode.ShouldBe(HttpStatusCode.Created);
|
||||
|
||||
// Complete setup (skip 2FA for this test suite)
|
||||
var completeResponse = await _client.PostAsJsonAsync("/api/auth/setup/complete", new { });
|
||||
completeResponse.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(2)]
|
||||
public async Task OidcStart_WhenDisabled_ReturnsBadRequest()
|
||||
{
|
||||
var response = await _client.PostAsync("/api/auth/oidc/start", null);
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
|
||||
response.Content.Headers.ContentType!.MediaType.ShouldBe("application/problem+json");
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
body.GetProperty("detail").GetString()!.ShouldContain("OIDC is not enabled");
|
||||
body.GetProperty("traceId").GetString().ShouldNotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact, TestPriority(3)]
|
||||
public async Task OidcExchange_WhenDisabled_ReturnsNotFound()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/oidc/exchange", new
|
||||
{
|
||||
code = "some-random-code"
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.NotFound);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(4)]
|
||||
public async Task OidcCallback_WithErrorParam_RedirectsToLoginWithError()
|
||||
{
|
||||
var response = await _client.GetAsync("/api/auth/oidc/callback?error=access_denied");
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Redirect);
|
||||
var location = response.Headers.Location?.ToString();
|
||||
location.ShouldNotBeNull();
|
||||
location.ShouldContain("/auth/login");
|
||||
location.ShouldContain("oidc_error=provider_error");
|
||||
}
|
||||
|
||||
[Fact, TestPriority(5)]
|
||||
public async Task OidcCallback_WithoutCodeOrState_RedirectsToLoginWithError()
|
||||
{
|
||||
var response = await _client.GetAsync("/api/auth/oidc/callback");
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Redirect);
|
||||
var location = response.Headers.Location?.ToString();
|
||||
location.ShouldNotBeNull();
|
||||
location.ShouldContain("oidc_error=invalid_request");
|
||||
}
|
||||
|
||||
[Fact, TestPriority(6)]
|
||||
public async Task OidcCallback_WithOnlyCode_RedirectsToLoginWithError()
|
||||
{
|
||||
var response = await _client.GetAsync("/api/auth/oidc/callback?code=some-code");
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Redirect);
|
||||
var location = response.Headers.Location?.ToString();
|
||||
location.ShouldNotBeNull();
|
||||
location.ShouldContain("oidc_error=invalid_request");
|
||||
}
|
||||
|
||||
[Fact, TestPriority(7)]
|
||||
public async Task OidcCallback_WithInvalidState_RedirectsToLoginWithError()
|
||||
{
|
||||
// Even with code and state, if the state is invalid the mock will return failure
|
||||
var response = await _client.GetAsync("/api/auth/oidc/callback?code=some-code&state=invalid-state");
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Redirect);
|
||||
var location = response.Headers.Location?.ToString();
|
||||
location.ShouldNotBeNull();
|
||||
location.ShouldContain("oidc_error=authentication_failed");
|
||||
}
|
||||
|
||||
[Fact, TestPriority(8)]
|
||||
public async Task EnableOidcConfig_ViaDirectDbUpdate()
|
||||
{
|
||||
// Simulate enabling OIDC via direct DB manipulation (since we'd normally do this through settings UI)
|
||||
await _factory.EnableOidcAsync();
|
||||
|
||||
// Verify auth status reflects OIDC enabled
|
||||
var response = await _client.GetAsync("/api/auth/status");
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
body.GetProperty("oidcEnabled").GetBoolean().ShouldBeTrue();
|
||||
body.GetProperty("oidcProviderName").GetString().ShouldBe("TestProvider");
|
||||
}
|
||||
|
||||
[Fact, TestPriority(9)]
|
||||
public async Task OidcStart_WhenEnabled_ReturnsAuthorizationUrl()
|
||||
{
|
||||
var response = await _client.PostAsync("/api/auth/oidc/start", null);
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
var authUrl = body.GetProperty("authorizationUrl").GetString();
|
||||
authUrl.ShouldNotBeNullOrEmpty();
|
||||
authUrl.ShouldContain("authorize");
|
||||
}
|
||||
|
||||
[Fact, TestPriority(10)]
|
||||
public async Task OidcCallback_ValidFlow_RedirectsWithOneTimeCode()
|
||||
{
|
||||
// Use the mock's valid state to simulate a successful callback
|
||||
var response = await _client.GetAsync(
|
||||
$"/api/auth/oidc/callback?code=valid-auth-code&state={MockOidcAuthService.ValidState}");
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Redirect);
|
||||
var location = response.Headers.Location?.ToString();
|
||||
location.ShouldNotBeNull();
|
||||
location.ShouldContain("/auth/oidc/callback");
|
||||
location.ShouldContain("code=");
|
||||
// Should NOT contain oidc_error
|
||||
location.ShouldNotContain("oidc_error");
|
||||
}
|
||||
|
||||
[Fact, TestPriority(11)]
|
||||
public async Task OidcExchange_ValidOneTimeCode_ReturnsTokens()
|
||||
{
|
||||
// First, trigger a valid callback to get a one-time code
|
||||
var callbackResponse = await _client.GetAsync(
|
||||
$"/api/auth/oidc/callback?code=valid-auth-code&state={MockOidcAuthService.ValidState}");
|
||||
callbackResponse.StatusCode.ShouldBe(HttpStatusCode.Redirect);
|
||||
|
||||
var location = callbackResponse.Headers.Location?.ToString();
|
||||
location.ShouldNotBeNull();
|
||||
|
||||
// Extract the one-time code from the redirect URL
|
||||
var uri = new Uri("http://localhost" + location);
|
||||
var queryParams = System.Web.HttpUtility.ParseQueryString(uri.Query);
|
||||
var oneTimeCode = queryParams["code"];
|
||||
oneTimeCode.ShouldNotBeNullOrEmpty();
|
||||
|
||||
// Exchange the one-time code for tokens
|
||||
var exchangeResponse = await _client.PostAsJsonAsync("/api/auth/oidc/exchange", new
|
||||
{
|
||||
code = oneTimeCode
|
||||
});
|
||||
|
||||
exchangeResponse.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var body = await exchangeResponse.Content.ReadFromJsonAsync<JsonElement>();
|
||||
body.GetProperty("accessToken").GetString().ShouldNotBeNullOrEmpty();
|
||||
body.GetProperty("refreshToken").GetString().ShouldNotBeNullOrEmpty();
|
||||
body.GetProperty("expiresIn").GetInt32().ShouldBeGreaterThan(0);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(12)]
|
||||
public async Task OidcExchange_SameCodeTwice_SecondFails()
|
||||
{
|
||||
// First, trigger a valid callback
|
||||
var callbackResponse = await _client.GetAsync(
|
||||
$"/api/auth/oidc/callback?code=valid-auth-code&state={MockOidcAuthService.ValidState}");
|
||||
var location = callbackResponse.Headers.Location?.ToString()!;
|
||||
var uri = new Uri("http://localhost" + location);
|
||||
var queryParams = System.Web.HttpUtility.ParseQueryString(uri.Query);
|
||||
var oneTimeCode = queryParams["code"]!;
|
||||
|
||||
// First exchange succeeds
|
||||
var response1 = await _client.PostAsJsonAsync("/api/auth/oidc/exchange", new { code = oneTimeCode });
|
||||
response1.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
// Second exchange with same code fails
|
||||
var response2 = await _client.PostAsJsonAsync("/api/auth/oidc/exchange", new { code = oneTimeCode });
|
||||
response2.StatusCode.ShouldBe(HttpStatusCode.NotFound);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(13)]
|
||||
public async Task OidcExchange_InvalidCode_ReturnsNotFound()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/oidc/exchange", new
|
||||
{
|
||||
code = "completely-invalid-code"
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.NotFound);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(14)]
|
||||
public async Task OidcCallback_UnauthorizedSubject_RedirectsWithError()
|
||||
{
|
||||
// Use the mock's state that returns a different subject
|
||||
var response = await _client.GetAsync(
|
||||
$"/api/auth/oidc/callback?code=valid-auth-code&state={MockOidcAuthService.WrongSubjectState}");
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Redirect);
|
||||
var location = response.Headers.Location?.ToString();
|
||||
location.ShouldNotBeNull();
|
||||
location.ShouldContain("oidc_error=unauthorized");
|
||||
}
|
||||
|
||||
[Fact, TestPriority(15)]
|
||||
public async Task AuthStatus_IncludesOidcFields()
|
||||
{
|
||||
var response = await _client.GetAsync("/api/auth/status");
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
body.GetProperty("setupCompleted").GetBoolean().ShouldBeTrue();
|
||||
body.GetProperty("oidcEnabled").GetBoolean().ShouldBeTrue();
|
||||
body.GetProperty("oidcProviderName").GetString().ShouldBe("TestProvider");
|
||||
}
|
||||
|
||||
[Fact, TestPriority(16)]
|
||||
public async Task PasswordLogin_StillWorks_AfterOidcEnabled()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/login", new
|
||||
{
|
||||
username = "admin",
|
||||
password = "TestPassword123!"
|
||||
});
|
||||
|
||||
// Should succeed (no 2FA since we skipped it in setup)
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
// No 2FA, so should have tokens directly
|
||||
body.GetProperty("requiresTwoFactor").GetBoolean().ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact, TestPriority(17)]
|
||||
public async Task OidcStatus_WhenSubjectCleared_StillEnabled()
|
||||
{
|
||||
// Clearing the authorized subject should NOT disable OIDC — it just means any user can log in
|
||||
await _factory.SetOidcAuthorizedSubjectAsync("");
|
||||
|
||||
var response = await _client.GetAsync("/api/auth/status");
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
body.GetProperty("oidcEnabled").GetBoolean().ShouldBeTrue();
|
||||
|
||||
// Restore for subsequent tests
|
||||
await _factory.SetOidcAuthorizedSubjectAsync(MockOidcAuthService.AuthorizedSubject);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(17)]
|
||||
public async Task OidcStatus_WhenMissingIssuerUrl_ReturnsFalse()
|
||||
{
|
||||
// OIDC should be disabled when essential config (IssuerUrl) is missing
|
||||
await _factory.SetOidcIssuerUrlAsync("");
|
||||
|
||||
var response = await _client.GetAsync("/api/auth/status");
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
body.GetProperty("oidcEnabled").GetBoolean().ShouldBeFalse();
|
||||
|
||||
// Restore for subsequent tests
|
||||
await _factory.SetOidcIssuerUrlAsync("https://mock-oidc-provider.test");
|
||||
}
|
||||
|
||||
[Fact, TestPriority(17)]
|
||||
public async Task OidcCallback_WithoutLinkedSubject_AllowsAnyUser()
|
||||
{
|
||||
// Clear the authorized subject — any OIDC user should be allowed
|
||||
await _factory.SetOidcAuthorizedSubjectAsync("");
|
||||
|
||||
// Use the "wrong subject" state — this returns a different subject than the authorized one
|
||||
// With no linked subject, it should still succeed
|
||||
var response = await _client.GetAsync(
|
||||
$"/api/auth/oidc/callback?code=valid-auth-code&state={MockOidcAuthService.WrongSubjectState}");
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Redirect);
|
||||
var location = response.Headers.Location?.ToString();
|
||||
location.ShouldNotBeNull();
|
||||
location.ShouldContain("code=");
|
||||
location.ShouldNotContain("oidc_error");
|
||||
|
||||
// Restore for subsequent tests
|
||||
await _factory.SetOidcAuthorizedSubjectAsync(MockOidcAuthService.AuthorizedSubject);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(18)]
|
||||
public async Task OidcExchange_RandomCode_ReturnsNotFound()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/oidc/exchange", new
|
||||
{
|
||||
code = "completely-random-nonexistent-code"
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.NotFound);
|
||||
}
|
||||
|
||||
#region Exclusive Mode
|
||||
|
||||
[Fact, TestPriority(19)]
|
||||
public async Task EnableExclusiveMode_AuthStatusReflectsIt()
|
||||
{
|
||||
await _factory.SetOidcExclusiveModeAsync(true);
|
||||
|
||||
var response = await _client.GetAsync("/api/auth/status");
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
body.GetProperty("oidcExclusiveMode").GetBoolean().ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact, TestPriority(20)]
|
||||
public async Task PasswordLogin_Blocked_WhenExclusiveModeActive()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/login", new
|
||||
{
|
||||
username = "admin",
|
||||
password = "TestPassword123!"
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Forbidden);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(21)]
|
||||
public async Task TwoFactorLogin_Blocked_WhenExclusiveModeActive()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/login/2fa", new
|
||||
{
|
||||
loginToken = "some-token",
|
||||
code = "123456"
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Forbidden);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(22)]
|
||||
public async Task PlexLoginPin_Blocked_WhenExclusiveModeActive()
|
||||
{
|
||||
var response = await _client.PostAsync("/api/auth/login/plex/pin", null);
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Forbidden);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(23)]
|
||||
public async Task PlexLoginVerify_Blocked_WhenExclusiveModeActive()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/login/plex/verify", new
|
||||
{
|
||||
pinId = 12345
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Forbidden);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(24)]
|
||||
public async Task OidcStart_StillWorks_WhenExclusiveModeActive()
|
||||
{
|
||||
var response = await _client.PostAsync("/api/auth/oidc/start", null);
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
body.GetProperty("authorizationUrl").GetString().ShouldNotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact, TestPriority(25)]
|
||||
public async Task OidcCallback_StillWorks_WhenExclusiveModeActive()
|
||||
{
|
||||
var response = await _client.GetAsync(
|
||||
$"/api/auth/oidc/callback?code=valid-auth-code&state={MockOidcAuthService.ValidState}");
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Redirect);
|
||||
var location = response.Headers.Location?.ToString();
|
||||
location.ShouldNotBeNull();
|
||||
location.ShouldContain("code=");
|
||||
location.ShouldNotContain("oidc_error");
|
||||
}
|
||||
|
||||
[Fact, TestPriority(26)]
|
||||
public async Task DisableExclusiveMode_PasswordLoginWorks_Again()
|
||||
{
|
||||
await _factory.SetOidcExclusiveModeAsync(false);
|
||||
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/login", new
|
||||
{
|
||||
username = "admin",
|
||||
password = "TestPassword123!"
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Test Infrastructure
|
||||
|
||||
/// <summary>
|
||||
/// Custom factory that replaces IOidcAuthService with a mock for testing.
|
||||
/// </summary>
|
||||
public class OidcWebApplicationFactory : WebApplicationFactory<Program>
|
||||
{
|
||||
private readonly string _tempDir;
|
||||
|
||||
public OidcWebApplicationFactory()
|
||||
{
|
||||
_tempDir = Path.Combine(Path.GetTempPath(), $"cleanuparr-oidc-test-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_tempDir);
|
||||
|
||||
// Redirect all database contexts to this factory's temp directory.
|
||||
ConfigurationPathProvider.SetConfigPath(_tempDir);
|
||||
}
|
||||
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
builder.UseEnvironment("Testing");
|
||||
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
// Replace IOidcAuthService with mock
|
||||
var oidcDescriptor = services.SingleOrDefault(d => d.ServiceType == typeof(IOidcAuthService));
|
||||
if (oidcDescriptor != null) services.Remove(oidcDescriptor);
|
||||
|
||||
services.AddSingleton<IOidcAuthService, MockOidcAuthService>();
|
||||
|
||||
// Remove all hosted services (Quartz scheduler, BackgroundJobManager) to prevent
|
||||
// Quartz.Logging.LogProvider.ResolvedLogProvider (a cached Lazy<T>) from being accessed
|
||||
// with a disposed ILoggerFactory from the previous factory lifecycle.
|
||||
// Auth tests don't depend on background job scheduling, so this is safe.
|
||||
foreach (var hostedService in services.Where(d => d.ServiceType == typeof(IHostedService)).ToList())
|
||||
services.Remove(hostedService);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enables OIDC on the user in the UsersContext database.
|
||||
/// </summary>
|
||||
public async Task EnableOidcAsync()
|
||||
{
|
||||
using var scope = Services.CreateScope();
|
||||
var usersContext = scope.ServiceProvider.GetRequiredService<UsersContext>();
|
||||
|
||||
var user = await usersContext.Users.FirstOrDefaultAsync();
|
||||
if (user is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
user.Oidc = new OidcConfig
|
||||
{
|
||||
Enabled = true,
|
||||
IssuerUrl = "https://mock-oidc-provider.test",
|
||||
ClientId = "test-client",
|
||||
ClientSecret = "test-secret",
|
||||
Scopes = "openid profile email",
|
||||
AuthorizedSubject = MockOidcAuthService.AuthorizedSubject,
|
||||
ProviderName = "TestProvider"
|
||||
};
|
||||
|
||||
await usersContext.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task SetOidcIssuerUrlAsync(string issuerUrl)
|
||||
{
|
||||
using var scope = Services.CreateScope();
|
||||
var usersContext = scope.ServiceProvider.GetRequiredService<UsersContext>();
|
||||
|
||||
var user = await usersContext.Users.FirstOrDefaultAsync();
|
||||
if (user is not null)
|
||||
{
|
||||
user.Oidc.IssuerUrl = issuerUrl;
|
||||
await usersContext.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SetOidcAuthorizedSubjectAsync(string subject)
|
||||
{
|
||||
using var scope = Services.CreateScope();
|
||||
var usersContext = scope.ServiceProvider.GetRequiredService<UsersContext>();
|
||||
|
||||
var user = await usersContext.Users.FirstOrDefaultAsync();
|
||||
if (user is not null)
|
||||
{
|
||||
user.Oidc.AuthorizedSubject = subject;
|
||||
await usersContext.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SetOidcExclusiveModeAsync(bool enabled)
|
||||
{
|
||||
using var scope = Services.CreateScope();
|
||||
var usersContext = scope.ServiceProvider.GetRequiredService<UsersContext>();
|
||||
|
||||
var user = await usersContext.Users.FirstOrDefaultAsync();
|
||||
if (user is not null)
|
||||
{
|
||||
user.Oidc.ExclusiveMode = enabled;
|
||||
await usersContext.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
base.Dispose(disposing);
|
||||
|
||||
if (disposing && Directory.Exists(_tempDir))
|
||||
{
|
||||
try { Directory.Delete(_tempDir, true); } catch { /* best effort */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mock OIDC auth service that simulates IdP behavior without network calls.
|
||||
/// </summary>
|
||||
private sealed class MockOidcAuthService : IOidcAuthService
|
||||
{
|
||||
public const string ValidState = "mock-valid-state";
|
||||
public const string WrongSubjectState = "mock-wrong-subject-state";
|
||||
public const string AuthorizedSubject = "mock-authorized-subject-123";
|
||||
|
||||
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, OidcTokenExchangeResult> _oneTimeCodes = new();
|
||||
|
||||
public Task<OidcAuthorizationResult> StartAuthorization(string redirectUri, string? initiatorUserId = null)
|
||||
{
|
||||
return Task.FromResult(new OidcAuthorizationResult
|
||||
{
|
||||
AuthorizationUrl = $"https://mock-oidc-provider.test/authorize?redirect_uri={Uri.EscapeDataString(redirectUri)}&state={ValidState}",
|
||||
State = ValidState
|
||||
});
|
||||
}
|
||||
|
||||
public Task<OidcCallbackResult> HandleCallback(string code, string state, string redirectUri)
|
||||
{
|
||||
if (state == ValidState)
|
||||
{
|
||||
return Task.FromResult(new OidcCallbackResult
|
||||
{
|
||||
Success = true,
|
||||
Subject = AuthorizedSubject,
|
||||
PreferredUsername = "testuser",
|
||||
Email = "testuser@example.com"
|
||||
});
|
||||
}
|
||||
|
||||
if (state == WrongSubjectState)
|
||||
{
|
||||
return Task.FromResult(new OidcCallbackResult
|
||||
{
|
||||
Success = true,
|
||||
Subject = "wrong-subject-that-doesnt-match",
|
||||
PreferredUsername = "wronguser",
|
||||
Email = "wrong@example.com"
|
||||
});
|
||||
}
|
||||
|
||||
return Task.FromResult(new OidcCallbackResult
|
||||
{
|
||||
Success = false,
|
||||
Error = "Invalid or expired OIDC state"
|
||||
});
|
||||
}
|
||||
|
||||
public string StoreOneTimeCode(string accessToken, string refreshToken, int expiresIn)
|
||||
{
|
||||
var code = Guid.NewGuid().ToString("N");
|
||||
_oneTimeCodes.TryAdd(code, new OidcTokenExchangeResult
|
||||
{
|
||||
AccessToken = accessToken,
|
||||
RefreshToken = refreshToken,
|
||||
ExpiresIn = expiresIn
|
||||
});
|
||||
return code;
|
||||
}
|
||||
|
||||
public OidcTokenExchangeResult? ExchangeOneTimeCode(string code)
|
||||
{
|
||||
return _oneTimeCodes.TryRemove(code, out var result) ? result : null;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using Cleanuparr.Infrastructure.Features.Auth;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Features.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Factory variant that replaces <see cref="IPasswordService"/> with a
|
||||
/// <see cref="TrackingPasswordService"/> spy so tests can assert that
|
||||
/// password verification is always called regardless of username validity.
|
||||
/// </summary>
|
||||
public class TimingTestWebApplicationFactory : CustomWebApplicationFactory
|
||||
{
|
||||
public TrackingPasswordService TrackingPasswordService { get; } = new();
|
||||
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
base.ConfigureWebHost(builder);
|
||||
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
// Replace IPasswordService with our tracking spy
|
||||
var descriptor = services.SingleOrDefault(d => d.ServiceType == typeof(IPasswordService));
|
||||
if (descriptor != null) services.Remove(descriptor);
|
||||
|
||||
services.AddSingleton<IPasswordService>(TrackingPasswordService);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using Cleanuparr.Infrastructure.Features.Auth;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Features.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Spy wrapper around <see cref="PasswordService"/> that tracks calls to
|
||||
/// <see cref="VerifyPassword"/> for behavioral assertions in timing tests.
|
||||
/// </summary>
|
||||
public sealed class TrackingPasswordService : IPasswordService
|
||||
{
|
||||
private readonly PasswordService _inner = new();
|
||||
private int _verifyPasswordCallCount;
|
||||
|
||||
public int VerifyPasswordCallCount => _verifyPasswordCallCount;
|
||||
|
||||
public string DummyHash => _inner.DummyHash;
|
||||
|
||||
public string HashPassword(string password)
|
||||
{
|
||||
return _inner.HashPassword(password);
|
||||
}
|
||||
|
||||
public bool VerifyPassword(string password, string hash)
|
||||
{
|
||||
Interlocked.Increment(ref _verifyPasswordCallCount);
|
||||
return _inner.VerifyPassword(password, hash);
|
||||
}
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
Interlocked.Exchange(ref _verifyPasswordCallCount, 0);
|
||||
}
|
||||
}
|
||||
+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);
|
||||
}
|
||||
}
|
||||
+473
@@ -0,0 +1,473 @@
|
||||
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.DownloadCleaner;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using Shouldly;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Features.DownloadCleaner;
|
||||
|
||||
public class SeedingRulesControllerTests : IDisposable
|
||||
{
|
||||
private readonly DataContext _dataContext;
|
||||
private readonly SeedingRulesController _controller;
|
||||
|
||||
public SeedingRulesControllerTests()
|
||||
{
|
||||
_dataContext = SeedingRulesTestDataFactory.CreateDataContext();
|
||||
var logger = Substitute.For<ILogger<SeedingRulesController>>();
|
||||
_controller = new SeedingRulesController(logger, _dataContext);
|
||||
ControllerTestContext.Attach(_controller);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_dataContext.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
private static SeedingRuleRequest CreateValidRequest(
|
||||
string name = "Test Rule",
|
||||
List<string>? categories = null,
|
||||
List<string>? trackerPatterns = null,
|
||||
List<string>? tagsAny = null,
|
||||
List<string>? tagsAll = null,
|
||||
int? priority = null,
|
||||
double maxRatio = 2.0,
|
||||
double minSeedTime = 0,
|
||||
double maxSeedTime = -1,
|
||||
int minSeeders = 0,
|
||||
bool deleteSourceFiles = true)
|
||||
{
|
||||
return new SeedingRuleRequest
|
||||
{
|
||||
Name = name,
|
||||
Categories = categories ?? ["movies"],
|
||||
TrackerPatterns = trackerPatterns ?? [],
|
||||
TagsAny = tagsAny ?? [],
|
||||
TagsAll = tagsAll ?? [],
|
||||
Priority = priority,
|
||||
PrivacyType = TorrentPrivacyType.Both,
|
||||
MaxRatio = maxRatio,
|
||||
MinSeedTime = minSeedTime,
|
||||
MaxSeedTime = maxSeedTime,
|
||||
MinSeeders = minSeeders,
|
||||
DeleteSourceFiles = deleteSourceFiles,
|
||||
};
|
||||
}
|
||||
|
||||
private static List<SeedingRuleResponse> GetRulesFromOk(IActionResult result)
|
||||
{
|
||||
var okResult = result.ShouldBeOfType<OkObjectResult>();
|
||||
IEnumerable<SeedingRuleResponse> rules = okResult.Value.ShouldBeAssignableTo<IEnumerable<SeedingRuleResponse>>()!;
|
||||
return rules.ToList();
|
||||
}
|
||||
|
||||
private static T GetCreatedRule<T>(IActionResult result) where T : ISeedingRule
|
||||
{
|
||||
var createdResult = result.ShouldBeOfType<CreatedAtActionResult>();
|
||||
return createdResult.Value.ShouldBeOfType<T>();
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// GetSeedingRules
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task GetSeedingRules_EmptyRules_ReturnsEmptyList()
|
||||
{
|
||||
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
|
||||
|
||||
var result = await _controller.GetSeedingRules(client.Id);
|
||||
|
||||
GetRulesFromOk(result).ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSeedingRules_ReturnsRulesOrderedByPriority()
|
||||
{
|
||||
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
|
||||
SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id, name: "Rule C", priority: 3);
|
||||
SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id, name: "Rule A", priority: 1);
|
||||
SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id, name: "Rule B", priority: 2);
|
||||
|
||||
var result = await _controller.GetSeedingRules(client.Id);
|
||||
|
||||
List<SeedingRuleResponse> rules = GetRulesFromOk(result);
|
||||
rules.Count.ShouldBe(3);
|
||||
rules[0].Name.ShouldBe("Rule A");
|
||||
rules[1].Name.ShouldBe("Rule B");
|
||||
rules[2].Name.ShouldBe("Rule C");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSeedingRules_NonExistentClient_ReturnsNotFound()
|
||||
{
|
||||
var result = await _controller.GetSeedingRules(Guid.NewGuid());
|
||||
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(StatusCodes.Status404NotFound);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSeedingRules_QBitClient_ReturnsTagFields()
|
||||
{
|
||||
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
|
||||
SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id,
|
||||
tagsAny: ["hd", "private"], tagsAll: ["required"]);
|
||||
|
||||
var result = await _controller.GetSeedingRules(client.Id);
|
||||
|
||||
SeedingRuleResponse rule = GetRulesFromOk(result).Single();
|
||||
rule.TagsAny.ShouldBe(new List<string> { "hd", "private" });
|
||||
rule.TagsAll.ShouldBe(new List<string> { "required" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSeedingRules_ReturnsMinSeeders()
|
||||
{
|
||||
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
|
||||
SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id, minSeeders: 5);
|
||||
|
||||
var result = await _controller.GetSeedingRules(client.Id);
|
||||
|
||||
SeedingRuleResponse rule = GetRulesFromOk(result).Single();
|
||||
rule.MinSeeders.ShouldBe(5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSeedingRules_DelugeClient_ReturnsEmptyTagFields()
|
||||
{
|
||||
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext, DownloadClientTypeName.Deluge, "Test Deluge");
|
||||
SeedingRulesTestDataFactory.AddDelugeSeedingRule(_dataContext, client.Id);
|
||||
|
||||
var result = await _controller.GetSeedingRules(client.Id);
|
||||
|
||||
SeedingRuleResponse rule = GetRulesFromOk(result).Single();
|
||||
rule.TagsAny.ShouldBeEmpty();
|
||||
rule.TagsAll.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// CreateSeedingRule
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task CreateSeedingRule_ValidRequest_ReturnsCreated()
|
||||
{
|
||||
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
|
||||
var request = CreateValidRequest(name: "Movies Rule", categories: ["movies", "films"]);
|
||||
|
||||
var result = await _controller.CreateSeedingRule(client.Id, request);
|
||||
|
||||
var createdResult = result.ShouldBeOfType<CreatedAtActionResult>();
|
||||
createdResult.StatusCode.ShouldBe(201);
|
||||
|
||||
QBitSeedingRule rule = GetCreatedRule<QBitSeedingRule>(result);
|
||||
rule.Name.ShouldBe("Movies Rule");
|
||||
rule.Categories.ShouldBe(new List<string> { "movies", "films" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateSeedingRule_AutoAssignsPriority_WhenNotProvided()
|
||||
{
|
||||
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
|
||||
var request = CreateValidRequest();
|
||||
|
||||
var result = await _controller.CreateSeedingRule(client.Id, request);
|
||||
|
||||
GetCreatedRule<QBitSeedingRule>(result).Priority.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateSeedingRule_SetsMinSeeders()
|
||||
{
|
||||
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
|
||||
var request = CreateValidRequest(minSeeders: 5);
|
||||
|
||||
var result = await _controller.CreateSeedingRule(client.Id, request);
|
||||
|
||||
GetCreatedRule<QBitSeedingRule>(result).MinSeeders.ShouldBe(5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateSeedingRule_AutoAssignsSequentialPriority()
|
||||
{
|
||||
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
|
||||
SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id, priority: 1);
|
||||
|
||||
var request = CreateValidRequest(name: "Second Rule", categories: ["tv"]);
|
||||
|
||||
var result = await _controller.CreateSeedingRule(client.Id, request);
|
||||
|
||||
GetCreatedRule<QBitSeedingRule>(result).Priority.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateSeedingRule_DuplicatePriority_ReturnsBadRequest()
|
||||
{
|
||||
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
|
||||
SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id, priority: 1);
|
||||
|
||||
var request = CreateValidRequest(priority: 1);
|
||||
|
||||
var result = await _controller.CreateSeedingRule(client.Id, request);
|
||||
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(StatusCodes.Status400BadRequest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateSeedingRule_NonExistentClient_ReturnsNotFound()
|
||||
{
|
||||
var request = CreateValidRequest();
|
||||
|
||||
var result = await _controller.CreateSeedingRule(Guid.NewGuid(), request);
|
||||
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(StatusCodes.Status404NotFound);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateSeedingRule_EmptyCategories_ReturnsBadRequest()
|
||||
{
|
||||
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
|
||||
var request = CreateValidRequest(categories: []);
|
||||
|
||||
await Should.ThrowAsync<ValidationException>(() => _controller.CreateSeedingRule(client.Id, request));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateSeedingRule_SanitizesWhitespaceInLists()
|
||||
{
|
||||
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
|
||||
var request = CreateValidRequest(
|
||||
trackerPatterns: ["", " ", "valid.com", " trimmed.com "]);
|
||||
|
||||
var result = await _controller.CreateSeedingRule(client.Id, request);
|
||||
|
||||
QBitSeedingRule rule = GetCreatedRule<QBitSeedingRule>(result);
|
||||
rule.TrackerPatterns.ShouldBe(new List<string> { "valid.com", "trimmed.com" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateSeedingRule_ForTransmission_CreatesTransmissionRule()
|
||||
{
|
||||
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext,
|
||||
DownloadClientTypeName.Transmission, "Test Transmission");
|
||||
var request = CreateValidRequest(tagsAny: ["tag1"]);
|
||||
|
||||
var result = await _controller.CreateSeedingRule(client.Id, request);
|
||||
|
||||
GetCreatedRule<TransmissionSeedingRule>(result).TagsAny.ShouldBe(new List<string> { "tag1" });
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// UpdateSeedingRule
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSeedingRule_ValidRequest_ReturnsOk()
|
||||
{
|
||||
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
|
||||
var rule = SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id);
|
||||
|
||||
var request = CreateValidRequest(name: "Updated Name", categories: ["tv", "anime"]);
|
||||
|
||||
var result = await _controller.UpdateSeedingRule(rule.Id, request);
|
||||
|
||||
var okResult = result.ShouldBeOfType<OkObjectResult>();
|
||||
var updated = okResult.Value.ShouldBeOfType<QBitSeedingRule>();
|
||||
updated.Name.ShouldBe("Updated Name");
|
||||
updated.Categories.ShouldBe(new List<string> { "tv", "anime" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSeedingRule_DoesNotChangePriority()
|
||||
{
|
||||
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
|
||||
var rule = SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id, priority: 5);
|
||||
|
||||
var request = CreateValidRequest(priority: 1);
|
||||
|
||||
var result = await _controller.UpdateSeedingRule(rule.Id, request);
|
||||
|
||||
var okResult = result.ShouldBeOfType<OkObjectResult>();
|
||||
var updated = okResult.Value.ShouldBeOfType<QBitSeedingRule>();
|
||||
updated.Priority.ShouldBe(5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSeedingRule_UpdatesTagsForTagFilterableClient()
|
||||
{
|
||||
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
|
||||
var rule = SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id);
|
||||
|
||||
var request = CreateValidRequest(tagsAny: ["new-tag"], tagsAll: ["must-have"]);
|
||||
|
||||
var result = await _controller.UpdateSeedingRule(rule.Id, request);
|
||||
|
||||
var okResult = result.ShouldBeOfType<OkObjectResult>();
|
||||
var updated = okResult.Value.ShouldBeOfType<QBitSeedingRule>();
|
||||
updated.TagsAny.ShouldBe(new List<string> { "new-tag" });
|
||||
updated.TagsAll.ShouldBe(new List<string> { "must-have" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSeedingRule_UpdatesMinSeeders()
|
||||
{
|
||||
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
|
||||
var rule = SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id);
|
||||
|
||||
var request = CreateValidRequest(minSeeders: 5);
|
||||
|
||||
var result = await _controller.UpdateSeedingRule(rule.Id, request);
|
||||
|
||||
var okResult = result.ShouldBeOfType<OkObjectResult>();
|
||||
var updated = okResult.Value.ShouldBeOfType<QBitSeedingRule>();
|
||||
updated.MinSeeders.ShouldBe(5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSeedingRule_NonExistentRule_ReturnsNotFound()
|
||||
{
|
||||
var request = CreateValidRequest();
|
||||
|
||||
var result = await _controller.UpdateSeedingRule(Guid.NewGuid(), request);
|
||||
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(StatusCodes.Status404NotFound);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSeedingRule_ValidationFailure_ReturnsBadRequest()
|
||||
{
|
||||
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
|
||||
var rule = SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id);
|
||||
|
||||
// Both maxRatio and maxSeedTime negative → validation failure
|
||||
var request = CreateValidRequest(maxRatio: -1, maxSeedTime: -1);
|
||||
|
||||
await Should.ThrowAsync<ValidationException>(() => _controller.UpdateSeedingRule(rule.Id, request));
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// ReorderSeedingRules
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task ReorderSeedingRules_ValidRequest_ReturnsNoContent()
|
||||
{
|
||||
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
|
||||
var rule1 = SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id, name: "A", priority: 1);
|
||||
var rule2 = SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id, name: "B", priority: 2);
|
||||
|
||||
var request = new ReorderSeedingRulesRequest { OrderedIds = [rule2.Id, rule1.Id] };
|
||||
|
||||
var result = await _controller.ReorderSeedingRules(client.Id, request);
|
||||
result.ShouldBeOfType<NoContentResult>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReorderSeedingRules_AssignsSequentialPriorities()
|
||||
{
|
||||
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
|
||||
var rule1 = SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id, name: "A", priority: 1);
|
||||
var rule2 = SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id, name: "B", priority: 2);
|
||||
var rule3 = SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id, name: "C", priority: 3);
|
||||
|
||||
// Reverse order
|
||||
var request = new ReorderSeedingRulesRequest { OrderedIds = [rule3.Id, rule2.Id, rule1.Id] };
|
||||
await _controller.ReorderSeedingRules(client.Id, request);
|
||||
|
||||
List<SeedingRuleResponse> rules = GetRulesFromOk(await _controller.GetSeedingRules(client.Id));
|
||||
|
||||
rules[0].Name.ShouldBe("C");
|
||||
rules[0].Priority.ShouldBe(1);
|
||||
rules[1].Name.ShouldBe("B");
|
||||
rules[1].Priority.ShouldBe(2);
|
||||
rules[2].Name.ShouldBe("A");
|
||||
rules[2].Priority.ShouldBe(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReorderSeedingRules_NonExistentClient_ReturnsNotFound()
|
||||
{
|
||||
var request = new ReorderSeedingRulesRequest { OrderedIds = [Guid.NewGuid()] };
|
||||
|
||||
var result = await _controller.ReorderSeedingRules(Guid.NewGuid(), request);
|
||||
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(StatusCodes.Status404NotFound);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReorderSeedingRules_DuplicateIds_ReturnsBadRequest()
|
||||
{
|
||||
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
|
||||
var rule1 = SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id, name: "A", priority: 1);
|
||||
var rule2 = SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id, name: "B", priority: 2);
|
||||
|
||||
var request = new ReorderSeedingRulesRequest { OrderedIds = [rule1.Id, rule1.Id] };
|
||||
|
||||
var result = await _controller.ReorderSeedingRules(client.Id, request);
|
||||
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(StatusCodes.Status400BadRequest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReorderSeedingRules_WrongCount_ReturnsBadRequest()
|
||||
{
|
||||
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
|
||||
var rule1 = SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id, name: "A", priority: 1);
|
||||
SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id, name: "B", priority: 2);
|
||||
|
||||
// Only send 1 of 2 IDs
|
||||
var request = new ReorderSeedingRulesRequest { OrderedIds = [rule1.Id] };
|
||||
|
||||
var result = await _controller.ReorderSeedingRules(client.Id, request);
|
||||
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(StatusCodes.Status400BadRequest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReorderSeedingRules_UnknownRuleId_ReturnsBadRequest()
|
||||
{
|
||||
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
|
||||
var rule1 = SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id, name: "A", priority: 1);
|
||||
SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id, name: "B", priority: 2);
|
||||
|
||||
var request = new ReorderSeedingRulesRequest { OrderedIds = [rule1.Id, Guid.NewGuid()] };
|
||||
|
||||
var result = await _controller.ReorderSeedingRules(client.Id, request);
|
||||
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(StatusCodes.Status400BadRequest);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// DeleteSeedingRule
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteSeedingRule_ExistingRule_ReturnsNoContent()
|
||||
{
|
||||
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
|
||||
var rule = SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id);
|
||||
|
||||
var result = await _controller.DeleteSeedingRule(rule.Id);
|
||||
result.ShouldBeOfType<NoContentResult>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteSeedingRule_VerifiesRuleRemoved()
|
||||
{
|
||||
var client = SeedingRulesTestDataFactory.AddDownloadClient(_dataContext);
|
||||
var rule = SeedingRulesTestDataFactory.AddQBitSeedingRule(_dataContext, client.Id);
|
||||
|
||||
await _controller.DeleteSeedingRule(rule.Id);
|
||||
|
||||
GetRulesFromOk(await _controller.GetSeedingRules(client.Id)).ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteSeedingRule_NonExistentRule_ReturnsNotFound()
|
||||
{
|
||||
var result = await _controller.DeleteSeedingRule(Guid.NewGuid());
|
||||
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(StatusCodes.Status404NotFound);
|
||||
}
|
||||
}
|
||||
+211
@@ -0,0 +1,211 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
|
||||
using Cleanuparr.Persistence.Models.Configuration.General;
|
||||
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
|
||||
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Seeker;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Features.DownloadCleaner.TestHelpers;
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating SQLite in-memory contexts for SeedingRulesController tests
|
||||
/// </summary>
|
||||
public static class SeedingRulesTestDataFactory
|
||||
{
|
||||
public static DataContext CreateDataContext()
|
||||
{
|
||||
var connection = new SqliteConnection("DataSource=:memory:");
|
||||
connection.Open();
|
||||
|
||||
var options = new DbContextOptionsBuilder<DataContext>()
|
||||
.UseSqlite(connection)
|
||||
.Options;
|
||||
|
||||
var context = new DataContext(options);
|
||||
context.Database.EnsureCreated();
|
||||
|
||||
SeedDefaultData(context);
|
||||
return context;
|
||||
}
|
||||
|
||||
private static void SeedDefaultData(DataContext context)
|
||||
{
|
||||
context.GeneralConfigs.Add(new GeneralConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
DryRun = false,
|
||||
IgnoredDownloads = [],
|
||||
Log = new LoggingConfig()
|
||||
});
|
||||
|
||||
context.ArrConfigs.AddRange(
|
||||
new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Sonarr, Instances = [], FailedImportMaxStrikes = 3 },
|
||||
new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Radarr, Instances = [], FailedImportMaxStrikes = 3 },
|
||||
new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Lidarr, Instances = [], FailedImportMaxStrikes = 3 },
|
||||
new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Readarr, Instances = [], FailedImportMaxStrikes = 3 },
|
||||
new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Whisparr, Instances = [], FailedImportMaxStrikes = 3 }
|
||||
);
|
||||
|
||||
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 }
|
||||
});
|
||||
|
||||
context.DownloadCleanerConfigs.Add(new DownloadCleanerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
IgnoredDownloads = []
|
||||
});
|
||||
|
||||
context.SeekerConfigs.Add(new SeekerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
SearchEnabled = true,
|
||||
ProactiveSearchEnabled = false
|
||||
});
|
||||
|
||||
context.SaveChanges();
|
||||
}
|
||||
|
||||
public static DownloadClientConfig AddDownloadClient(
|
||||
DataContext context,
|
||||
DownloadClientTypeName typeName = DownloadClientTypeName.qBittorrent,
|
||||
string name = "Test qBittorrent")
|
||||
{
|
||||
var config = new DownloadClientConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = name,
|
||||
TypeName = typeName,
|
||||
Type = DownloadClientType.Torrent,
|
||||
Enabled = true,
|
||||
Host = new Uri("http://localhost:8080"),
|
||||
Username = "admin",
|
||||
Password = "admin"
|
||||
};
|
||||
|
||||
context.DownloadClients.Add(config);
|
||||
context.SaveChanges();
|
||||
return config;
|
||||
}
|
||||
|
||||
public static QBitSeedingRule AddQBitSeedingRule(
|
||||
DataContext context,
|
||||
Guid downloadClientId,
|
||||
string name = "Test Rule",
|
||||
int priority = 1,
|
||||
List<string>? categories = null,
|
||||
List<string>? trackerPatterns = null,
|
||||
List<string>? tagsAny = null,
|
||||
List<string>? tagsAll = null,
|
||||
double maxRatio = 2.0,
|
||||
double minSeedTime = 0,
|
||||
double maxSeedTime = -1,
|
||||
int minSeeders = 0)
|
||||
{
|
||||
var rule = new QBitSeedingRule
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
DownloadClientConfigId = downloadClientId,
|
||||
Name = name,
|
||||
Priority = priority,
|
||||
Categories = categories ?? ["movies"],
|
||||
TrackerPatterns = trackerPatterns ?? [],
|
||||
TagsAny = tagsAny ?? [],
|
||||
TagsAll = tagsAll ?? [],
|
||||
PrivacyType = TorrentPrivacyType.Both,
|
||||
MaxRatio = maxRatio,
|
||||
MinSeedTime = minSeedTime,
|
||||
MaxSeedTime = maxSeedTime,
|
||||
MinSeeders = minSeeders,
|
||||
DeleteSourceFiles = true,
|
||||
};
|
||||
|
||||
context.QBitSeedingRules.Add(rule);
|
||||
context.SaveChanges();
|
||||
return rule;
|
||||
}
|
||||
|
||||
public static DelugeSeedingRule AddDelugeSeedingRule(
|
||||
DataContext context,
|
||||
Guid downloadClientId,
|
||||
string name = "Test Rule",
|
||||
int priority = 1,
|
||||
List<string>? categories = null,
|
||||
double maxRatio = 2.0,
|
||||
double maxSeedTime = -1,
|
||||
int minSeeders = 0)
|
||||
{
|
||||
var rule = new DelugeSeedingRule
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
DownloadClientConfigId = downloadClientId,
|
||||
Name = name,
|
||||
Priority = priority,
|
||||
Categories = categories ?? ["movies"],
|
||||
TrackerPatterns = [],
|
||||
PrivacyType = TorrentPrivacyType.Both,
|
||||
MaxRatio = maxRatio,
|
||||
MinSeedTime = 0,
|
||||
MaxSeedTime = maxSeedTime,
|
||||
MinSeeders = minSeeders,
|
||||
DeleteSourceFiles = true,
|
||||
};
|
||||
|
||||
context.DelugeSeedingRules.Add(rule);
|
||||
context.SaveChanges();
|
||||
return rule;
|
||||
}
|
||||
|
||||
public static TransmissionSeedingRule AddTransmissionSeedingRule(
|
||||
DataContext context,
|
||||
Guid downloadClientId,
|
||||
string name = "Test Rule",
|
||||
int priority = 1,
|
||||
List<string>? categories = null,
|
||||
double maxRatio = 2.0,
|
||||
double maxSeedTime = -1,
|
||||
int minSeeders = 0)
|
||||
{
|
||||
var rule = new TransmissionSeedingRule
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
DownloadClientConfigId = downloadClientId,
|
||||
Name = name,
|
||||
Priority = priority,
|
||||
Categories = categories ?? ["movies"],
|
||||
TrackerPatterns = [],
|
||||
TagsAny = [],
|
||||
TagsAll = [],
|
||||
PrivacyType = TorrentPrivacyType.Both,
|
||||
MaxRatio = maxRatio,
|
||||
MinSeedTime = 0,
|
||||
MaxSeedTime = maxSeedTime,
|
||||
MinSeeders = minSeeders,
|
||||
DeleteSourceFiles = true,
|
||||
};
|
||||
|
||||
context.TransmissionSeedingRules.Add(rule);
|
||||
context.SaveChanges();
|
||||
return rule;
|
||||
}
|
||||
}
|
||||
+539
@@ -0,0 +1,539 @@
|
||||
using System.Text.Json;
|
||||
using Cleanuparr.Api.Features.Seeker.Contracts.Responses;
|
||||
using Cleanuparr.Api.Features.Seeker.Controllers;
|
||||
using Cleanuparr.Api.Tests.Features.Seeker.TestHelpers;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.State;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Shouldly;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Features.Seeker;
|
||||
|
||||
public class CustomFormatScoreControllerTests : IDisposable
|
||||
{
|
||||
private readonly DataContext _dataContext;
|
||||
private readonly CustomFormatScoreController _controller;
|
||||
|
||||
public CustomFormatScoreControllerTests()
|
||||
{
|
||||
_dataContext = SeekerTestDataFactory.CreateDataContext();
|
||||
_controller = new CustomFormatScoreController(_dataContext);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_dataContext.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
private static JsonElement GetResponseBody(IActionResult result)
|
||||
{
|
||||
var okResult = result.ShouldBeOfType<OkObjectResult>();
|
||||
var json = JsonSerializer.Serialize(okResult.Value);
|
||||
return JsonDocument.Parse(json).RootElement;
|
||||
}
|
||||
|
||||
#region GetCustomFormatScores Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetCustomFormatScores_WithPageBelowMinimum_ClampsToOne()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
AddScoreEntry(radarr.Id, 1, "Movie A", currentScore: 100, cutoffScore: 500);
|
||||
AddScoreEntry(radarr.Id, 2, "Movie B", currentScore: 200, cutoffScore: 500);
|
||||
|
||||
var result = await _controller.GetCustomFormatScores(page: -5, pageSize: 50);
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("Page").GetInt32().ShouldBe(1);
|
||||
body.GetProperty("Items").GetArrayLength().ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCustomFormatScores_WithPageSizeAboveMaximum_ClampsToHundred()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
AddScoreEntry(radarr.Id, 1, "Movie A", currentScore: 100, cutoffScore: 500);
|
||||
|
||||
var result = await _controller.GetCustomFormatScores(page: 1, pageSize: 999);
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("PageSize").GetInt32().ShouldBe(500);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCustomFormatScores_WithHideMetTrue_ExcludesItemsAtOrAboveCutoff()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
AddScoreEntry(radarr.Id, 1, "Below Cutoff", currentScore: 100, cutoffScore: 500);
|
||||
AddScoreEntry(radarr.Id, 2, "At Cutoff", currentScore: 500, cutoffScore: 500);
|
||||
AddScoreEntry(radarr.Id, 3, "Above Cutoff", currentScore: 600, cutoffScore: 500);
|
||||
|
||||
var result = await _controller.GetCustomFormatScores(cutoffFilter: CutoffFilter.Below);
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("TotalCount").GetInt32().ShouldBe(1);
|
||||
body.GetProperty("Items")[0].GetProperty("Title").GetString().ShouldBe("Below Cutoff");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCustomFormatScores_WithHideUnmonitoredTrue_ExcludesUnmonitoredItems()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
AddScoreEntry(radarr.Id, 1, "Monitored Movie", currentScore: 100, cutoffScore: 500, isMonitored: true);
|
||||
AddScoreEntry(radarr.Id, 2, "Unmonitored Movie", currentScore: 200, cutoffScore: 500, isMonitored: false);
|
||||
AddScoreEntry(radarr.Id, 3, "Another Monitored", currentScore: 300, cutoffScore: 500, isMonitored: true);
|
||||
|
||||
var result = await _controller.GetCustomFormatScores(monitoredFilter: MonitoredFilter.Monitored);
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("TotalCount").GetInt32().ShouldBe(2);
|
||||
var items = body.GetProperty("Items");
|
||||
items.GetArrayLength().ShouldBe(2);
|
||||
items[0].GetProperty("Title").GetString().ShouldBe("Another Monitored");
|
||||
items[1].GetProperty("Title").GetString().ShouldBe("Monitored Movie");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCustomFormatScores_WithSearchFilter_ReturnsMatchingTitlesOnly()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
AddScoreEntry(radarr.Id, 1, "The Matrix", currentScore: 100, cutoffScore: 500);
|
||||
AddScoreEntry(radarr.Id, 2, "Inception", currentScore: 200, cutoffScore: 500);
|
||||
AddScoreEntry(radarr.Id, 3, "The Matrix Reloaded", currentScore: 300, cutoffScore: 500);
|
||||
|
||||
var result = await _controller.GetCustomFormatScores(search: "matrix");
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("TotalCount").GetInt32().ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCustomFormatScores_WithSortByDate_OrdersByLastSyncedDescending()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
AddScoreEntry(radarr.Id, 1, "Older", currentScore: 100, cutoffScore: 500,
|
||||
lastSynced: DateTime.UtcNow.AddHours(-2));
|
||||
AddScoreEntry(radarr.Id, 2, "Newer", currentScore: 200, cutoffScore: 500,
|
||||
lastSynced: DateTime.UtcNow.AddHours(-1));
|
||||
|
||||
var result = await _controller.GetCustomFormatScores(sortBy: CfScoresSortBy.LastSyncedAt);
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("Items")[0].GetProperty("Title").GetString().ShouldBe("Newer");
|
||||
body.GetProperty("Items")[1].GetProperty("Title").GetString().ShouldBe("Older");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCustomFormatScores_WithInstanceIdFilter_ReturnsOnlyThatInstance()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
var sonarr = SeekerTestDataFactory.AddSonarrInstance(_dataContext);
|
||||
AddScoreEntry(radarr.Id, 1, "Movie", currentScore: 100, cutoffScore: 500);
|
||||
AddScoreEntry(sonarr.Id, 2, "Series", currentScore: 200, cutoffScore: 500,
|
||||
itemType: InstanceType.Sonarr);
|
||||
|
||||
var result = await _controller.GetCustomFormatScores(instanceId: radarr.Id);
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("TotalCount").GetInt32().ShouldBe(1);
|
||||
body.GetProperty("Items")[0].GetProperty("Title").GetString().ShouldBe("Movie");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCustomFormatScores_ReturnsCorrectTotalPagesCalculation()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
for (int i = 1; i <= 7; i++)
|
||||
{
|
||||
AddScoreEntry(radarr.Id, i, $"Movie {i}", currentScore: 100, cutoffScore: 500);
|
||||
}
|
||||
|
||||
var result = await _controller.GetCustomFormatScores(page: 1, pageSize: 3);
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("TotalCount").GetInt32().ShouldBe(7);
|
||||
body.GetProperty("TotalPages").GetInt32().ShouldBe(3); // ceil(7/3) = 3
|
||||
body.GetProperty("Items").GetArrayLength().ShouldBe(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCustomFormatScores_WithCutoffFilterMet_ExcludesBelowCutoff()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
AddScoreEntry(radarr.Id, 1, "Below", currentScore: 100, cutoffScore: 500);
|
||||
AddScoreEntry(radarr.Id, 2, "At", currentScore: 500, cutoffScore: 500);
|
||||
AddScoreEntry(radarr.Id, 3, "Above", currentScore: 600, cutoffScore: 500);
|
||||
|
||||
var result = await _controller.GetCustomFormatScores(cutoffFilter: CutoffFilter.Met);
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("TotalCount").GetInt32().ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCustomFormatScores_WithCutoffFilterAll_IncludesEverything()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
AddScoreEntry(radarr.Id, 1, "Below", currentScore: 100, cutoffScore: 500);
|
||||
AddScoreEntry(radarr.Id, 2, "Above", currentScore: 600, cutoffScore: 500);
|
||||
|
||||
var result = await _controller.GetCustomFormatScores(cutoffFilter: CutoffFilter.All);
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("TotalCount").GetInt32().ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCustomFormatScores_WithMonitoredFilterUnmonitored_ReturnsOnlyUnmonitored()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
AddScoreEntry(radarr.Id, 1, "A", currentScore: 100, cutoffScore: 500, isMonitored: true);
|
||||
AddScoreEntry(radarr.Id, 2, "B", currentScore: 100, cutoffScore: 500, isMonitored: false);
|
||||
|
||||
var result = await _controller.GetCustomFormatScores(monitoredFilter: MonitoredFilter.Unmonitored);
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("TotalCount").GetInt32().ShouldBe(1);
|
||||
body.GetProperty("Items")[0].GetProperty("Title").GetString().ShouldBe("B");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCustomFormatScores_WithQualityProfileFilter_ReturnsOnlyMatchingProfile()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
AddScoreEntry(radarr.Id, 1, "HD Movie", currentScore: 100, cutoffScore: 500, qualityProfileName: "HD");
|
||||
AddScoreEntry(radarr.Id, 2, "UHD Movie", currentScore: 200, cutoffScore: 500, qualityProfileName: "UHD");
|
||||
|
||||
var result = await _controller.GetCustomFormatScores(qualityProfile: "UHD");
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("TotalCount").GetInt32().ShouldBe(1);
|
||||
body.GetProperty("Items")[0].GetProperty("Title").GetString().ShouldBe("UHD Movie");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCustomFormatScores_WithExplicitSortDirectionAsc_OverridesDefault()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
AddScoreEntry(radarr.Id, 1, "A", currentScore: 100, cutoffScore: 500);
|
||||
AddScoreEntry(radarr.Id, 2, "B", currentScore: 300, cutoffScore: 500);
|
||||
|
||||
// CurrentScore default is descending; overriding with Asc should flip it.
|
||||
var result = await _controller.GetCustomFormatScores(
|
||||
sortBy: CfScoresSortBy.CurrentScore,
|
||||
sortDirection: Cleanuparr.Domain.Enums.SortDirection.Asc);
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
var items = body.GetProperty("Items");
|
||||
items[0].GetProperty("CurrentScore").GetInt32().ShouldBe(100);
|
||||
items[1].GetProperty("CurrentScore").GetInt32().ShouldBe(300);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCustomFormatScores_WithSortByTitleDescending_OrdersReverseAlphabetically()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
AddScoreEntry(radarr.Id, 1, "Apple", currentScore: 100, cutoffScore: 500);
|
||||
AddScoreEntry(radarr.Id, 2, "Banana", currentScore: 200, cutoffScore: 500);
|
||||
|
||||
var result = await _controller.GetCustomFormatScores(
|
||||
sortBy: CfScoresSortBy.Title,
|
||||
sortDirection: Cleanuparr.Domain.Enums.SortDirection.Desc);
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("Items")[0].GetProperty("Title").GetString().ShouldBe("Banana");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetRecentUpgrades Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetRecentUpgrades_WithNoHistory_ReturnsEmptyList()
|
||||
{
|
||||
var result = await _controller.GetRecentUpgrades();
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("TotalCount").GetInt32().ShouldBe(0);
|
||||
body.GetProperty("Items").GetArrayLength().ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetRecentUpgrades_WithSingleEntryPerItem_ReturnsNoUpgrades()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 100, recordedAt: DateTime.UtcNow.AddDays(-1));
|
||||
|
||||
var result = await _controller.GetRecentUpgrades();
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("TotalCount").GetInt32().ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetRecentUpgrades_WithScoreIncrease_DetectsUpgrade()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 100, recordedAt: DateTime.UtcNow.AddDays(-2));
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 250, recordedAt: DateTime.UtcNow.AddDays(-1));
|
||||
|
||||
var result = await _controller.GetRecentUpgrades();
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("TotalCount").GetInt32().ShouldBe(1);
|
||||
var upgrade = body.GetProperty("Items")[0];
|
||||
upgrade.GetProperty("PreviousScore").GetInt32().ShouldBe(100);
|
||||
upgrade.GetProperty("NewScore").GetInt32().ShouldBe(250);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetRecentUpgrades_WithScoreDecrease_DoesNotCountAsUpgrade()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 300, recordedAt: DateTime.UtcNow.AddDays(-2));
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 150, recordedAt: DateTime.UtcNow.AddDays(-1));
|
||||
|
||||
var result = await _controller.GetRecentUpgrades();
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("TotalCount").GetInt32().ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetRecentUpgrades_WithMultipleUpgradesInSameGroup_CountsEach()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
// 100 -> 200 -> 300 = two upgrades for the same item
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 100, recordedAt: DateTime.UtcNow.AddDays(-3));
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 200, recordedAt: DateTime.UtcNow.AddDays(-2));
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 300, recordedAt: DateTime.UtcNow.AddDays(-1));
|
||||
|
||||
var result = await _controller.GetRecentUpgrades();
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("TotalCount").GetInt32().ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetRecentUpgrades_WithDaysFilter_ExcludesOlderHistory()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
// Old upgrade (outside 7-day window)
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 100, recordedAt: DateTime.UtcNow.AddDays(-20));
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 250, recordedAt: DateTime.UtcNow.AddDays(-15));
|
||||
// Recent upgrade (inside 7-day window)
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 2, score: 100, recordedAt: DateTime.UtcNow.AddDays(-3));
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 2, score: 300, recordedAt: DateTime.UtcNow.AddDays(-1));
|
||||
|
||||
var result = await _controller.GetRecentUpgrades(days: 7);
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("TotalCount").GetInt32().ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetRecentUpgrades_WithUpgradeCrossingWindowBoundary_IsDetected()
|
||||
{
|
||||
// CR2: pre-window baseline must still participate so the first in-window
|
||||
// row can be recognised as an upgrade.
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 100, recordedAt: DateTime.UtcNow.AddDays(-10));
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 200, recordedAt: DateTime.UtcNow.AddDays(-3));
|
||||
|
||||
var result = await _controller.GetRecentUpgrades(days: 7);
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("TotalCount").GetInt32().ShouldBe(1);
|
||||
var upgrade = body.GetProperty("Items")[0];
|
||||
upgrade.GetProperty("PreviousScore").GetInt32().ShouldBe(100);
|
||||
upgrade.GetProperty("NewScore").GetInt32().ShouldBe(200);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetRecentUpgrades_WithSortByScoreDeltaDescending_OrdersByLargestDelta()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
// Item 1: +50
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 100, recordedAt: DateTime.UtcNow.AddDays(-3));
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 150, recordedAt: DateTime.UtcNow.AddDays(-2));
|
||||
// Item 2: +400
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 2, score: 100, recordedAt: DateTime.UtcNow.AddDays(-3));
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 2, score: 500, recordedAt: DateTime.UtcNow.AddDays(-2));
|
||||
|
||||
var result = await _controller.GetRecentUpgrades(sortBy: CfUpgradesSortBy.ScoreDelta);
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
var items = body.GetProperty("Items");
|
||||
items[0].GetProperty("NewScore").GetInt32().ShouldBe(500);
|
||||
items[1].GetProperty("NewScore").GetInt32().ShouldBe(150);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetRecentUpgrades_WithSortByTitleAscending_OrdersAlphabetically()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 2, score: 100, recordedAt: DateTime.UtcNow.AddDays(-3));
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 2, score: 200, recordedAt: DateTime.UtcNow.AddDays(-2));
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 100, recordedAt: DateTime.UtcNow.AddDays(-3));
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 200, recordedAt: DateTime.UtcNow.AddDays(-2));
|
||||
|
||||
var result = await _controller.GetRecentUpgrades(sortBy: CfUpgradesSortBy.Title);
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
var items = body.GetProperty("Items");
|
||||
items[0].GetProperty("Title").GetString().ShouldBe("Item 1");
|
||||
items[1].GetProperty("Title").GetString().ShouldBe("Item 2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetRecentUpgrades_WithSearchFilter_ReturnsMatchingTitlesOnly()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
// AddHistoryEntry titles as "Item {externalItemId}".
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 42, score: 100, recordedAt: DateTime.UtcNow.AddDays(-3));
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 42, score: 200, recordedAt: DateTime.UtcNow.AddDays(-2));
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 99, score: 100, recordedAt: DateTime.UtcNow.AddDays(-3));
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 99, score: 200, recordedAt: DateTime.UtcNow.AddDays(-2));
|
||||
|
||||
var result = await _controller.GetRecentUpgrades(search: "42");
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("TotalCount").GetInt32().ShouldBe(1);
|
||||
body.GetProperty("Items")[0].GetProperty("Title").GetString().ShouldBe("Item 42");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetRecentUpgrades_ReturnsSortedByMostRecentFirst()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
// Item 1: upgrade happened 5 days ago
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 100, recordedAt: DateTime.UtcNow.AddDays(-6));
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 200, recordedAt: DateTime.UtcNow.AddDays(-5));
|
||||
// Item 2: upgrade happened 1 day ago
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 2, score: 100, recordedAt: DateTime.UtcNow.AddDays(-2));
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 2, score: 300, recordedAt: DateTime.UtcNow.AddDays(-1));
|
||||
|
||||
var result = await _controller.GetRecentUpgrades();
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
var items = body.GetProperty("Items");
|
||||
items.GetArrayLength().ShouldBe(2);
|
||||
// Most recent upgrade (item 2) should be first
|
||||
items[0].GetProperty("NewScore").GetInt32().ShouldBe(300);
|
||||
items[1].GetProperty("NewScore").GetInt32().ShouldBe(200);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetStats Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetStats_WithNoEntries_ReturnsZeroes()
|
||||
{
|
||||
var result = await _controller.GetStats();
|
||||
var okResult = result.ShouldBeOfType<OkObjectResult>();
|
||||
var stats = okResult.Value.ShouldBeOfType<CustomFormatScoreStatsResponse>();
|
||||
|
||||
stats.TotalTracked.ShouldBe(0);
|
||||
stats.BelowCutoff.ShouldBe(0);
|
||||
stats.AtOrAboveCutoff.ShouldBe(0);
|
||||
stats.RecentUpgrades.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStats_CorrectlyCategorizesBelowAndAboveCutoff()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
AddScoreEntry(radarr.Id, 1, "Below", currentScore: 100, cutoffScore: 500);
|
||||
AddScoreEntry(radarr.Id, 2, "At", currentScore: 500, cutoffScore: 500);
|
||||
AddScoreEntry(radarr.Id, 3, "Above", currentScore: 600, cutoffScore: 500);
|
||||
|
||||
var result = await _controller.GetStats();
|
||||
var okResult = result.ShouldBeOfType<OkObjectResult>();
|
||||
var stats = okResult.Value.ShouldBeOfType<CustomFormatScoreStatsResponse>();
|
||||
|
||||
stats.TotalTracked.ShouldBe(3);
|
||||
stats.BelowCutoff.ShouldBe(1);
|
||||
stats.AtOrAboveCutoff.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStats_CountsRecentUpgradesFromLast7Days()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
AddScoreEntry(radarr.Id, 1, "Movie", currentScore: 300, cutoffScore: 500);
|
||||
|
||||
// Upgrade within 7 days
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 100, recordedAt: DateTime.UtcNow.AddDays(-3));
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 300, recordedAt: DateTime.UtcNow.AddDays(-1));
|
||||
|
||||
// Upgrade outside 7 days (should not be counted)
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 2, score: 50, recordedAt: DateTime.UtcNow.AddDays(-20));
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 2, score: 200, recordedAt: DateTime.UtcNow.AddDays(-15));
|
||||
|
||||
var result = await _controller.GetStats();
|
||||
var okResult = result.ShouldBeOfType<OkObjectResult>();
|
||||
var stats = okResult.Value.ShouldBeOfType<CustomFormatScoreStatsResponse>();
|
||||
|
||||
stats.RecentUpgrades.ShouldBe(1);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helpers
|
||||
|
||||
private void AddScoreEntry(
|
||||
Guid arrInstanceId,
|
||||
long externalItemId,
|
||||
string title,
|
||||
int currentScore,
|
||||
int cutoffScore,
|
||||
InstanceType itemType = InstanceType.Radarr,
|
||||
DateTime? lastSynced = null,
|
||||
bool isMonitored = true,
|
||||
string qualityProfileName = "HD")
|
||||
{
|
||||
_dataContext.CustomFormatScoreEntries.Add(new CustomFormatScoreEntry
|
||||
{
|
||||
ArrInstanceId = arrInstanceId,
|
||||
ExternalItemId = externalItemId,
|
||||
EpisodeId = 0,
|
||||
ItemType = itemType,
|
||||
Title = title,
|
||||
FileId = externalItemId * 10,
|
||||
CurrentScore = currentScore,
|
||||
CutoffScore = cutoffScore,
|
||||
QualityProfileName = qualityProfileName,
|
||||
IsMonitored = isMonitored,
|
||||
LastSyncedAt = lastSynced ?? DateTime.UtcNow
|
||||
});
|
||||
_dataContext.SaveChanges();
|
||||
}
|
||||
|
||||
private void AddHistoryEntry(
|
||||
Guid arrInstanceId,
|
||||
long externalItemId,
|
||||
int score,
|
||||
DateTime recordedAt,
|
||||
long episodeId = 0,
|
||||
int cutoffScore = 500,
|
||||
InstanceType itemType = InstanceType.Radarr)
|
||||
{
|
||||
_dataContext.CustomFormatScoreHistory.Add(new CustomFormatScoreHistory
|
||||
{
|
||||
ArrInstanceId = arrInstanceId,
|
||||
ExternalItemId = externalItemId,
|
||||
EpisodeId = episodeId,
|
||||
ItemType = itemType,
|
||||
Title = $"Item {externalItemId}",
|
||||
Score = score,
|
||||
CutoffScore = cutoffScore,
|
||||
RecordedAt = recordedAt
|
||||
});
|
||||
_dataContext.SaveChanges();
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
using System.Text.Json;
|
||||
using Cleanuparr.Api.Features.Seeker.Controllers;
|
||||
using Cleanuparr.Api.Tests.Features.Seeker.TestHelpers;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Events;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Shouldly;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Features.Seeker;
|
||||
|
||||
public class SearchStatsControllerTests : IDisposable
|
||||
{
|
||||
private readonly DataContext _dataContext;
|
||||
private readonly EventsContext _eventsContext;
|
||||
private readonly SearchStatsController _controller;
|
||||
|
||||
public SearchStatsControllerTests()
|
||||
{
|
||||
_dataContext = SeekerTestDataFactory.CreateDataContext();
|
||||
_eventsContext = SeekerTestDataFactory.CreateEventsContext();
|
||||
_controller = new SearchStatsController(_dataContext, _eventsContext);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_dataContext.Dispose();
|
||||
_eventsContext.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
private static JsonElement GetResponseBody(IActionResult result)
|
||||
{
|
||||
var okResult = result.ShouldBeOfType<OkObjectResult>();
|
||||
var json = JsonSerializer.Serialize(okResult.Value);
|
||||
return JsonDocument.Parse(json).RootElement;
|
||||
}
|
||||
|
||||
#region GetEvents with SearchEventData
|
||||
|
||||
[Fact]
|
||||
public async Task GetEvents_WithNoSearchEventData_ReturnsUnknownDefaults()
|
||||
{
|
||||
AddSearchEvent();
|
||||
|
||||
var result = await _controller.GetEvents();
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
var item = body.GetProperty("Items")[0];
|
||||
item.GetProperty("ItemTitle").GetString().ShouldBe("Unknown");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEvents_WithSearchEventData_ReturnsAllFields()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
|
||||
AddSearchEvent(
|
||||
arrInstanceId: radarr.Id,
|
||||
itemTitle: "Movie A",
|
||||
searchType: SeekerSearchType.Proactive,
|
||||
searchReason: SeekerSearchReason.Missing,
|
||||
grabbedItems: ["Movie A (2024)"]);
|
||||
|
||||
var result = await _controller.GetEvents();
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
var item = body.GetProperty("Items")[0];
|
||||
item.GetProperty("ArrInstanceId").GetString().ShouldBe(radarr.Id.ToString());
|
||||
item.GetProperty("InstanceType").GetString().ShouldBe(nameof(InstanceType.Radarr));
|
||||
item.GetProperty("ItemTitle").GetString().ShouldBe("Movie A");
|
||||
item.GetProperty("SearchType").GetString().ShouldBe(nameof(SeekerSearchType.Proactive));
|
||||
item.GetProperty("SearchReason").GetString().ShouldBe(nameof(SeekerSearchReason.Missing));
|
||||
item.GetProperty("GrabbedItems")[0].GetString().ShouldBe("Movie A (2024)");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEvents_WithReplacementSearchType_ParsesCorrectEnum()
|
||||
{
|
||||
AddSearchEvent(
|
||||
itemTitle: "Series A",
|
||||
searchType: SeekerSearchType.Replacement,
|
||||
searchReason: SeekerSearchReason.Replacement);
|
||||
|
||||
var result = await _controller.GetEvents();
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
var item = body.GetProperty("Items")[0];
|
||||
item.GetProperty("SearchType").GetString().ShouldBe(nameof(SeekerSearchType.Replacement));
|
||||
item.GetProperty("SearchReason").GetString().ShouldBe(nameof(SeekerSearchReason.Replacement));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetEvents Filtering
|
||||
|
||||
[Fact]
|
||||
public async Task GetEvents_WithInstanceIdFilter_FiltersByArrInstanceId()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
var sonarr = SeekerTestDataFactory.AddSonarrInstance(_dataContext);
|
||||
|
||||
AddSearchEvent(arrInstanceId: radarr.Id, itemTitle: "Radarr Movie");
|
||||
AddSearchEvent(arrInstanceId: sonarr.Id, itemTitle: "Sonarr Series");
|
||||
|
||||
var result = await _controller.GetEvents(instanceId: radarr.Id);
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("TotalCount").GetInt32().ShouldBe(1);
|
||||
body.GetProperty("Items")[0].GetProperty("ArrInstanceId").GetString().ShouldBe(radarr.Id.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEvents_WithCycleIdFilter_ReturnsOnlyMatchingCycle()
|
||||
{
|
||||
var cycleA = Guid.NewGuid();
|
||||
var cycleB = Guid.NewGuid();
|
||||
|
||||
AddSearchEvent(cycleId: cycleA, itemTitle: "Cycle A Movie");
|
||||
AddSearchEvent(cycleId: cycleB, itemTitle: "Cycle B Movie");
|
||||
|
||||
var result = await _controller.GetEvents(cycleId: cycleA);
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("TotalCount").GetInt32().ShouldBe(1);
|
||||
body.GetProperty("Items")[0].GetProperty("ItemTitle").GetString().ShouldBe("Cycle A Movie");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEvents_WithSearchFilter_FiltersOnItemTitle()
|
||||
{
|
||||
AddSearchEvent(itemTitle: "The Matrix");
|
||||
AddSearchEvent(itemTitle: "Breaking Bad");
|
||||
|
||||
var result = await _controller.GetEvents(search: "matrix");
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("TotalCount").GetInt32().ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEvents_WithPagination_ReturnsCorrectPageAndCount()
|
||||
{
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
AddSearchEvent(itemTitle: $"Event {i}");
|
||||
}
|
||||
|
||||
var result = await _controller.GetEvents(page: 2, pageSize: 2);
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("TotalCount").GetInt32().ShouldBe(5);
|
||||
body.GetProperty("TotalPages").GetInt32().ShouldBe(3);
|
||||
body.GetProperty("Page").GetInt32().ShouldBe(2);
|
||||
body.GetProperty("Items").GetArrayLength().ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEvents_WithSortByTitleAscending_OrdersAlphabetically()
|
||||
{
|
||||
AddSearchEvent(itemTitle: "Charlie");
|
||||
AddSearchEvent(itemTitle: "Alpha");
|
||||
AddSearchEvent(itemTitle: "Bravo");
|
||||
|
||||
var result = await _controller.GetEvents(
|
||||
sortBy: SearchEventsSortBy.Title,
|
||||
sortDirection: Cleanuparr.Domain.Enums.SortDirection.Asc);
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
var items = body.GetProperty("Items");
|
||||
items[0].GetProperty("ItemTitle").GetString().ShouldBe("Alpha");
|
||||
items[1].GetProperty("ItemTitle").GetString().ShouldBe("Bravo");
|
||||
items[2].GetProperty("ItemTitle").GetString().ShouldBe("Charlie");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEvents_WithSortByTimestampAscending_OldestFirst()
|
||||
{
|
||||
AddSearchEvent(itemTitle: "Newest", timestamp: DateTime.UtcNow);
|
||||
AddSearchEvent(itemTitle: "Oldest", timestamp: DateTime.UtcNow.AddHours(-2));
|
||||
AddSearchEvent(itemTitle: "Middle", timestamp: DateTime.UtcNow.AddHours(-1));
|
||||
|
||||
var result = await _controller.GetEvents(sortDirection: Cleanuparr.Domain.Enums.SortDirection.Asc);
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
var items = body.GetProperty("Items");
|
||||
items[0].GetProperty("ItemTitle").GetString().ShouldBe("Oldest");
|
||||
items[2].GetProperty("ItemTitle").GetString().ShouldBe("Newest");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEvents_WithSearchStatusFilter_ReturnsOnlyMatchingStatuses()
|
||||
{
|
||||
AddSearchEvent(itemTitle: "A", searchStatus: SearchCommandStatus.Completed);
|
||||
AddSearchEvent(itemTitle: "B", searchStatus: SearchCommandStatus.Failed);
|
||||
AddSearchEvent(itemTitle: "C", searchStatus: SearchCommandStatus.TimedOut);
|
||||
|
||||
var result = await _controller.GetEvents(
|
||||
searchStatus: [SearchCommandStatus.Completed, SearchCommandStatus.Failed]);
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("TotalCount").GetInt32().ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEvents_WithSearchTypeFilter_ReturnsOnlyMatchingType()
|
||||
{
|
||||
AddSearchEvent(itemTitle: "Proactive Movie", searchType: SeekerSearchType.Proactive);
|
||||
AddSearchEvent(itemTitle: "Replacement Movie", searchType: SeekerSearchType.Replacement);
|
||||
|
||||
var result = await _controller.GetEvents(searchType: SeekerSearchType.Replacement);
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("TotalCount").GetInt32().ShouldBe(1);
|
||||
body.GetProperty("Items")[0].GetProperty("ItemTitle").GetString().ShouldBe("Replacement Movie");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEvents_WithSearchReasonFilter_ReturnsOnlyMatchingReason()
|
||||
{
|
||||
AddSearchEvent(itemTitle: "Missing", searchReason: SeekerSearchReason.Missing);
|
||||
AddSearchEvent(itemTitle: "Cutoff", searchReason: SeekerSearchReason.QualityCutoffNotMet);
|
||||
|
||||
var result = await _controller.GetEvents(searchReason: SeekerSearchReason.QualityCutoffNotMet);
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("TotalCount").GetInt32().ShouldBe(1);
|
||||
body.GetProperty("Items")[0].GetProperty("ItemTitle").GetString().ShouldBe("Cutoff");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEvents_WithGrabbedTrue_KeepsOnlyEventsWithGrabbedItems()
|
||||
{
|
||||
AddSearchEvent(itemTitle: "With Grabs", grabbedItems: ["movie (2024)"]);
|
||||
AddSearchEvent(itemTitle: "No Grabs", grabbedItems: []);
|
||||
|
||||
var result = await _controller.GetEvents(grabbed: true);
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("TotalCount").GetInt32().ShouldBe(1);
|
||||
body.GetProperty("Items")[0].GetProperty("ItemTitle").GetString().ShouldBe("With Grabs");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEvents_WithGrabbedFalse_KeepsOnlyEventsWithoutGrabbedItems()
|
||||
{
|
||||
AddSearchEvent(itemTitle: "With Grabs", grabbedItems: ["movie (2024)"]);
|
||||
AddSearchEvent(itemTitle: "No Grabs", grabbedItems: []);
|
||||
|
||||
var result = await _controller.GetEvents(grabbed: false);
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("TotalCount").GetInt32().ShouldBe(1);
|
||||
body.GetProperty("Items")[0].GetProperty("ItemTitle").GetString().ShouldBe("No Grabs");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helpers
|
||||
|
||||
private void AddSearchEvent(
|
||||
string? itemTitle = null,
|
||||
SeekerSearchType searchType = SeekerSearchType.Proactive,
|
||||
SeekerSearchReason searchReason = SeekerSearchReason.Missing,
|
||||
List<string>? grabbedItems = null,
|
||||
Guid? arrInstanceId = null,
|
||||
Guid? cycleId = null,
|
||||
SearchCommandStatus? searchStatus = null,
|
||||
DateTime? timestamp = null)
|
||||
{
|
||||
var appEvent = new AppEvent
|
||||
{
|
||||
EventType = EventType.SearchTriggered,
|
||||
Message = "Search triggered",
|
||||
Severity = EventSeverity.Information,
|
||||
ArrInstanceId = arrInstanceId,
|
||||
CycleId = cycleId,
|
||||
SearchStatus = searchStatus,
|
||||
Timestamp = timestamp ?? DateTime.UtcNow
|
||||
};
|
||||
|
||||
_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();
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
using Cleanuparr.Api.Features.Seeker.Contracts.Requests;
|
||||
using Cleanuparr.Api.Features.Seeker.Contracts.Responses;
|
||||
using Cleanuparr.Api.Features.Seeker.Controllers;
|
||||
using Cleanuparr.Api.Tests.Features.Seeker.TestHelpers;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Services.Interfaces;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Seeker;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using Shouldly;
|
||||
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Features.Seeker;
|
||||
|
||||
public class SeekerConfigControllerTests : IDisposable
|
||||
{
|
||||
private readonly DataContext _dataContext;
|
||||
private readonly ILogger<SeekerConfigController> _logger;
|
||||
private readonly IJobManagementService _jobManagementService;
|
||||
private readonly SeekerConfigController _controller;
|
||||
|
||||
public SeekerConfigControllerTests()
|
||||
{
|
||||
_dataContext = SeekerTestDataFactory.CreateDataContext();
|
||||
_logger = Substitute.For<ILogger<SeekerConfigController>>();
|
||||
_jobManagementService = Substitute.For<IJobManagementService>();
|
||||
_controller = new SeekerConfigController(_logger, _dataContext, _jobManagementService);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_dataContext.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
#region GetSeekerConfig Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetSeekerConfig_WithNoSeekerInstanceConfigs_ReturnsDefaults()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
|
||||
var result = await _controller.GetSeekerConfig();
|
||||
var okResult = result.ShouldBeOfType<OkObjectResult>();
|
||||
var response = okResult.Value.ShouldBeOfType<SeekerConfigResponse>();
|
||||
|
||||
var instance = response.Instances.ShouldHaveSingleItem();
|
||||
instance.ArrInstanceId.ShouldBe(radarr.Id);
|
||||
instance.Enabled.ShouldBeFalse();
|
||||
instance.SkipTags.ShouldBeEmpty();
|
||||
instance.ActiveDownloadLimit.ShouldBe(3);
|
||||
instance.MinCycleTimeDays.ShouldBe(7);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSeekerConfig_OnlyReturnsSonarrAndRadarrInstances()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
var sonarr = SeekerTestDataFactory.AddSonarrInstance(_dataContext);
|
||||
var lidarr = SeekerTestDataFactory.AddLidarrInstance(_dataContext);
|
||||
|
||||
var result = await _controller.GetSeekerConfig();
|
||||
var okResult = result.ShouldBeOfType<OkObjectResult>();
|
||||
var response = okResult.Value.ShouldBeOfType<SeekerConfigResponse>();
|
||||
|
||||
response.Instances.Count.ShouldBe(2);
|
||||
response.Instances.ShouldContain(i => i.ArrInstanceId == radarr.Id);
|
||||
response.Instances.ShouldContain(i => i.ArrInstanceId == sonarr.Id);
|
||||
response.Instances.ShouldNotContain(i => i.ArrInstanceId == lidarr.Id);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region UpdateSeekerConfig Tests
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSeekerConfig_WithProactiveEnabledAndNoInstancesEnabled_ThrowsValidationException()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
var request = new UpdateSeekerConfigRequest
|
||||
{
|
||||
SearchEnabled = true,
|
||||
SearchInterval = 5,
|
||||
ProactiveSearchEnabled = true,
|
||||
Instances =
|
||||
[
|
||||
new UpdateSeekerInstanceConfigRequest
|
||||
{
|
||||
ArrInstanceId = radarr.Id,
|
||||
Enabled = false // No instances enabled
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
await Should.ThrowAsync<ValidationException>(() => _controller.UpdateSeekerConfig(request));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSeekerConfig_WhenIntervalChanges_ReschedulesSeeker()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
_dataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
|
||||
{
|
||||
ArrInstanceId = radarr.Id,
|
||||
Enabled = true
|
||||
});
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
// Default interval is 3, change to 5
|
||||
var request = new UpdateSeekerConfigRequest
|
||||
{
|
||||
SearchEnabled = true,
|
||||
SearchInterval = 5,
|
||||
ProactiveSearchEnabled = true,
|
||||
Instances =
|
||||
[
|
||||
new UpdateSeekerInstanceConfigRequest { ArrInstanceId = radarr.Id, Enabled = true }
|
||||
]
|
||||
};
|
||||
|
||||
await _controller.UpdateSeekerConfig(request);
|
||||
|
||||
await _jobManagementService.Received(1)
|
||||
.StartJob(JobType.Seeker, null, Arg.Any<string>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSeekerConfig_WhenIntervalUnchanged_DoesNotReschedule()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
_dataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
|
||||
{
|
||||
ArrInstanceId = radarr.Id,
|
||||
Enabled = true
|
||||
});
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
// Keep interval at default (3)
|
||||
var request = new UpdateSeekerConfigRequest
|
||||
{
|
||||
SearchEnabled = true,
|
||||
SearchInterval = 3,
|
||||
ProactiveSearchEnabled = true,
|
||||
Instances =
|
||||
[
|
||||
new UpdateSeekerInstanceConfigRequest { ArrInstanceId = radarr.Id, Enabled = true }
|
||||
]
|
||||
};
|
||||
|
||||
await _controller.UpdateSeekerConfig(request);
|
||||
|
||||
await _jobManagementService.DidNotReceive()
|
||||
.StartJob(Arg.Any<JobType>(), null, Arg.Any<string>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSeekerConfig_WhenCustomFormatScoreEnabled_StartsAndTriggersSyncerJob()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
_dataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
|
||||
{
|
||||
ArrInstanceId = radarr.Id,
|
||||
Enabled = true
|
||||
});
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
// UseCustomFormatScore was false (default), now enable it on the instance
|
||||
var request = new UpdateSeekerConfigRequest
|
||||
{
|
||||
SearchEnabled = true,
|
||||
SearchInterval = 3,
|
||||
ProactiveSearchEnabled = true,
|
||||
Instances =
|
||||
[
|
||||
new UpdateSeekerInstanceConfigRequest { ArrInstanceId = radarr.Id, Enabled = true, UseCustomFormatScore = true }
|
||||
]
|
||||
};
|
||||
|
||||
await _controller.UpdateSeekerConfig(request);
|
||||
|
||||
await _jobManagementService.Received(1)
|
||||
.StartJob(JobType.CustomFormatScoreSyncer, null, Arg.Any<string>());
|
||||
await _jobManagementService.Received(1)
|
||||
.TriggerJobOnce(JobType.CustomFormatScoreSyncer);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSeekerConfig_WhenCustomFormatScoreDisabled_StopsSyncerJob()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
_dataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
|
||||
{
|
||||
ArrInstanceId = radarr.Id,
|
||||
Enabled = true,
|
||||
UseCustomFormatScore = true
|
||||
});
|
||||
|
||||
// Syncer was running: both proactive and CF score were enabled
|
||||
var config = await _dataContext.SeekerConfigs.FirstAsync();
|
||||
config.ProactiveSearchEnabled = true;
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
// Disable CF score — syncer conditions no longer met
|
||||
var request = new UpdateSeekerConfigRequest
|
||||
{
|
||||
SearchEnabled = true,
|
||||
SearchInterval = 3,
|
||||
ProactiveSearchEnabled = true,
|
||||
Instances =
|
||||
[
|
||||
new UpdateSeekerInstanceConfigRequest { ArrInstanceId = radarr.Id, Enabled = true, UseCustomFormatScore = false }
|
||||
]
|
||||
};
|
||||
|
||||
await _controller.UpdateSeekerConfig(request);
|
||||
|
||||
await _jobManagementService.Received(1)
|
||||
.StopJob(JobType.CustomFormatScoreSyncer);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSeekerConfig_WhenProactiveSearchDisabled_StopsSyncerJob()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
_dataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
|
||||
{
|
||||
ArrInstanceId = radarr.Id,
|
||||
Enabled = true,
|
||||
UseCustomFormatScore = true
|
||||
});
|
||||
|
||||
// Syncer was running: both proactive and CF score were enabled
|
||||
var config = await _dataContext.SeekerConfigs.FirstAsync();
|
||||
config.ProactiveSearchEnabled = true;
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
// Disable proactive search — syncer should stop even though CF score is still enabled
|
||||
var request = new UpdateSeekerConfigRequest
|
||||
{
|
||||
SearchEnabled = true,
|
||||
SearchInterval = 3,
|
||||
ProactiveSearchEnabled = false,
|
||||
Instances =
|
||||
[
|
||||
new UpdateSeekerInstanceConfigRequest { ArrInstanceId = radarr.Id, Enabled = true, UseCustomFormatScore = true }
|
||||
]
|
||||
};
|
||||
|
||||
await _controller.UpdateSeekerConfig(request);
|
||||
|
||||
await _jobManagementService.Received(1)
|
||||
.StopJob(JobType.CustomFormatScoreSyncer);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSeekerConfig_WhenProactiveSearchEnabled_WithCfScoreActive_StartsAndTriggersSyncer()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
_dataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
|
||||
{
|
||||
ArrInstanceId = radarr.Id,
|
||||
Enabled = true,
|
||||
UseCustomFormatScore = true
|
||||
});
|
||||
|
||||
// Syncer was NOT running: CF score enabled but proactive was off (default)
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
// Enable proactive search — syncer should start
|
||||
var request = new UpdateSeekerConfigRequest
|
||||
{
|
||||
SearchEnabled = true,
|
||||
SearchInterval = 3,
|
||||
ProactiveSearchEnabled = true,
|
||||
Instances =
|
||||
[
|
||||
new UpdateSeekerInstanceConfigRequest { ArrInstanceId = radarr.Id, Enabled = true, UseCustomFormatScore = true }
|
||||
]
|
||||
};
|
||||
|
||||
await _controller.UpdateSeekerConfig(request);
|
||||
|
||||
await _jobManagementService.Received(1)
|
||||
.StartJob(JobType.CustomFormatScoreSyncer, null, Arg.Any<string>());
|
||||
await _jobManagementService.Received(1)
|
||||
.TriggerJobOnce(JobType.CustomFormatScoreSyncer);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSeekerConfig_WhenCustomFormatScoreEnabledButProactiveDisabled_DoesNotStartSyncer()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
_dataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
|
||||
{
|
||||
ArrInstanceId = radarr.Id,
|
||||
Enabled = true,
|
||||
UseCustomFormatScore = false
|
||||
});
|
||||
|
||||
// ProactiveSearchEnabled stays false (default)
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
// Enable CF score but keep proactive disabled — syncer should NOT start
|
||||
var request = new UpdateSeekerConfigRequest
|
||||
{
|
||||
SearchEnabled = true,
|
||||
SearchInterval = 3,
|
||||
ProactiveSearchEnabled = false,
|
||||
Instances =
|
||||
[
|
||||
new UpdateSeekerInstanceConfigRequest { ArrInstanceId = radarr.Id, Enabled = true, UseCustomFormatScore = true }
|
||||
]
|
||||
};
|
||||
|
||||
await _controller.UpdateSeekerConfig(request);
|
||||
|
||||
await _jobManagementService.DidNotReceive()
|
||||
.StartJob(JobType.CustomFormatScoreSyncer, null, Arg.Any<string>());
|
||||
await _jobManagementService.DidNotReceive()
|
||||
.TriggerJobOnce(JobType.CustomFormatScoreSyncer);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSeekerConfig_SyncsExistingAndCreatesNewInstanceConfigs()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
var sonarr = SeekerTestDataFactory.AddSonarrInstance(_dataContext);
|
||||
|
||||
// Radarr already has a config
|
||||
_dataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
|
||||
{
|
||||
ArrInstanceId = radarr.Id,
|
||||
Enabled = false,
|
||||
SkipTags = ["old-tag"],
|
||||
ActiveDownloadLimit = 2,
|
||||
MinCycleTimeDays = 5
|
||||
});
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
var request = new UpdateSeekerConfigRequest
|
||||
{
|
||||
SearchEnabled = true,
|
||||
SearchInterval = 3,
|
||||
ProactiveSearchEnabled = true,
|
||||
Instances =
|
||||
[
|
||||
// Update existing radarr config
|
||||
new UpdateSeekerInstanceConfigRequest
|
||||
{
|
||||
ArrInstanceId = radarr.Id,
|
||||
Enabled = true,
|
||||
SkipTags = ["new-tag"],
|
||||
ActiveDownloadLimit = 5,
|
||||
MinCycleTimeDays = 14
|
||||
},
|
||||
// Create new sonarr config
|
||||
new UpdateSeekerInstanceConfigRequest
|
||||
{
|
||||
ArrInstanceId = sonarr.Id,
|
||||
Enabled = true,
|
||||
SkipTags = ["sonarr-tag"],
|
||||
ActiveDownloadLimit = 3,
|
||||
MinCycleTimeDays = 7
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
await _controller.UpdateSeekerConfig(request);
|
||||
|
||||
var configs = await _dataContext.SeekerInstanceConfigs.ToListAsync();
|
||||
configs.Count.ShouldBe(2);
|
||||
|
||||
var radarrConfig = configs.First(c => c.ArrInstanceId == radarr.Id);
|
||||
radarrConfig.Enabled.ShouldBeTrue();
|
||||
radarrConfig.SkipTags.ShouldContain("new-tag");
|
||||
radarrConfig.ActiveDownloadLimit.ShouldBe(5);
|
||||
radarrConfig.MinCycleTimeDays.ShouldBe(14);
|
||||
|
||||
var sonarrConfig = configs.First(c => c.ArrInstanceId == sonarr.Id);
|
||||
sonarrConfig.Enabled.ShouldBeTrue();
|
||||
sonarrConfig.SkipTags.ShouldContain("sonarr-tag");
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
+166
@@ -0,0 +1,166 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
|
||||
using Cleanuparr.Persistence.Models.Configuration.General;
|
||||
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
|
||||
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Seeker;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Features.Seeker.TestHelpers;
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating SQLite in-memory contexts for Seeker controller tests
|
||||
/// </summary>
|
||||
public static class SeekerTestDataFactory
|
||||
{
|
||||
public static DataContext CreateDataContext()
|
||||
{
|
||||
var connection = new SqliteConnection("DataSource=:memory:");
|
||||
connection.Open();
|
||||
|
||||
var options = new DbContextOptionsBuilder<DataContext>()
|
||||
.UseSqlite(connection)
|
||||
.UseLowerCaseNamingConvention()
|
||||
.UseSnakeCaseNamingConvention()
|
||||
.Options;
|
||||
|
||||
var context = new DataContext(options);
|
||||
context.Database.EnsureCreated();
|
||||
|
||||
SeedDefaultData(context);
|
||||
return context;
|
||||
}
|
||||
|
||||
public static EventsContext CreateEventsContext()
|
||||
{
|
||||
var connection = new SqliteConnection("DataSource=:memory:");
|
||||
connection.Open();
|
||||
|
||||
var options = new DbContextOptionsBuilder<EventsContext>()
|
||||
.UseSqlite(connection)
|
||||
.UseLowerCaseNamingConvention()
|
||||
.UseSnakeCaseNamingConvention()
|
||||
.Options;
|
||||
|
||||
var context = new EventsContext(options);
|
||||
context.Database.EnsureCreated();
|
||||
return context;
|
||||
}
|
||||
|
||||
private static void SeedDefaultData(DataContext context)
|
||||
{
|
||||
context.GeneralConfigs.Add(new GeneralConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
DryRun = false,
|
||||
IgnoredDownloads = [],
|
||||
Log = new LoggingConfig()
|
||||
});
|
||||
|
||||
context.ArrConfigs.AddRange(
|
||||
new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Sonarr, Instances = [], FailedImportMaxStrikes = 3 },
|
||||
new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Radarr, Instances = [], FailedImportMaxStrikes = 3 },
|
||||
new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Lidarr, Instances = [], FailedImportMaxStrikes = 3 },
|
||||
new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Readarr, Instances = [], FailedImportMaxStrikes = 3 },
|
||||
new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Whisparr, Instances = [], FailedImportMaxStrikes = 3 }
|
||||
);
|
||||
|
||||
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 }
|
||||
});
|
||||
|
||||
context.DownloadCleanerConfigs.Add(new DownloadCleanerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
IgnoredDownloads = []
|
||||
});
|
||||
|
||||
context.SeekerConfigs.Add(new SeekerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
SearchEnabled = true,
|
||||
ProactiveSearchEnabled = false
|
||||
});
|
||||
|
||||
context.SaveChanges();
|
||||
}
|
||||
|
||||
public static ArrInstance AddSonarrInstance(DataContext context, bool enabled = true)
|
||||
{
|
||||
var arrConfig = context.ArrConfigs.First(x => x.Type == InstanceType.Sonarr);
|
||||
var instance = new ArrInstance
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Test Sonarr",
|
||||
Url = new Uri("http://sonarr:8989"),
|
||||
ApiKey = "test-api-key",
|
||||
Enabled = enabled,
|
||||
ArrConfigId = arrConfig.Id,
|
||||
ArrConfig = arrConfig
|
||||
};
|
||||
|
||||
arrConfig.Instances.Add(instance);
|
||||
context.ArrInstances.Add(instance);
|
||||
context.SaveChanges();
|
||||
return instance;
|
||||
}
|
||||
|
||||
public static ArrInstance AddRadarrInstance(DataContext context, bool enabled = true)
|
||||
{
|
||||
var arrConfig = context.ArrConfigs.First(x => x.Type == InstanceType.Radarr);
|
||||
var instance = new ArrInstance
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Test Radarr",
|
||||
Url = new Uri("http://radarr:7878"),
|
||||
ApiKey = "test-api-key",
|
||||
Enabled = enabled,
|
||||
ArrConfigId = arrConfig.Id,
|
||||
ArrConfig = arrConfig
|
||||
};
|
||||
|
||||
arrConfig.Instances.Add(instance);
|
||||
context.ArrInstances.Add(instance);
|
||||
context.SaveChanges();
|
||||
return instance;
|
||||
}
|
||||
|
||||
public static ArrInstance AddLidarrInstance(DataContext context, bool enabled = true)
|
||||
{
|
||||
var arrConfig = context.ArrConfigs.First(x => x.Type == InstanceType.Lidarr);
|
||||
var instance = new ArrInstance
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Test Lidarr",
|
||||
Url = new Uri("http://lidarr:8686"),
|
||||
ApiKey = "test-api-key",
|
||||
Enabled = enabled,
|
||||
ArrConfigId = arrConfig.Id,
|
||||
ArrConfig = arrConfig
|
||||
};
|
||||
|
||||
arrConfig.Instances.Add(instance);
|
||||
context.ArrInstances.Add(instance);
|
||||
context.SaveChanges();
|
||||
return instance;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
using Shouldly;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Features.SensitiveData;
|
||||
|
||||
public class SensitiveDataHelperTests
|
||||
{
|
||||
[Fact]
|
||||
public void IsPlaceholder_WithPlaceholder_ReturnsTrue()
|
||||
{
|
||||
SensitiveDataHelper.Placeholder.IsPlaceholder().ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsPlaceholder_WithAppriseStyledPlaceholder_ReturnsTrue()
|
||||
{
|
||||
$"discord://{SensitiveDataHelper.Placeholder}".IsPlaceholder().ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsPlaceholder_WithNull_ReturnsFalse()
|
||||
{
|
||||
((string?)null).IsPlaceholder().ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsPlaceholder_WithEmptyString_ReturnsFalse()
|
||||
{
|
||||
"".IsPlaceholder().ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsPlaceholder_WithRealValue_ReturnsFalse()
|
||||
{
|
||||
"my-secret-api-key-123".IsPlaceholder().ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("discord://webhook_id/webhook_token", "discord://••••••••")]
|
||||
[InlineData("slack://tokenA/tokenB/tokenC", "slack://••••••••")]
|
||||
[InlineData("mailto://user:pass@gmail.com", "mailto://••••••••")]
|
||||
[InlineData("json+http://user:pass@host/path", "json+http://••••••••")]
|
||||
public void MaskAppriseUrls_SingleUrl_MasksCorrectly(string input, string expected)
|
||||
{
|
||||
SensitiveDataHelper.MaskAppriseUrls(input).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MaskAppriseUrls_MultipleUrls_MasksAll()
|
||||
{
|
||||
var input = "discord://token1 slack://tokenA/tokenB";
|
||||
var result = SensitiveDataHelper.MaskAppriseUrls(input);
|
||||
|
||||
result.ShouldContain("discord://••••••••");
|
||||
result.ShouldContain("slack://••••••••");
|
||||
result.ShouldNotContain("token1");
|
||||
result.ShouldNotContain("tokenA");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MaskAppriseUrls_MultilineUrls_MasksAll()
|
||||
{
|
||||
var input = "discord://token1\nslack://tokenA/tokenB";
|
||||
var result = SensitiveDataHelper.MaskAppriseUrls(input);
|
||||
|
||||
result.ShouldContain("discord://••••••••");
|
||||
result.ShouldContain("slack://••••••••");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void MaskAppriseUrls_EmptyOrNull_ReturnsAsIs(string? input)
|
||||
{
|
||||
SensitiveDataHelper.MaskAppriseUrls(input).ShouldBe(input);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
using Cleanuparr.Api.Features.Arr.Contracts.Requests;
|
||||
using Cleanuparr.Api.Features.DownloadClient.Contracts.Requests;
|
||||
using Cleanuparr.Api.Features.Auth.Contracts.Requests;
|
||||
using Cleanuparr.Api.Features.General.Contracts.Requests;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Persistence.Models.Auth;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
using Shouldly;
|
||||
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Features.SensitiveData;
|
||||
|
||||
/// <summary>
|
||||
/// Tests that placeholder values are correctly handled on the input side:
|
||||
/// - UPDATE operations preserve the existing DB value when a placeholder is sent
|
||||
/// - CREATE operations reject placeholder values
|
||||
/// - TEST operations reject placeholder values
|
||||
/// </summary>
|
||||
public class SensitiveDataInputTests
|
||||
{
|
||||
private const string Placeholder = SensitiveDataHelper.Placeholder;
|
||||
|
||||
#region ArrInstanceRequest — UPDATE
|
||||
|
||||
[Fact]
|
||||
public void ArrInstanceRequest_ApplyTo_WithPlaceholderApiKey_PreservesExistingValue()
|
||||
{
|
||||
var request = new ArrInstanceRequest
|
||||
{
|
||||
Name = "Updated Sonarr",
|
||||
Url = "http://sonarr:8989",
|
||||
ApiKey = Placeholder,
|
||||
Version = 4,
|
||||
};
|
||||
|
||||
var existingInstance = new ArrInstance
|
||||
{
|
||||
Name = "Sonarr",
|
||||
Url = new Uri("http://sonarr:8989"),
|
||||
ApiKey = "original-secret-key",
|
||||
ArrConfigId = Guid.NewGuid(),
|
||||
Version = 4,
|
||||
};
|
||||
|
||||
request.ApplyTo(existingInstance);
|
||||
|
||||
existingInstance.ApiKey.ShouldBe("original-secret-key");
|
||||
existingInstance.Name.ShouldBe("Updated Sonarr");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ArrInstanceRequest_ApplyTo_WithRealApiKey_UpdatesValue()
|
||||
{
|
||||
var request = new ArrInstanceRequest
|
||||
{
|
||||
Name = "Sonarr",
|
||||
Url = "http://sonarr:8989",
|
||||
ApiKey = "brand-new-api-key",
|
||||
Version = 4,
|
||||
};
|
||||
|
||||
var existingInstance = new ArrInstance
|
||||
{
|
||||
Name = "Sonarr",
|
||||
Url = new Uri("http://sonarr:8989"),
|
||||
ApiKey = "original-secret-key",
|
||||
ArrConfigId = Guid.NewGuid(),
|
||||
Version = 4,
|
||||
};
|
||||
|
||||
request.ApplyTo(existingInstance);
|
||||
|
||||
existingInstance.ApiKey.ShouldBe("brand-new-api-key");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ArrInstanceRequest — CREATE
|
||||
|
||||
[Fact]
|
||||
public void ArrInstanceRequest_ToEntity_WithPlaceholderApiKey_ThrowsValidationException()
|
||||
{
|
||||
var request = new ArrInstanceRequest
|
||||
{
|
||||
Name = "Sonarr",
|
||||
Url = "http://sonarr:8989",
|
||||
ApiKey = Placeholder,
|
||||
Version = 4,
|
||||
};
|
||||
|
||||
Should.Throw<ValidationException>(() => request.ToEntity(Guid.NewGuid()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ArrInstanceRequest_ToEntity_WithRealApiKey_Succeeds()
|
||||
{
|
||||
var request = new ArrInstanceRequest
|
||||
{
|
||||
Name = "Sonarr",
|
||||
Url = "http://sonarr:8989",
|
||||
ApiKey = "real-api-key-123",
|
||||
Version = 4,
|
||||
};
|
||||
|
||||
var entity = request.ToEntity(Guid.NewGuid());
|
||||
entity.ApiKey.ShouldBe("real-api-key-123");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region TestArrInstanceRequest — TEST
|
||||
|
||||
[Fact]
|
||||
public void TestArrInstanceRequest_ToTestInstance_WithPlaceholderApiKey_AndNoResolvedKey_ThrowsValidationException()
|
||||
{
|
||||
var request = new TestArrInstanceRequest
|
||||
{
|
||||
Url = "http://sonarr:8989",
|
||||
ApiKey = Placeholder,
|
||||
Version = 4,
|
||||
};
|
||||
|
||||
Should.Throw<ValidationException>(() => request.ToTestInstance());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TestArrInstanceRequest_ToTestInstance_WithPlaceholderApiKey_AndResolvedKey_UsesResolvedKey()
|
||||
{
|
||||
var request = new TestArrInstanceRequest
|
||||
{
|
||||
Url = "http://sonarr:8989",
|
||||
ApiKey = Placeholder,
|
||||
Version = 4,
|
||||
InstanceId = Guid.NewGuid(),
|
||||
};
|
||||
|
||||
var instance = request.ToTestInstance("resolved-api-key-from-db");
|
||||
instance.ApiKey.ShouldBe("resolved-api-key-from-db");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TestArrInstanceRequest_ToTestInstance_WithRealApiKey_Succeeds()
|
||||
{
|
||||
var request = new TestArrInstanceRequest
|
||||
{
|
||||
Url = "http://sonarr:8989",
|
||||
ApiKey = "real-api-key",
|
||||
Version = 4,
|
||||
};
|
||||
|
||||
var instance = request.ToTestInstance();
|
||||
instance.ApiKey.ShouldBe("real-api-key");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region UpdateDownloadClientRequest — UPDATE
|
||||
|
||||
[Fact]
|
||||
public void UpdateDownloadClientRequest_ApplyTo_WithPlaceholderPassword_PreservesExistingValue()
|
||||
{
|
||||
var request = new UpdateDownloadClientRequest
|
||||
{
|
||||
Name = "Updated qBit",
|
||||
TypeName = DownloadClientTypeName.qBittorrent,
|
||||
Type = DownloadClientType.Torrent,
|
||||
Host = "http://qbit:8080",
|
||||
Username = "admin",
|
||||
Password = Placeholder,
|
||||
};
|
||||
|
||||
var existing = new DownloadClientConfig
|
||||
{
|
||||
Name = "qBittorrent",
|
||||
TypeName = DownloadClientTypeName.qBittorrent,
|
||||
Type = DownloadClientType.Torrent,
|
||||
Host = new Uri("http://qbit:8080"),
|
||||
Username = "admin",
|
||||
Password = "original-secret-password",
|
||||
};
|
||||
|
||||
var result = request.ApplyTo(existing);
|
||||
|
||||
result.Password.ShouldBe("original-secret-password");
|
||||
result.Name.ShouldBe("Updated qBit");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateDownloadClientRequest_ApplyTo_WithRealPassword_UpdatesValue()
|
||||
{
|
||||
var request = new UpdateDownloadClientRequest
|
||||
{
|
||||
Name = "qBittorrent",
|
||||
TypeName = DownloadClientTypeName.qBittorrent,
|
||||
Type = DownloadClientType.Torrent,
|
||||
Host = "http://qbit:8080",
|
||||
Username = "admin",
|
||||
Password = "new-password-123",
|
||||
};
|
||||
|
||||
var existing = new DownloadClientConfig
|
||||
{
|
||||
Name = "qBittorrent",
|
||||
TypeName = DownloadClientTypeName.qBittorrent,
|
||||
Type = DownloadClientType.Torrent,
|
||||
Host = new Uri("http://qbit:8080"),
|
||||
Username = "admin",
|
||||
Password = "original-secret-password",
|
||||
};
|
||||
|
||||
var result = request.ApplyTo(existing);
|
||||
|
||||
result.Password.ShouldBe("new-password-123");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region CreateDownloadClientRequest — CREATE
|
||||
|
||||
[Fact]
|
||||
public void CreateDownloadClientRequest_Validate_WithPlaceholderPassword_ThrowsValidationException()
|
||||
{
|
||||
var request = new CreateDownloadClientRequest
|
||||
{
|
||||
Name = "qBittorrent",
|
||||
TypeName = DownloadClientTypeName.qBittorrent,
|
||||
Type = DownloadClientType.Torrent,
|
||||
Host = "http://qbit:8080",
|
||||
Password = Placeholder,
|
||||
};
|
||||
|
||||
Should.Throw<ValidationException>(() => request.Validate());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateDownloadClientRequest_Validate_WithRealPassword_Succeeds()
|
||||
{
|
||||
var request = new CreateDownloadClientRequest
|
||||
{
|
||||
Name = "qBittorrent",
|
||||
TypeName = DownloadClientTypeName.qBittorrent,
|
||||
Type = DownloadClientType.Torrent,
|
||||
Host = "http://qbit:8080",
|
||||
Password = "real-password",
|
||||
};
|
||||
|
||||
Should.NotThrow(() => request.Validate());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateDownloadClientRequest_Validate_WithNullPassword_Succeeds()
|
||||
{
|
||||
var request = new CreateDownloadClientRequest
|
||||
{
|
||||
Name = "qBittorrent",
|
||||
TypeName = DownloadClientTypeName.qBittorrent,
|
||||
Type = DownloadClientType.Torrent,
|
||||
Host = "http://qbit:8080",
|
||||
Password = null,
|
||||
};
|
||||
|
||||
Should.NotThrow(() => request.Validate());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region TestDownloadClientRequest — TEST
|
||||
|
||||
[Fact]
|
||||
public void TestDownloadClientRequest_ToTestConfig_WithPlaceholderPassword_AndNoResolvedPassword_ThrowsValidationException()
|
||||
{
|
||||
var request = new TestDownloadClientRequest
|
||||
{
|
||||
TypeName = DownloadClientTypeName.qBittorrent,
|
||||
Type = DownloadClientType.Torrent,
|
||||
Host = "http://qbit:8080",
|
||||
Password = Placeholder,
|
||||
};
|
||||
|
||||
request.Validate();
|
||||
Should.Throw<ValidationException>(() => request.ToTestConfig());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TestDownloadClientRequest_ToTestConfig_WithPlaceholderPassword_AndResolvedPassword_UsesResolvedPassword()
|
||||
{
|
||||
var request = new TestDownloadClientRequest
|
||||
{
|
||||
TypeName = DownloadClientTypeName.qBittorrent,
|
||||
Type = DownloadClientType.Torrent,
|
||||
Host = "http://qbit:8080",
|
||||
Password = Placeholder,
|
||||
ClientId = Guid.NewGuid(),
|
||||
};
|
||||
|
||||
request.Validate();
|
||||
var config = request.ToTestConfig("resolved-password-from-db");
|
||||
config.Password.ShouldBe("resolved-password-from-db");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TestDownloadClientRequest_ToTestConfig_WithRealPassword_Succeeds()
|
||||
{
|
||||
var request = new TestDownloadClientRequest
|
||||
{
|
||||
TypeName = DownloadClientTypeName.qBittorrent,
|
||||
Type = DownloadClientType.Torrent,
|
||||
Host = "http://qbit:8080",
|
||||
Password = "real-password",
|
||||
};
|
||||
|
||||
request.Validate();
|
||||
var config = request.ToTestConfig();
|
||||
config.Password.ShouldBe("real-password");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region UpdateOidcConfigRequest — UPDATE
|
||||
|
||||
[Fact]
|
||||
public void UpdateOidcConfigRequest_ApplyTo_WithPlaceholderClientSecret_PreservesExistingValue()
|
||||
{
|
||||
var request = new UpdateOidcConfigRequest
|
||||
{
|
||||
Enabled = true,
|
||||
IssuerUrl = "http://localhost:8080/realms/test",
|
||||
ClientId = "cleanuparr",
|
||||
ClientSecret = Placeholder,
|
||||
Scopes = "openid profile email",
|
||||
ProviderName = "Keycloak",
|
||||
};
|
||||
|
||||
var existingConfig = new OidcConfig
|
||||
{
|
||||
Enabled = true,
|
||||
IssuerUrl = "http://localhost:8080/realms/test",
|
||||
ClientId = "cleanuparr",
|
||||
ClientSecret = "original-secret",
|
||||
Scopes = "openid profile email",
|
||||
ProviderName = "OIDC",
|
||||
};
|
||||
|
||||
request.ApplyTo(existingConfig);
|
||||
|
||||
existingConfig.ClientSecret.ShouldBe("original-secret");
|
||||
existingConfig.ProviderName.ShouldBe("Keycloak");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateOidcConfigRequest_ApplyTo_WithRealClientSecret_UpdatesValue()
|
||||
{
|
||||
var request = new UpdateOidcConfigRequest
|
||||
{
|
||||
Enabled = true,
|
||||
IssuerUrl = "http://localhost:8080/realms/test",
|
||||
ClientId = "cleanuparr",
|
||||
ClientSecret = "brand-new-secret",
|
||||
Scopes = "openid profile email",
|
||||
ProviderName = "Keycloak",
|
||||
};
|
||||
|
||||
var existingConfig = new OidcConfig
|
||||
{
|
||||
Enabled = true,
|
||||
IssuerUrl = "http://localhost:8080/realms/test",
|
||||
ClientId = "cleanuparr",
|
||||
ClientSecret = "original-secret",
|
||||
Scopes = "openid profile email",
|
||||
ProviderName = "OIDC",
|
||||
};
|
||||
|
||||
request.ApplyTo(existingConfig);
|
||||
|
||||
existingConfig.ClientSecret.ShouldBe("brand-new-secret");
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
+461
@@ -0,0 +1,461 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization.Metadata;
|
||||
using Cleanuparr.Api.Json;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.Arr.Dtos;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Notification;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
using Shouldly;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Features.SensitiveData;
|
||||
|
||||
/// <summary>
|
||||
/// Tests that the SensitiveDataResolver correctly masks all [SensitiveData] properties
|
||||
/// during JSON serialization — this is what controls the API response output.
|
||||
/// </summary>
|
||||
public class SensitiveDataResolverTests
|
||||
{
|
||||
private readonly JsonSerializerOptions _options;
|
||||
private const string Placeholder = SensitiveDataHelper.Placeholder;
|
||||
|
||||
public SensitiveDataResolverTests()
|
||||
{
|
||||
_options = new JsonSerializerOptions
|
||||
{
|
||||
TypeInfoResolver = new SensitiveDataResolver(new DefaultJsonTypeInfoResolver()),
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
};
|
||||
}
|
||||
|
||||
#region ArrInstance
|
||||
|
||||
[Fact]
|
||||
public void ArrInstance_ApiKey_IsMasked()
|
||||
{
|
||||
var instance = new ArrInstance
|
||||
{
|
||||
Name = "Sonarr",
|
||||
Url = new Uri("http://sonarr:8989"),
|
||||
ApiKey = "super-secret-api-key-12345",
|
||||
ArrConfigId = Guid.NewGuid(),
|
||||
Version = 4
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(instance, _options);
|
||||
var doc = JsonDocument.Parse(json);
|
||||
|
||||
doc.RootElement.GetProperty("apiKey").GetString().ShouldBe(Placeholder);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ArrInstance_NonSensitiveFields_AreVisible()
|
||||
{
|
||||
var instance = new ArrInstance
|
||||
{
|
||||
Name = "Sonarr",
|
||||
Url = new Uri("http://sonarr:8989"),
|
||||
ExternalUrl = new Uri("https://sonarr.example.com"),
|
||||
ApiKey = "super-secret-api-key-12345",
|
||||
ArrConfigId = Guid.NewGuid(),
|
||||
Version = 4
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(instance, _options);
|
||||
var doc = JsonDocument.Parse(json);
|
||||
|
||||
doc.RootElement.GetProperty("name").GetString().ShouldBe("Sonarr");
|
||||
doc.RootElement.GetProperty("url").GetString().ShouldBe("http://sonarr:8989");
|
||||
doc.RootElement.GetProperty("externalUrl").GetString().ShouldBe("https://sonarr.example.com");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ArrInstance_NullApiKey_RemainsNull()
|
||||
{
|
||||
// ApiKey is required, but let's test with the DTO which might handle null
|
||||
var dto = new ArrInstanceDto
|
||||
{
|
||||
Name = "Sonarr",
|
||||
Url = "http://sonarr:8989",
|
||||
ApiKey = null!,
|
||||
Version = 4
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(dto, _options);
|
||||
var doc = JsonDocument.Parse(json);
|
||||
|
||||
doc.RootElement.GetProperty("apiKey").ValueKind.ShouldBe(JsonValueKind.Null);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ArrInstanceDto
|
||||
|
||||
[Fact]
|
||||
public void ArrInstanceDto_ApiKey_IsMasked()
|
||||
{
|
||||
var dto = new ArrInstanceDto
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Radarr",
|
||||
Url = "http://radarr:7878",
|
||||
ApiKey = "dto-secret-api-key-67890",
|
||||
Version = 5
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(dto, _options);
|
||||
var doc = JsonDocument.Parse(json);
|
||||
|
||||
doc.RootElement.GetProperty("apiKey").GetString().ShouldBe(Placeholder);
|
||||
doc.RootElement.GetProperty("name").GetString().ShouldBe("Radarr");
|
||||
doc.RootElement.GetProperty("url").GetString().ShouldBe("http://radarr:7878");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region DownloadClientConfig
|
||||
|
||||
[Fact]
|
||||
public void DownloadClientConfig_Password_IsMasked()
|
||||
{
|
||||
var config = new DownloadClientConfig
|
||||
{
|
||||
Name = "qBittorrent",
|
||||
TypeName = DownloadClientTypeName.qBittorrent,
|
||||
Type = DownloadClientType.Torrent,
|
||||
Host = new Uri("http://qbit:8080"),
|
||||
Username = "admin",
|
||||
Password = "my-secret-password",
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(config, _options);
|
||||
var doc = JsonDocument.Parse(json);
|
||||
|
||||
doc.RootElement.GetProperty("password").GetString().ShouldBe(Placeholder);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DownloadClientConfig_Username_IsVisible()
|
||||
{
|
||||
var config = new DownloadClientConfig
|
||||
{
|
||||
Name = "qBittorrent",
|
||||
TypeName = DownloadClientTypeName.qBittorrent,
|
||||
Type = DownloadClientType.Torrent,
|
||||
Host = new Uri("http://qbit:8080"),
|
||||
Username = "admin",
|
||||
Password = "my-secret-password",
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(config, _options);
|
||||
var doc = JsonDocument.Parse(json);
|
||||
|
||||
doc.RootElement.GetProperty("username").GetString().ShouldBe("admin");
|
||||
doc.RootElement.GetProperty("name").GetString().ShouldBe("qBittorrent");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DownloadClientConfig_NullPassword_RemainsNull()
|
||||
{
|
||||
var config = new DownloadClientConfig
|
||||
{
|
||||
Name = "qBittorrent",
|
||||
TypeName = DownloadClientTypeName.qBittorrent,
|
||||
Type = DownloadClientType.Torrent,
|
||||
Host = new Uri("http://qbit:8080"),
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(config, _options);
|
||||
var doc = JsonDocument.Parse(json);
|
||||
|
||||
doc.RootElement.GetProperty("password").ValueKind.ShouldBe(JsonValueKind.Null);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region NotifiarrConfig
|
||||
|
||||
[Fact]
|
||||
public void NotifiarrConfig_ApiKey_IsMasked()
|
||||
{
|
||||
var config = new NotifiarrConfig
|
||||
{
|
||||
ApiKey = "notifiarr-api-key-secret",
|
||||
ChannelId = "123456789"
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(config, _options);
|
||||
var doc = JsonDocument.Parse(json);
|
||||
|
||||
doc.RootElement.GetProperty("apiKey").GetString().ShouldBe(Placeholder);
|
||||
doc.RootElement.GetProperty("channelId").GetString().ShouldBe("123456789");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region DiscordConfig
|
||||
|
||||
[Fact]
|
||||
public void DiscordConfig_WebhookUrl_IsMasked()
|
||||
{
|
||||
var config = new DiscordConfig
|
||||
{
|
||||
WebhookUrl = "https://discord.com/api/webhooks/123456/secret-token",
|
||||
Username = "Cleanuparr Bot",
|
||||
AvatarUrl = "https://example.com/avatar.png"
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(config, _options);
|
||||
var doc = JsonDocument.Parse(json);
|
||||
|
||||
doc.RootElement.GetProperty("webhookUrl").GetString().ShouldBe(Placeholder);
|
||||
doc.RootElement.GetProperty("username").GetString().ShouldBe("Cleanuparr Bot");
|
||||
doc.RootElement.GetProperty("avatarUrl").GetString().ShouldBe("https://example.com/avatar.png");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region TelegramConfig
|
||||
|
||||
[Fact]
|
||||
public void TelegramConfig_BotToken_IsMasked()
|
||||
{
|
||||
var config = new TelegramConfig
|
||||
{
|
||||
BotToken = "1234567890:ABCdefGHIjklmnoPQRstuvWXyz",
|
||||
ChatId = "-1001234567890",
|
||||
TopicId = "42",
|
||||
SendSilently = true
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(config, _options);
|
||||
var doc = JsonDocument.Parse(json);
|
||||
|
||||
doc.RootElement.GetProperty("botToken").GetString().ShouldBe(Placeholder);
|
||||
doc.RootElement.GetProperty("chatId").GetString().ShouldBe("-1001234567890");
|
||||
doc.RootElement.GetProperty("topicId").GetString().ShouldBe("42");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region NtfyConfig
|
||||
|
||||
[Fact]
|
||||
public void NtfyConfig_PasswordAndAccessToken_AreMasked()
|
||||
{
|
||||
var config = new NtfyConfig
|
||||
{
|
||||
ServerUrl = "https://ntfy.example.com",
|
||||
Topics = ["test-topic"],
|
||||
AuthenticationType = NtfyAuthenticationType.BasicAuth,
|
||||
Username = "ntfy-user",
|
||||
Password = "ntfy-secret-password",
|
||||
AccessToken = "ntfy-access-token-secret",
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(config, _options);
|
||||
var doc = JsonDocument.Parse(json);
|
||||
|
||||
doc.RootElement.GetProperty("password").GetString().ShouldBe(Placeholder);
|
||||
doc.RootElement.GetProperty("accessToken").GetString().ShouldBe(Placeholder);
|
||||
doc.RootElement.GetProperty("serverUrl").GetString().ShouldBe("https://ntfy.example.com");
|
||||
doc.RootElement.GetProperty("username").GetString().ShouldBe("ntfy-user");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NtfyConfig_NullPasswordAndAccessToken_RemainNull()
|
||||
{
|
||||
var config = new NtfyConfig
|
||||
{
|
||||
ServerUrl = "https://ntfy.example.com",
|
||||
Topics = ["test-topic"],
|
||||
AuthenticationType = NtfyAuthenticationType.None,
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(config, _options);
|
||||
var doc = JsonDocument.Parse(json);
|
||||
|
||||
doc.RootElement.GetProperty("password").ValueKind.ShouldBe(JsonValueKind.Null);
|
||||
doc.RootElement.GetProperty("accessToken").ValueKind.ShouldBe(JsonValueKind.Null);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region PushoverConfig
|
||||
|
||||
[Fact]
|
||||
public void PushoverConfig_ApiTokenAndUserKey_AreMasked()
|
||||
{
|
||||
var config = new PushoverConfig
|
||||
{
|
||||
ApiToken = "pushover-api-token-secret",
|
||||
UserKey = "pushover-user-key-secret",
|
||||
Priority = PushoverPriority.Normal,
|
||||
Devices = ["iphone", "desktop"]
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(config, _options);
|
||||
var doc = JsonDocument.Parse(json);
|
||||
|
||||
doc.RootElement.GetProperty("apiToken").GetString().ShouldBe(Placeholder);
|
||||
doc.RootElement.GetProperty("userKey").GetString().ShouldBe(Placeholder);
|
||||
doc.RootElement.GetProperty("devices").GetArrayLength().ShouldBe(2);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GotifyConfig
|
||||
|
||||
[Fact]
|
||||
public void GotifyConfig_ApplicationToken_IsMasked()
|
||||
{
|
||||
var config = new GotifyConfig
|
||||
{
|
||||
ServerUrl = "https://gotify.example.com",
|
||||
ApplicationToken = "gotify-app-token-secret",
|
||||
Priority = 5
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(config, _options);
|
||||
var doc = JsonDocument.Parse(json);
|
||||
|
||||
doc.RootElement.GetProperty("applicationToken").GetString().ShouldBe(Placeholder);
|
||||
doc.RootElement.GetProperty("serverUrl").GetString().ShouldBe("https://gotify.example.com");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region AppriseConfig
|
||||
|
||||
[Fact]
|
||||
public void AppriseConfig_Key_IsMasked_WithFullMask()
|
||||
{
|
||||
var config = new AppriseConfig
|
||||
{
|
||||
Mode = AppriseMode.Api,
|
||||
Url = "https://apprise.example.com",
|
||||
Key = "apprise-config-key-secret",
|
||||
Tags = "urgent",
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(config, _options);
|
||||
var doc = JsonDocument.Parse(json);
|
||||
|
||||
doc.RootElement.GetProperty("key").GetString().ShouldBe(Placeholder);
|
||||
doc.RootElement.GetProperty("url").GetString().ShouldBe("https://apprise.example.com");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AppriseConfig_ServiceUrls_IsMasked_WithAppriseUrlMask()
|
||||
{
|
||||
var config = new AppriseConfig
|
||||
{
|
||||
Mode = AppriseMode.Cli,
|
||||
ServiceUrls = "discord://webhook_id/webhook_token slack://tokenA/tokenB/tokenC"
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(config, _options);
|
||||
var doc = JsonDocument.Parse(json);
|
||||
|
||||
var maskedUrls = doc.RootElement.GetProperty("serviceUrls").GetString();
|
||||
maskedUrls.ShouldContain("discord://••••••••");
|
||||
maskedUrls.ShouldContain("slack://••••••••");
|
||||
maskedUrls.ShouldNotContain("webhook_id");
|
||||
maskedUrls.ShouldNotContain("webhook_token");
|
||||
maskedUrls.ShouldNotContain("tokenA");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AppriseConfig_NullServiceUrls_RemainsNull()
|
||||
{
|
||||
var config = new AppriseConfig
|
||||
{
|
||||
Mode = AppriseMode.Api,
|
||||
Url = "https://apprise.example.com",
|
||||
Key = "some-key",
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(config, _options);
|
||||
var doc = JsonDocument.Parse(json);
|
||||
|
||||
doc.RootElement.GetProperty("serviceUrls").ValueKind.ShouldBe(JsonValueKind.Null);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Polymorphic serialization (as used in NotificationProviderResponse)
|
||||
|
||||
[Fact]
|
||||
public void PolymorphicSerialization_NotifiarrConfig_StillMasked()
|
||||
{
|
||||
// The notification providers endpoint casts configs to `object`.
|
||||
// Verify that the resolver still masks when serializing as a concrete type at runtime.
|
||||
object config = new NotifiarrConfig
|
||||
{
|
||||
ApiKey = "my-secret-notifiarr-key",
|
||||
ChannelId = "987654321"
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(config, config.GetType(), _options);
|
||||
var doc = JsonDocument.Parse(json);
|
||||
|
||||
doc.RootElement.GetProperty("apiKey").GetString().ShouldBe(Placeholder);
|
||||
doc.RootElement.GetProperty("channelId").GetString().ShouldBe("987654321");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PolymorphicSerialization_DiscordConfig_StillMasked()
|
||||
{
|
||||
object config = new DiscordConfig
|
||||
{
|
||||
WebhookUrl = "https://discord.com/api/webhooks/123/secret",
|
||||
Username = "Bot"
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(config, config.GetType(), _options);
|
||||
var doc = JsonDocument.Parse(json);
|
||||
|
||||
doc.RootElement.GetProperty("webhookUrl").GetString().ShouldBe(Placeholder);
|
||||
doc.RootElement.GetProperty("username").GetString().ShouldBe("Bot");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Edge cases
|
||||
|
||||
[Fact]
|
||||
public void EmptySensitiveString_IsMasked_NotReturnedEmpty()
|
||||
{
|
||||
var config = new NotifiarrConfig
|
||||
{
|
||||
ApiKey = "",
|
||||
ChannelId = "123"
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(config, _options);
|
||||
var doc = JsonDocument.Parse(json);
|
||||
|
||||
// Even empty strings get masked to the placeholder
|
||||
doc.RootElement.GetProperty("apiKey").GetString().ShouldBe(Placeholder);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MultipleSensitiveFields_AllMasked()
|
||||
{
|
||||
var config = new PushoverConfig
|
||||
{
|
||||
ApiToken = "token-abc-123",
|
||||
UserKey = "user-key-xyz-789",
|
||||
Priority = PushoverPriority.High,
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(config, _options);
|
||||
var doc = JsonDocument.Parse(json);
|
||||
|
||||
doc.RootElement.GetProperty("apiToken").GetString().ShouldBe(Placeholder);
|
||||
doc.RootElement.GetProperty("userKey").GetString().ShouldBe(Placeholder);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
using Cleanuparr.Api.Features.Webhooks.Contracts;
|
||||
using Cleanuparr.Api.Features.Webhooks.Controllers;
|
||||
using Cleanuparr.Api.Tests.TestHelpers;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Services.Interfaces;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using Shouldly;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Features.Webhooks;
|
||||
|
||||
public class WebhooksControllerTests : IDisposable
|
||||
{
|
||||
private readonly DataContext _dataContext;
|
||||
private readonly IJobManagementService _jobManagement;
|
||||
private readonly WebhooksController _controller;
|
||||
|
||||
private Guid _sonarrInstanceId;
|
||||
private Guid _lidarrInstanceId;
|
||||
|
||||
public WebhooksControllerTests()
|
||||
{
|
||||
_dataContext = CreateDataContext();
|
||||
_jobManagement = Substitute.For<IJobManagementService>();
|
||||
var logger = Substitute.For<ILogger<WebhooksController>>();
|
||||
_controller = new WebhooksController(logger, _dataContext, _jobManagement);
|
||||
ControllerTestContext.Attach(_controller);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_dataContext.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
private DataContext CreateDataContext()
|
||||
{
|
||||
var connection = new SqliteConnection("DataSource=:memory:");
|
||||
connection.Open();
|
||||
var options = new DbContextOptionsBuilder<DataContext>().UseSqlite(connection).Options;
|
||||
var context = new DataContext(options);
|
||||
context.Database.EnsureCreated();
|
||||
|
||||
var sonarrInstance = new ArrInstance { Enabled = true, Name = "Sonarr", Url = new Uri("http://sonarr:8989"), ApiKey = "key" };
|
||||
var lidarrInstance = new ArrInstance { Enabled = true, Name = "Lidarr", Url = new Uri("http://lidarr:8686"), ApiKey = "key" };
|
||||
_sonarrInstanceId = sonarrInstance.Id;
|
||||
_lidarrInstanceId = lidarrInstance.Id;
|
||||
|
||||
context.ArrConfigs.AddRange(
|
||||
new ArrConfig { Type = InstanceType.Sonarr, Instances = [sonarrInstance] },
|
||||
new ArrConfig { Type = InstanceType.Lidarr, Instances = [lidarrInstance] }
|
||||
);
|
||||
|
||||
context.ContentBlockerConfigs.Add(new ContentBlockerConfig
|
||||
{
|
||||
Enabled = true,
|
||||
TriggerMode = JobTriggerMode.Both,
|
||||
IgnoredDownloads = [],
|
||||
});
|
||||
|
||||
context.SaveChanges();
|
||||
return context;
|
||||
}
|
||||
|
||||
private void SetConfig(bool enabled, JobTriggerMode mode)
|
||||
{
|
||||
var config = _dataContext.ContentBlockerConfigs.First();
|
||||
config.Enabled = enabled;
|
||||
config.TriggerMode = mode;
|
||||
_dataContext.SaveChanges();
|
||||
}
|
||||
|
||||
private static ArrWebhookPayload GrabPayload(string? downloadId = "HASH123", long seriesId = 42) => new()
|
||||
{
|
||||
EventType = "Grab",
|
||||
DownloadId = downloadId,
|
||||
Series = new ArrWebhookContent { Id = seriesId },
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task TestEvent_ReturnsOk_AndDoesNotSchedule()
|
||||
{
|
||||
var result = await _controller.TriggerMalwareBlocker(_sonarrInstanceId, new ArrWebhookPayload { EventType = "Test" });
|
||||
|
||||
result.ShouldBeOfType<OkResult>();
|
||||
await _jobManagement.DidNotReceive()
|
||||
.TriggerMalwareBlockerWebhook(Arg.Any<Guid>(), Arg.Any<string>(), Arg.Any<long>(), Arg.Any<InstanceType>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidGrab_SchedulesTargetedScan()
|
||||
{
|
||||
var result = await _controller.TriggerMalwareBlocker(_sonarrInstanceId, GrabPayload());
|
||||
|
||||
result.ShouldBeOfType<OkResult>();
|
||||
await _jobManagement.Received(1)
|
||||
.TriggerMalwareBlockerWebhook(_sonarrInstanceId, "HASH123", 42, InstanceType.Sonarr);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnknownInstance_ReturnsNotFound()
|
||||
{
|
||||
var result = await _controller.TriggerMalwareBlocker(Guid.NewGuid(), GrabPayload());
|
||||
|
||||
var notFound = result.ShouldBeOfType<ObjectResult>();
|
||||
notFound.StatusCode.ShouldBe(StatusCodes.Status404NotFound);
|
||||
notFound.Value.ShouldBeOfType<ProblemDetails>();
|
||||
await _jobManagement.DidNotReceive()
|
||||
.TriggerMalwareBlockerWebhook(Arg.Any<Guid>(), Arg.Any<string>(), Arg.Any<long>(), Arg.Any<InstanceType>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NonSonarrRadarrInstance_ReturnsUnprocessable()
|
||||
{
|
||||
var result = await _controller.TriggerMalwareBlocker(_lidarrInstanceId, GrabPayload());
|
||||
|
||||
var unprocessable = result.ShouldBeOfType<ObjectResult>();
|
||||
unprocessable.StatusCode.ShouldBe(StatusCodes.Status422UnprocessableEntity);
|
||||
unprocessable.Value.ShouldBeOfType<ProblemDetails>();
|
||||
await _jobManagement.DidNotReceive()
|
||||
.TriggerMalwareBlockerWebhook(Arg.Any<Guid>(), Arg.Any<string>(), Arg.Any<long>(), Arg.Any<InstanceType>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Disabled_ReturnsOk_AndDoesNotSchedule()
|
||||
{
|
||||
SetConfig(enabled: false, JobTriggerMode.Both);
|
||||
|
||||
var result = await _controller.TriggerMalwareBlocker(_sonarrInstanceId, GrabPayload());
|
||||
|
||||
result.ShouldBeOfType<OkResult>();
|
||||
await _jobManagement.DidNotReceive()
|
||||
.TriggerMalwareBlockerWebhook(Arg.Any<Guid>(), Arg.Any<string>(), Arg.Any<long>(), Arg.Any<InstanceType>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ScheduleOnlyMode_ReturnsOk_AndDoesNotSchedule()
|
||||
{
|
||||
SetConfig(enabled: true, JobTriggerMode.Schedule);
|
||||
|
||||
var result = await _controller.TriggerMalwareBlocker(_sonarrInstanceId, GrabPayload());
|
||||
|
||||
result.ShouldBeOfType<OkResult>();
|
||||
await _jobManagement.DidNotReceive()
|
||||
.TriggerMalwareBlockerWebhook(Arg.Any<Guid>(), Arg.Any<string>(), Arg.Any<long>(), Arg.Any<InstanceType>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EmptyDownloadId_ReturnsOk_AndDoesNotSchedule()
|
||||
{
|
||||
var result = await _controller.TriggerMalwareBlocker(_sonarrInstanceId, GrabPayload(downloadId: null));
|
||||
|
||||
result.ShouldBeOfType<OkResult>();
|
||||
await _jobManagement.DidNotReceive()
|
||||
.TriggerMalwareBlockerWebhook(Arg.Any<Guid>(), Arg.Any<string>(), Arg.Any<long>(), Arg.Any<InstanceType>());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
using Cleanuparr.Api.DependencyInjection;
|
||||
using Cleanuparr.Api.Middleware;
|
||||
using Cleanuparr.Domain.Exceptions;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Infrastructure;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NSubstitute;
|
||||
using Shouldly;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Middleware;
|
||||
|
||||
public class GlobalExceptionHandlerTests
|
||||
{
|
||||
private static readonly ProblemDetailsFactory ProblemDetailsFactory = BuildProblemDetailsFactory();
|
||||
|
||||
private static ProblemDetailsFactory BuildProblemDetailsFactory()
|
||||
{
|
||||
ServiceCollection services = new();
|
||||
services.AddLogging();
|
||||
services.AddControllers();
|
||||
services.AddCleanuparrProblemDetails();
|
||||
return services.BuildServiceProvider().GetRequiredService<ProblemDetailsFactory>();
|
||||
}
|
||||
|
||||
private static async Task<(bool handled, HttpContext context, ProblemDetails problemDetails)> Handle(Exception exception)
|
||||
{
|
||||
IProblemDetailsService problemDetailsService = Substitute.For<IProblemDetailsService>();
|
||||
problemDetailsService
|
||||
.TryWriteAsync(Arg.Any<ProblemDetailsContext>())
|
||||
.Returns(callInfo => ValueTask.FromResult(true));
|
||||
|
||||
DefaultHttpContext context = new();
|
||||
GlobalExceptionHandler handler = new(problemDetailsService, ProblemDetailsFactory, NullLogger<GlobalExceptionHandler>.Instance);
|
||||
|
||||
bool handled = await handler.TryHandleAsync(context, exception, CancellationToken.None);
|
||||
|
||||
ProblemDetailsContext captured = (ProblemDetailsContext)problemDetailsService
|
||||
.ReceivedCalls()
|
||||
.Single()
|
||||
.GetArguments()[0]!;
|
||||
|
||||
return (handled, context, captured.ProblemDetails);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidationException_MapsTo400_WithMessageAsDetail()
|
||||
{
|
||||
(bool handled, HttpContext context, ProblemDetails problemDetails) = await Handle(new ValidationException("Name is required"));
|
||||
|
||||
handled.ShouldBeTrue();
|
||||
context.Response.StatusCode.ShouldBe(StatusCodes.Status400BadRequest);
|
||||
problemDetails.Status.ShouldBe(StatusCodes.Status400BadRequest);
|
||||
problemDetails.Title.ShouldBe("Validation failed");
|
||||
problemDetails.Detail.ShouldBe("Name is required");
|
||||
problemDetails.Type.ShouldNotBeNullOrEmpty();
|
||||
problemDetails.Extensions.ShouldContainKey("traceId");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NotificationTestException_MapsTo400()
|
||||
{
|
||||
(bool handled, HttpContext context, ProblemDetails problemDetails) = await Handle(new NotificationTestException("Test failed: connection refused"));
|
||||
|
||||
handled.ShouldBeTrue();
|
||||
context.Response.StatusCode.ShouldBe(StatusCodes.Status400BadRequest);
|
||||
problemDetails.Status.ShouldBe(StatusCodes.Status400BadRequest);
|
||||
problemDetails.Title.ShouldBe("Notification test failed");
|
||||
problemDetails.Detail.ShouldBe("Test failed: connection refused");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RateLimitException_MapsTo429_WithRetryAfterExtensionAndHeader()
|
||||
{
|
||||
(bool handled, HttpContext context, ProblemDetails problemDetails) = await Handle(new RateLimitException("Account is locked", 30));
|
||||
|
||||
handled.ShouldBeTrue();
|
||||
context.Response.StatusCode.ShouldBe(StatusCodes.Status429TooManyRequests);
|
||||
problemDetails.Status.ShouldBe(StatusCodes.Status429TooManyRequests);
|
||||
problemDetails.Title.ShouldBe("Too many requests");
|
||||
problemDetails.Extensions["retryAfterSeconds"].ShouldBe(30);
|
||||
context.Response.Headers.RetryAfter.ToString().ShouldBe("30");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RateLimitException_WithZeroRetry_MapsTo429_WithoutRetryAfter()
|
||||
{
|
||||
(bool handled, HttpContext context, ProblemDetails problemDetails) = await Handle(new RateLimitException("Too many pending OIDC flows", 0));
|
||||
|
||||
handled.ShouldBeTrue();
|
||||
context.Response.StatusCode.ShouldBe(StatusCodes.Status429TooManyRequests);
|
||||
problemDetails.Extensions.ShouldNotContainKey("retryAfterSeconds");
|
||||
context.Response.Headers.RetryAfter.ToString().ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnknownException_MapsTo500_WithGenericDetail_AndDoesNotLeakMessage()
|
||||
{
|
||||
(bool handled, HttpContext context, ProblemDetails problemDetails) = await Handle(new InvalidOperationException("internal connection string leaked"));
|
||||
|
||||
handled.ShouldBeTrue();
|
||||
context.Response.StatusCode.ShouldBe(StatusCodes.Status500InternalServerError);
|
||||
problemDetails.Status.ShouldBe(StatusCodes.Status500InternalServerError);
|
||||
problemDetails.Detail.ShouldBe("An unexpected error occurred");
|
||||
problemDetails.Detail.ShouldNotContain("connection string");
|
||||
}
|
||||
}
|
||||
+226
@@ -0,0 +1,226 @@
|
||||
using System.Net;
|
||||
using Cleanuparr.Api.Middleware;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Shouldly;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Middleware;
|
||||
|
||||
public class TrustedForwardedHeadersMiddlewareTests
|
||||
{
|
||||
private static HttpContext NewContext(IPAddress peer, Action<HttpContext>? configure = null)
|
||||
{
|
||||
var ctx = new DefaultHttpContext();
|
||||
ctx.Connection.RemoteIpAddress = peer;
|
||||
ctx.Request.Scheme = "http";
|
||||
ctx.Request.Host = new HostString("backend.local");
|
||||
configure?.Invoke(ctx);
|
||||
return ctx;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Untrusted_direct_peer_leaves_everything_alone()
|
||||
{
|
||||
var ctx = NewContext(IPAddress.Parse("203.0.113.1"), c =>
|
||||
{
|
||||
c.Request.Headers["X-Forwarded-For"] = "10.0.0.5";
|
||||
c.Request.Headers["X-Forwarded-Proto"] = "https";
|
||||
c.Request.Headers["X-Forwarded-Host"] = "spoofed.example.com";
|
||||
});
|
||||
|
||||
TrustedForwardedHeadersMiddleware.ApplyForwardedHeaders(ctx, new List<string>());
|
||||
|
||||
ctx.Connection.RemoteIpAddress.ShouldBe(IPAddress.Parse("203.0.113.1"));
|
||||
ctx.Request.Scheme.ShouldBe("http");
|
||||
ctx.Request.Host.Value.ShouldBe("backend.local");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Local_peer_no_xff_is_a_no_op()
|
||||
{
|
||||
var ctx = NewContext(IPAddress.Loopback);
|
||||
|
||||
TrustedForwardedHeadersMiddleware.ApplyForwardedHeaders(ctx, new List<string>());
|
||||
|
||||
ctx.Connection.RemoteIpAddress.ShouldBe(IPAddress.Loopback);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Spoofed_local_xff_with_appended_attacker_promotes_attacker_ip_only()
|
||||
{
|
||||
var ctx = NewContext(IPAddress.Loopback, c =>
|
||||
c.Request.Headers["X-Forwarded-For"] = "10.0.0.5, 99.99.99.99");
|
||||
|
||||
TrustedForwardedHeadersMiddleware.ApplyForwardedHeaders(ctx, new List<string>());
|
||||
|
||||
ctx.Connection.RemoteIpAddress.ShouldBe(IPAddress.Parse("99.99.99.99"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Single_xff_entry_from_overwrite_mode_proxy_becomes_client_ip()
|
||||
{
|
||||
var ctx = NewContext(IPAddress.Loopback, c => c.Request.Headers["X-Forwarded-For"] = "203.0.113.45");
|
||||
|
||||
TrustedForwardedHeadersMiddleware.ApplyForwardedHeaders(ctx, new List<string>());
|
||||
|
||||
ctx.Connection.RemoteIpAddress.ShouldBe(IPAddress.Parse("203.0.113.45"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Legitimate_lan_client_through_local_proxy_resolves_to_lan_ip()
|
||||
{
|
||||
var ctx = NewContext(IPAddress.Loopback, c => c.Request.Headers["X-Forwarded-For"] = "192.168.1.50");
|
||||
|
||||
TrustedForwardedHeadersMiddleware.ApplyForwardedHeaders(ctx, new List<string>());
|
||||
|
||||
ctx.Connection.RemoteIpAddress.ShouldBe(IPAddress.Parse("192.168.1.50"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Custom_trusted_network_pops_through_to_real_client()
|
||||
{
|
||||
var ctx = NewContext(IPAddress.Loopback, c => c.Request.Headers["X-Forwarded-For"] = "100.64.1.5, 100.64.0.7");
|
||||
|
||||
TrustedForwardedHeadersMiddleware.ApplyForwardedHeaders(ctx, new List<string> { "100.64.0.0/10" });
|
||||
|
||||
ctx.Connection.RemoteIpAddress.ShouldBe(IPAddress.Parse("100.64.1.5"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Forwarded_proto_and_host_applied_when_chain_consumed()
|
||||
{
|
||||
var ctx = NewContext(IPAddress.Loopback, c =>
|
||||
{
|
||||
c.Request.Headers["X-Forwarded-For"] = "203.0.113.45";
|
||||
c.Request.Headers["X-Forwarded-Proto"] = "https";
|
||||
c.Request.Headers["X-Forwarded-Host"] = "cleanuparr.example.com";
|
||||
});
|
||||
|
||||
TrustedForwardedHeadersMiddleware.ApplyForwardedHeaders(ctx, new List<string>());
|
||||
|
||||
ctx.Request.Scheme.ShouldBe("https");
|
||||
ctx.Request.Host.Value.ShouldBe("cleanuparr.example.com");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Forwarded_proto_not_applied_when_peer_untrusted()
|
||||
{
|
||||
var ctx = NewContext(IPAddress.Parse("203.0.113.1"), c => c.Request.Headers["X-Forwarded-Proto"] = "https");
|
||||
|
||||
TrustedForwardedHeadersMiddleware.ApplyForwardedHeaders(ctx, new List<string>());
|
||||
|
||||
ctx.Request.Scheme.ShouldBe("http");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void X_real_ip_is_ignored()
|
||||
{
|
||||
var ctx = NewContext(IPAddress.Loopback, c => c.Request.Headers["X-Real-IP"] = "10.0.0.5");
|
||||
|
||||
TrustedForwardedHeadersMiddleware.ApplyForwardedHeaders(ctx, new List<string>());
|
||||
|
||||
ctx.Connection.RemoteIpAddress.ShouldBe(IPAddress.Loopback);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Malformed_xff_entry_fails_closed()
|
||||
{
|
||||
var ctx = NewContext(IPAddress.Loopback, c =>
|
||||
{
|
||||
c.Request.Headers["X-Forwarded-For"] = "10.0.0.5, not-an-ip";
|
||||
c.Request.Headers["X-Forwarded-Proto"] = "https";
|
||||
});
|
||||
|
||||
TrustedForwardedHeadersMiddleware.ApplyForwardedHeaders(ctx, new List<string>());
|
||||
|
||||
ctx.Connection.RemoteIpAddress.ShouldBe(IPAddress.Loopback);
|
||||
ctx.Request.Scheme.ShouldBe("http");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Empty_entries_in_xff_are_skipped()
|
||||
{
|
||||
// nginx with `proxy_set_header X-Forwarded-For "$http_x_forwarded_for, 1.2.3.4"`
|
||||
// produces a leading empty entry when the input header was absent.
|
||||
var ctx = NewContext(IPAddress.Loopback, c => c.Request.Headers["X-Forwarded-For"] = ", 99.99.99.99");
|
||||
|
||||
TrustedForwardedHeadersMiddleware.ApplyForwardedHeaders(ctx, new List<string>());
|
||||
|
||||
ctx.Connection.RemoteIpAddress.ShouldBe(IPAddress.Parse("99.99.99.99"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ipv4_mapped_ipv6_loopback_is_treated_as_trusted()
|
||||
{
|
||||
// Kestrel may surface "::ffff:127.0.0.1" as the peer.
|
||||
var mapped = IPAddress.Parse("::ffff:127.0.0.1");
|
||||
var ctx = NewContext(mapped, c => c.Request.Headers["X-Forwarded-For"] = "203.0.113.45");
|
||||
|
||||
TrustedForwardedHeadersMiddleware.ApplyForwardedHeaders(ctx, new List<string>());
|
||||
|
||||
ctx.Connection.RemoteIpAddress.ShouldBe(IPAddress.Parse("203.0.113.45"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Forwarded_proto_with_multiple_values_uses_only_first_token()
|
||||
{
|
||||
// Chained proxies that append (rather than overwrite) X-Forwarded-Proto
|
||||
// produce comma-separated values like "https, http". Only the leftmost
|
||||
// hop's value should be applied — matching how XFF is handled.
|
||||
var ctx = NewContext(IPAddress.Loopback, c =>
|
||||
{
|
||||
c.Request.Headers["X-Forwarded-For"] = "203.0.113.45";
|
||||
c.Request.Headers["X-Forwarded-Proto"] = "https, http";
|
||||
});
|
||||
|
||||
TrustedForwardedHeadersMiddleware.ApplyForwardedHeaders(ctx, new List<string>());
|
||||
|
||||
ctx.Request.Scheme.ShouldBe("https");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Forwarded_proto_with_unknown_scheme_is_ignored()
|
||||
{
|
||||
// Anything outside the http/https allowlist is dropped to keep
|
||||
// arbitrary values (e.g. "javascript:") from flowing into URLs.
|
||||
var ctx = NewContext(IPAddress.Loopback, c =>
|
||||
{
|
||||
c.Request.Headers["X-Forwarded-For"] = "203.0.113.45";
|
||||
c.Request.Headers["X-Forwarded-Proto"] = "javascript:";
|
||||
});
|
||||
|
||||
TrustedForwardedHeadersMiddleware.ApplyForwardedHeaders(ctx, new List<string>());
|
||||
|
||||
ctx.Request.Scheme.ShouldBe("http");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Forwarded_host_with_multiple_values_uses_only_first_token()
|
||||
{
|
||||
// Same multi-hop concern as X-Forwarded-Proto — the host string must
|
||||
// not end up as "a.example, b.example".
|
||||
var ctx = NewContext(IPAddress.Loopback, c =>
|
||||
{
|
||||
c.Request.Headers["X-Forwarded-For"] = "203.0.113.45";
|
||||
c.Request.Headers["X-Forwarded-Host"] = "cleanuparr.example.com, attacker.example.com";
|
||||
});
|
||||
|
||||
TrustedForwardedHeadersMiddleware.ApplyForwardedHeaders(ctx, new List<string>());
|
||||
|
||||
ctx.Request.Host.Value.ShouldBe("cleanuparr.example.com");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Malformed_entry_mid_chain_does_not_partially_mutate_remote_ip()
|
||||
{
|
||||
// Walk right-to-left: 10.0.0.5 (trusted) is popped first, then
|
||||
// "not-an-ip" fails. The pre-fix middleware committed mutation eagerly,
|
||||
// leaving RemoteIpAddress = 10.0.0.5. Fix: validate-then-commit, so the
|
||||
// original peer is preserved when any chain entry is malformed.
|
||||
var ctx = NewContext(IPAddress.Loopback, c =>
|
||||
c.Request.Headers["X-Forwarded-For"] = "not-an-ip, 10.0.0.5");
|
||||
|
||||
TrustedForwardedHeadersMiddleware.ApplyForwardedHeaders(ctx, new List<string>());
|
||||
|
||||
ctx.Connection.RemoteIpAddress.ShouldBe(IPAddress.Loopback);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using Xunit.Abstractions;
|
||||
using Xunit.Sdk;
|
||||
|
||||
namespace Cleanuparr.Api.Tests;
|
||||
|
||||
public sealed class PriorityOrderer : ITestCaseOrderer
|
||||
{
|
||||
public IEnumerable<TTestCase> OrderTestCases<TTestCase>(IEnumerable<TTestCase> testCases)
|
||||
where TTestCase : ITestCase
|
||||
{
|
||||
var sortedMethods = new SortedDictionary<int, List<TTestCase>>();
|
||||
|
||||
foreach (var testCase in testCases)
|
||||
{
|
||||
var priority = testCase.TestMethod.Method
|
||||
.GetCustomAttributes(typeof(TestPriorityAttribute).AssemblyQualifiedName)
|
||||
.FirstOrDefault()
|
||||
?.GetNamedArgument<int>("Priority") ?? 0;
|
||||
|
||||
if (!sortedMethods.TryGetValue(priority, out var list))
|
||||
{
|
||||
list = [];
|
||||
sortedMethods[priority] = list;
|
||||
}
|
||||
|
||||
list.Add(testCase);
|
||||
}
|
||||
|
||||
foreach (var list in sortedMethods.Values)
|
||||
{
|
||||
foreach (var testCase in list)
|
||||
{
|
||||
yield return testCase;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace Cleanuparr.Api.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Auth integration tests share the file-system config directory (users.db via
|
||||
/// SetupGuardMiddleware.CreateStaticInstance). Grouping them in one collection
|
||||
/// forces sequential execution and prevents inter-factory interference.
|
||||
/// </summary>
|
||||
[CollectionDefinition("Auth Integration Tests")]
|
||||
public class AuthIntegrationTestsCollection { }
|
||||
@@ -0,0 +1,35 @@
|
||||
using Cleanuparr.Api.DependencyInjection;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Infrastructure;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.TestHelpers;
|
||||
|
||||
/// <summary>
|
||||
/// Attaches a minimal MVC <see cref="ControllerContext"/> (with a real <see cref="ProblemDetailsFactory"/>
|
||||
/// and <see cref="HttpContext"/>) to a directly-instantiated controller so that
|
||||
/// <c>this.ProblemResult(...)</c> can build problem-details responses in unit tests.
|
||||
/// </summary>
|
||||
public static class ControllerTestContext
|
||||
{
|
||||
private static readonly IServiceProvider Services = BuildServices();
|
||||
|
||||
private static IServiceProvider BuildServices()
|
||||
{
|
||||
ServiceCollection services = new();
|
||||
services.AddLogging();
|
||||
services.AddControllers();
|
||||
services.AddCleanuparrProblemDetails();
|
||||
return services.BuildServiceProvider();
|
||||
}
|
||||
|
||||
public static void Attach(ControllerBase controller)
|
||||
{
|
||||
controller.ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = new DefaultHttpContext { RequestServices = Services },
|
||||
};
|
||||
controller.ProblemDetailsFactory = Services.GetRequiredService<ProblemDetailsFactory>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace Cleanuparr.Api.Tests;
|
||||
|
||||
[AttributeUsage(AttributeTargets.Method)]
|
||||
public sealed class TestPriorityAttribute : Attribute
|
||||
{
|
||||
public int Priority { get; }
|
||||
|
||||
public TestPriorityAttribute(int priority)
|
||||
{
|
||||
Priority = priority;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
|
||||
"parallelizeAssembly": false,
|
||||
"parallelizeTestCollections": false
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text.Encodings.Web;
|
||||
using Cleanuparr.Persistence;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Cleanuparr.Api.Auth;
|
||||
|
||||
public static class ApiKeyAuthenticationDefaults
|
||||
{
|
||||
public const string AuthenticationScheme = "ApiKey";
|
||||
public const string HeaderName = "X-Api-Key";
|
||||
public const string QueryParameterName = "apikey";
|
||||
}
|
||||
|
||||
public class ApiKeyAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
|
||||
{
|
||||
public ApiKeyAuthenticationHandler(
|
||||
IOptionsMonitor<AuthenticationSchemeOptions> options,
|
||||
ILoggerFactory logger,
|
||||
UrlEncoder encoder)
|
||||
: base(options, logger, encoder)
|
||||
{
|
||||
}
|
||||
|
||||
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||
{
|
||||
// Try header first, then query string
|
||||
string? apiKey = null;
|
||||
|
||||
if (Request.Headers.TryGetValue(ApiKeyAuthenticationDefaults.HeaderName, out var headerValue))
|
||||
{
|
||||
apiKey = headerValue.ToString();
|
||||
}
|
||||
else if (Request.Query.TryGetValue(ApiKeyAuthenticationDefaults.QueryParameterName, out var queryValue))
|
||||
{
|
||||
apiKey = queryValue.ToString();
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(apiKey))
|
||||
{
|
||||
return AuthenticateResult.NoResult();
|
||||
}
|
||||
|
||||
await using var usersContext = UsersContext.CreateStaticInstance();
|
||||
var user = await usersContext.Users
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(u => u.ApiKey == apiKey && u.SetupCompleted);
|
||||
|
||||
if (user is null)
|
||||
{
|
||||
return AuthenticateResult.Fail("Invalid API key");
|
||||
}
|
||||
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
|
||||
new Claim(ClaimTypes.Name, user.Username),
|
||||
new Claim("auth_method", "apikey")
|
||||
};
|
||||
|
||||
var identity = new ClaimsIdentity(claims, ApiKeyAuthenticationDefaults.AuthenticationScheme);
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
var ticket = new AuthenticationTicket(principal, ApiKeyAuthenticationDefaults.AuthenticationScheme);
|
||||
|
||||
return AuthenticateResult.Success(ticket);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
using System.Net;
|
||||
using System.Security.Claims;
|
||||
using System.Text.Encodings.Web;
|
||||
using Cleanuparr.Infrastructure.Extensions;
|
||||
using Cleanuparr.Persistence;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Cleanuparr.Api.Auth;
|
||||
|
||||
public static class TrustedNetworkAuthenticationDefaults
|
||||
{
|
||||
public const string AuthenticationScheme = "TrustedNetwork";
|
||||
}
|
||||
|
||||
public class TrustedNetworkAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
|
||||
{
|
||||
private readonly DataContext _dataContext;
|
||||
|
||||
public TrustedNetworkAuthenticationHandler(
|
||||
IOptionsMonitor<AuthenticationSchemeOptions> options,
|
||||
ILoggerFactory logger,
|
||||
UrlEncoder encoder,
|
||||
DataContext dataContext)
|
||||
: base(options, logger, encoder)
|
||||
{
|
||||
_dataContext = dataContext;
|
||||
}
|
||||
|
||||
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||
{
|
||||
// Load auth config from database
|
||||
var config = await _dataContext.GeneralConfigs.AsNoTracking().FirstOrDefaultAsync();
|
||||
|
||||
if (config is null || !config.Auth.DisableAuthForLocalAddresses)
|
||||
{
|
||||
return AuthenticateResult.NoResult();
|
||||
}
|
||||
|
||||
// Determine client IP
|
||||
var clientIp = ResolveClientIp(Context);
|
||||
if (clientIp is null)
|
||||
{
|
||||
return AuthenticateResult.NoResult();
|
||||
}
|
||||
|
||||
// Check if the client IP is trusted
|
||||
if (!IsTrustedAddress(clientIp, config.Auth.TrustedNetworks))
|
||||
{
|
||||
return AuthenticateResult.NoResult();
|
||||
}
|
||||
|
||||
// Load the admin user
|
||||
await using var usersContext = UsersContext.CreateStaticInstance();
|
||||
var user = await usersContext.Users
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(u => u.SetupCompleted);
|
||||
|
||||
if (user is null)
|
||||
{
|
||||
return AuthenticateResult.NoResult();
|
||||
}
|
||||
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
|
||||
new Claim(ClaimTypes.Name, user.Username),
|
||||
new Claim("auth_method", "trusted_network")
|
||||
};
|
||||
|
||||
var identity = new ClaimsIdentity(claims, TrustedNetworkAuthenticationDefaults.AuthenticationScheme);
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
var ticket = new AuthenticationTicket(principal, TrustedNetworkAuthenticationDefaults.AuthenticationScheme);
|
||||
|
||||
return AuthenticateResult.Success(ticket);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the connection's remote IP address. Callers must run <see cref="Cleanuparr.Api.Middleware.TrustedForwardedHeadersMiddleware"/>
|
||||
/// earlier in the pipeline so that <c>X-Forwarded-*</c> headers from trusted proxy chains have already been resolved into <c>Connection.RemoteIpAddress</c>.
|
||||
/// </summary>
|
||||
/// <param name="httpContext">The current HTTP context.</param>
|
||||
/// <returns>The resolved client IP, or <c>null</c> when unavailable.</returns>
|
||||
public static IPAddress? ResolveClientIp(HttpContext httpContext) => httpContext.Connection.RemoteIpAddress;
|
||||
|
||||
public static bool IsTrustedAddress(IPAddress clientIp, List<string> trustedNetworks)
|
||||
{
|
||||
// Normalize IPv4-mapped IPv6 addresses
|
||||
if (clientIp.IsIPv4MappedToIPv6)
|
||||
{
|
||||
clientIp = clientIp.MapToIPv4();
|
||||
}
|
||||
|
||||
// Check if it's a local address (built-in ranges)
|
||||
if (clientIp.IsLocalAddress())
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check against custom trusted networks
|
||||
foreach (var network in trustedNetworks)
|
||||
{
|
||||
if (MatchesCidr(clientIp, network))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public static bool MatchesCidr(IPAddress address, string cidr)
|
||||
{
|
||||
if (cidr.Contains('/'))
|
||||
{
|
||||
var parts = cidr.Split('/');
|
||||
if (!IPAddress.TryParse(parts[0], out var networkAddress) ||
|
||||
!int.TryParse(parts[1], out var prefixLength))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Normalize both addresses
|
||||
if (networkAddress.IsIPv4MappedToIPv6)
|
||||
networkAddress = networkAddress.MapToIPv4();
|
||||
if (address.IsIPv4MappedToIPv6)
|
||||
address = address.MapToIPv4();
|
||||
|
||||
// Must be same address family
|
||||
if (address.AddressFamily != networkAddress.AddressFamily)
|
||||
return false;
|
||||
|
||||
var addressBytes = address.GetAddressBytes();
|
||||
var networkBytes = networkAddress.GetAddressBytes();
|
||||
|
||||
// Compare bytes up to prefix length
|
||||
var fullBytes = prefixLength / 8;
|
||||
var remainingBits = prefixLength % 8;
|
||||
|
||||
for (var i = 0; i < fullBytes && i < addressBytes.Length; i++)
|
||||
{
|
||||
if (addressBytes[i] != networkBytes[i])
|
||||
return false;
|
||||
}
|
||||
|
||||
if (remainingBits > 0 && fullBytes < addressBytes.Length)
|
||||
{
|
||||
var mask = (byte)(0xFF << (8 - remainingBits));
|
||||
if ((addressBytes[fullBytes] & mask) != (networkBytes[fullBytes] & mask))
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Plain IP match
|
||||
if (!IPAddress.TryParse(cidr, out var singleIp))
|
||||
return false;
|
||||
|
||||
if (singleIp.IsIPv4MappedToIPv6)
|
||||
singleIp = singleIp.MapToIPv4();
|
||||
|
||||
return address.Equals(singleIp);
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
<PropertyGroup>
|
||||
<AssemblyName>Cleanuparr</AssemblyName>
|
||||
<Version Condition="'$(Version)' == ''">0.0.1</Version>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<PublishReadyToRun>true</PublishReadyToRun>
|
||||
@@ -19,31 +19,29 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Cleanuparr.Application\Cleanuparr.Application.csproj" />
|
||||
<ProjectReference Include="..\Cleanuparr.Infrastructure\Cleanuparr.Infrastructure.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.6">
|
||||
<PackageReference Include="MassTransit" Version="8.5.7" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.1">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.6" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="9.0.6" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.6" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="9.0.6" />
|
||||
<PackageReference Include="Quartz" Version="3.14.0" />
|
||||
<PackageReference Include="Quartz.Extensions.DependencyInjection" Version="3.14.0" />
|
||||
<PackageReference Include="Quartz.Extensions.Hosting" Version="3.14.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="10.0.1" />
|
||||
<PackageReference Include="Quartz" Version="3.15.1" />
|
||||
<PackageReference Include="Quartz.Extensions.DependencyInjection" Version="3.15.1" />
|
||||
<PackageReference Include="Quartz.Extensions.Hosting" Version="3.15.1" />
|
||||
<PackageReference Include="Serilog" Version="4.3.0" />
|
||||
<PackageReference Include="Serilog.Expressions" Version="5.0.0" />
|
||||
<PackageReference Include="Serilog.Extensions.Hosting" Version="9.0.0" />
|
||||
<PackageReference Include="Serilog.Settings.Configuration" Version="9.0.0" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
|
||||
<PackageReference Include="Serilog.Extensions.Hosting" Version="10.0.0" />
|
||||
<PackageReference Include="Serilog.Settings.Configuration" Version="10.0.0" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
|
||||
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
|
||||
<!-- API-related packages -->
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.2" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,9 +1,11 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Cleanuparr.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api")]
|
||||
[Authorize]
|
||||
public class ApiDocumentationController : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -2,6 +2,7 @@ using System.Text.Json.Serialization;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Events;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
@@ -9,6 +10,7 @@ namespace Cleanuparr.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
[Authorize]
|
||||
public class EventsController : ControllerBase
|
||||
{
|
||||
private readonly EventsContext _context;
|
||||
@@ -24,17 +26,29 @@ public class EventsController : ControllerBase
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<PaginatedResult<AppEvent>>> GetEvents(
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 100,
|
||||
[FromQuery] int pageSize = 50,
|
||||
[FromQuery] string? severity = null,
|
||||
[FromQuery] string? eventType = null,
|
||||
[FromQuery] DateTime? fromDate = null,
|
||||
[FromQuery] DateTime? toDate = null,
|
||||
[FromQuery] string? search = null)
|
||||
[FromQuery] DateTimeOffset? fromDate = null,
|
||||
[FromQuery] DateTimeOffset? toDate = null,
|
||||
[FromQuery] string? search = null,
|
||||
[FromQuery] string? jobRunId = null)
|
||||
{
|
||||
// Validate pagination parameters
|
||||
if (page < 1) page = 1;
|
||||
if (pageSize < 1) pageSize = 100;
|
||||
if (pageSize > 1000) pageSize = 1000; // Cap at 1000 for performance
|
||||
if (page < 1)
|
||||
{
|
||||
page = 1;
|
||||
}
|
||||
|
||||
if (pageSize < 1)
|
||||
{
|
||||
pageSize = 50;
|
||||
}
|
||||
|
||||
if (pageSize > 500)
|
||||
{
|
||||
pageSize = 500;
|
||||
}
|
||||
|
||||
var query = _context.Events.AsQueryable();
|
||||
|
||||
@@ -62,6 +76,12 @@ public class EventsController : ControllerBase
|
||||
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))
|
||||
{
|
||||
query = query.Where(e => e.JobRunId == jobRunGuid);
|
||||
}
|
||||
|
||||
// Apply search filter if provided
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
{
|
||||
@@ -69,7 +89,8 @@ public class EventsController : ControllerBase
|
||||
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.TrackingId.ToString(), pattern) ||
|
||||
EF.Functions.Like(e.JobRunId.ToString(), pattern)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -87,10 +108,6 @@ public class EventsController : ControllerBase
|
||||
.Take(pageSize)
|
||||
.ToListAsync();
|
||||
|
||||
events = events
|
||||
.OrderBy(e => e.Timestamp)
|
||||
.ToList();
|
||||
|
||||
// Return paginated result
|
||||
var result = new PaginatedResult<AppEvent>
|
||||
{
|
||||
@@ -132,40 +149,13 @@ public class EventsController : ControllerBase
|
||||
return Ok(events);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets event statistics
|
||||
/// </summary>
|
||||
[HttpGet("stats")]
|
||||
public async Task<ActionResult<object>> GetEventStats()
|
||||
{
|
||||
var stats = new
|
||||
{
|
||||
TotalEvents = await _context.Events.CountAsync(),
|
||||
EventsBySeverity = await _context.Events
|
||||
.GroupBy(e => e.Severity)
|
||||
.Select(g => new { Severity = g.Key.ToString(), Count = g.Count() })
|
||||
.ToListAsync(),
|
||||
EventsByType = await _context.Events
|
||||
.GroupBy(e => e.EventType)
|
||||
.Select(g => new { EventType = g.Key.ToString(), Count = g.Count() })
|
||||
.OrderByDescending(x => x.Count)
|
||||
.Take(10)
|
||||
.ToListAsync(),
|
||||
RecentEventsCount = await _context.Events
|
||||
.Where(e => e.Timestamp > DateTime.UtcNow.AddHours(-24))
|
||||
.CountAsync()
|
||||
};
|
||||
|
||||
return Ok(stats);
|
||||
}
|
||||
|
||||
/// <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);
|
||||
var cutoffDate = DateTimeOffset.UtcNow.AddDays(-retentionDays);
|
||||
|
||||
await _context.Events
|
||||
.Where(e => e.Timestamp < cutoffDate)
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using Cleanuparr.Api.Extensions;
|
||||
using Cleanuparr.Infrastructure.Health;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Cleanuparr.Api.Controllers;
|
||||
@@ -8,19 +10,16 @@ namespace Cleanuparr.Api.Controllers;
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/health")]
|
||||
[Authorize]
|
||||
public class HealthCheckController : ControllerBase
|
||||
{
|
||||
private readonly ILogger<HealthCheckController> _logger;
|
||||
private readonly IHealthCheckService _healthCheckService;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="HealthCheckController"/> class
|
||||
/// </summary>
|
||||
public HealthCheckController(
|
||||
ILogger<HealthCheckController> logger,
|
||||
IHealthCheckService healthCheckService)
|
||||
public HealthCheckController(IHealthCheckService healthCheckService)
|
||||
{
|
||||
_logger = logger;
|
||||
_healthCheckService = healthCheckService;
|
||||
}
|
||||
|
||||
@@ -30,16 +29,8 @@ public class HealthCheckController : ControllerBase
|
||||
[HttpGet]
|
||||
public IActionResult GetAllHealth()
|
||||
{
|
||||
try
|
||||
{
|
||||
var healthStatuses = _healthCheckService.GetAllClientHealth();
|
||||
return Ok(healthStatuses);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error retrieving client health statuses");
|
||||
return StatusCode(500, new { Error = "An error occurred while retrieving client health statuses" });
|
||||
}
|
||||
var healthStatuses = _healthCheckService.GetAllClientHealth();
|
||||
return Ok(healthStatuses);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -48,21 +39,13 @@ public class HealthCheckController : ControllerBase
|
||||
[HttpGet("{id:guid}")]
|
||||
public IActionResult GetClientHealth(Guid id)
|
||||
{
|
||||
try
|
||||
var healthStatus = _healthCheckService.GetClientHealth(id);
|
||||
if (healthStatus == null)
|
||||
{
|
||||
var healthStatus = _healthCheckService.GetClientHealth(id);
|
||||
if (healthStatus == null)
|
||||
{
|
||||
return NotFound(new { Message = $"Health status for client with ID '{id}' not found" });
|
||||
}
|
||||
return this.ProblemResult(StatusCodes.Status404NotFound, $"Health status for client with ID '{id}' not found");
|
||||
}
|
||||
|
||||
return Ok(healthStatus);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error retrieving health status for client {id}", id);
|
||||
return StatusCode(500, new { Error = "An error occurred while retrieving the client health status" });
|
||||
}
|
||||
return Ok(healthStatus);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -71,16 +54,8 @@ public class HealthCheckController : ControllerBase
|
||||
[HttpPost("check")]
|
||||
public async Task<IActionResult> CheckAllHealth()
|
||||
{
|
||||
try
|
||||
{
|
||||
var results = await _healthCheckService.CheckAllClientsHealthAsync();
|
||||
return Ok(results);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error checking health for all clients");
|
||||
return StatusCode(500, new { Error = "An error occurred while checking client health" });
|
||||
}
|
||||
var results = await _healthCheckService.CheckAllClientsHealthAsync();
|
||||
return Ok(results);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -89,15 +64,7 @@ public class HealthCheckController : ControllerBase
|
||||
[HttpPost("check/{id:guid}")]
|
||||
public async Task<IActionResult> CheckClientHealth(Guid id)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await _healthCheckService.CheckClientHealthAsync(id);
|
||||
return Ok(result);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error checking health for client {id}", id);
|
||||
return StatusCode(500, new { Error = "An error occurred while checking client health" });
|
||||
}
|
||||
var result = await _healthCheckService.CheckClientHealthAsync(id);
|
||||
return Ok(result);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
|
||||
@@ -8,6 +9,7 @@ namespace Cleanuparr.Api.Controllers;
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("[controller]")]
|
||||
[Authorize]
|
||||
public class HealthController : ControllerBase
|
||||
{
|
||||
private readonly HealthCheckService _healthCheckService;
|
||||
@@ -23,6 +25,7 @@ public class HealthController : ControllerBase
|
||||
/// Basic liveness probe - checks if the application is running
|
||||
/// Used by Docker HEALTHCHECK and Kubernetes liveness probes
|
||||
/// </summary>
|
||||
[AllowAnonymous]
|
||||
[HttpGet]
|
||||
[Route("/health")]
|
||||
public async Task<IActionResult> GetHealth()
|
||||
@@ -33,13 +36,13 @@ public class HealthController : ControllerBase
|
||||
registration => registration.Tags.Contains("liveness"));
|
||||
|
||||
return result.Status == HealthStatus.Healthy
|
||||
? Ok(new { status = "healthy", timestamp = DateTime.UtcNow })
|
||||
: StatusCode(503, new { status = "unhealthy", timestamp = DateTime.UtcNow });
|
||||
? Ok(new { status = "healthy", timestamp = DateTimeOffset.UtcNow })
|
||||
: StatusCode(503, new { status = "unhealthy", timestamp = DateTimeOffset.UtcNow });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Health check failed");
|
||||
return StatusCode(503, new { status = "unhealthy", error = "Health check failed", timestamp = DateTime.UtcNow });
|
||||
return StatusCode(503, new { status = "unhealthy", error = "Health check failed", timestamp = DateTimeOffset.UtcNow });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +50,7 @@ public class HealthController : ControllerBase
|
||||
/// Readiness probe - checks if the application is ready to serve traffic
|
||||
/// Used by Kubernetes readiness probes
|
||||
/// </summary>
|
||||
[AllowAnonymous]
|
||||
[HttpGet]
|
||||
[Route("/health/ready")]
|
||||
public async Task<IActionResult> GetReadiness()
|
||||
@@ -58,13 +62,13 @@ public class HealthController : ControllerBase
|
||||
|
||||
if (result.Status == HealthStatus.Healthy)
|
||||
{
|
||||
return Ok(new { status = "ready", timestamp = DateTime.UtcNow });
|
||||
return Ok(new { status = "ready", timestamp = DateTimeOffset.UtcNow });
|
||||
}
|
||||
|
||||
// For readiness, we consider degraded as not ready
|
||||
return StatusCode(503, new {
|
||||
status = "not_ready",
|
||||
timestamp = DateTime.UtcNow,
|
||||
timestamp = DateTimeOffset.UtcNow,
|
||||
details = result.Entries.Where(e => e.Value.Status != HealthStatus.Healthy)
|
||||
.ToDictionary(e => e.Key, e => new {
|
||||
status = e.Value.Status.ToString().ToLowerInvariant(),
|
||||
@@ -75,7 +79,7 @@ public class HealthController : ControllerBase
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Readiness check failed");
|
||||
return StatusCode(503, new { status = "not_ready", error = "Readiness check failed", timestamp = DateTime.UtcNow });
|
||||
return StatusCode(503, new { status = "not_ready", error = "Readiness check failed", timestamp = DateTimeOffset.UtcNow });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,7 +97,7 @@ public class HealthController : ControllerBase
|
||||
var response = new
|
||||
{
|
||||
status = result.Status.ToString().ToLowerInvariant(),
|
||||
timestamp = DateTime.UtcNow,
|
||||
timestamp = DateTimeOffset.UtcNow,
|
||||
totalDuration = result.TotalDuration.TotalMilliseconds,
|
||||
entries = result.Entries.ToDictionary(
|
||||
e => e.Key,
|
||||
@@ -118,7 +122,7 @@ public class HealthController : ControllerBase
|
||||
return StatusCode(503, new {
|
||||
status = "unhealthy",
|
||||
error = "Detailed health check failed",
|
||||
timestamp = DateTime.UtcNow
|
||||
timestamp = DateTimeOffset.UtcNow
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,163 +1,100 @@
|
||||
using Cleanuparr.Api.Extensions;
|
||||
using Cleanuparr.Api.Models;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Models;
|
||||
using Cleanuparr.Infrastructure.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Cleanuparr.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
[Authorize]
|
||||
public class JobsController : ControllerBase
|
||||
{
|
||||
private readonly IJobManagementService _jobManagementService;
|
||||
private readonly ILogger<JobsController> _logger;
|
||||
|
||||
public JobsController(IJobManagementService jobManagementService, ILogger<JobsController> logger)
|
||||
public JobsController(IJobManagementService jobManagementService)
|
||||
{
|
||||
_jobManagementService = jobManagementService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetAllJobs()
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await _jobManagementService.GetAllJobs();
|
||||
return Ok(result);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error getting all jobs");
|
||||
return StatusCode(500, "An error occurred while retrieving jobs");
|
||||
}
|
||||
var result = await _jobManagementService.GetAllJobs();
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
[HttpGet("{jobType}")]
|
||||
public async Task<IActionResult> GetJob(JobType jobType)
|
||||
{
|
||||
try
|
||||
var jobInfo = await _jobManagementService.GetJob(jobType);
|
||||
|
||||
if (jobInfo.Status == "Not Found")
|
||||
{
|
||||
var jobInfo = await _jobManagementService.GetJob(jobType);
|
||||
|
||||
if (jobInfo.Status == "Not Found")
|
||||
{
|
||||
return NotFound($"Job '{jobType}' not found");
|
||||
}
|
||||
return Ok(jobInfo);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error getting job {jobType}", jobType);
|
||||
return StatusCode(500, $"An error occurred while retrieving job '{jobType}'");
|
||||
return this.ProblemResult(StatusCodes.Status404NotFound, $"Job '{jobType}' not found");
|
||||
}
|
||||
return Ok(jobInfo);
|
||||
}
|
||||
|
||||
[HttpPost("{jobType}/start")]
|
||||
public async Task<IActionResult> StartJob(JobType jobType, [FromBody] ScheduleRequest scheduleRequest = null)
|
||||
public async Task<IActionResult> StartJob(JobType jobType, [FromBody] ScheduleRequest scheduleRequest)
|
||||
{
|
||||
try
|
||||
if (jobType == JobType.Seeker)
|
||||
{
|
||||
// Get the schedule from the request body if provided
|
||||
JobSchedule jobSchedule = scheduleRequest.Schedule;
|
||||
|
||||
var result = await _jobManagementService.StartJob(jobType, jobSchedule);
|
||||
|
||||
if (!result)
|
||||
{
|
||||
return BadRequest($"Failed to start job '{jobType}'");
|
||||
}
|
||||
return Ok(new { Message = $"Job '{jobType}' started successfully" });
|
||||
return this.ProblemResult(StatusCodes.Status400BadRequest, "The Seeker job cannot be manually controlled");
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
// Get the schedule from the request body if provided
|
||||
JobSchedule jobSchedule = scheduleRequest.Schedule;
|
||||
|
||||
var result = await _jobManagementService.StartJob(jobType, jobSchedule);
|
||||
|
||||
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}/stop")]
|
||||
public async Task<IActionResult> StopJob(JobType jobType)
|
||||
[HttpPost("{jobType}/trigger")]
|
||||
public async Task<IActionResult> TriggerJob(JobType jobType)
|
||||
{
|
||||
try
|
||||
if (jobType == JobType.Seeker)
|
||||
{
|
||||
var result = await _jobManagementService.StopJob(jobType);
|
||||
|
||||
if (!result)
|
||||
{
|
||||
return BadRequest($"Failed to stop job '{jobType}'");
|
||||
}
|
||||
return Ok(new { Message = $"Job '{jobType}' stopped successfully" });
|
||||
return this.ProblemResult(StatusCodes.Status400BadRequest, "The Seeker job cannot be manually triggered");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error stopping job {jobType}", jobType);
|
||||
return StatusCode(500, $"An error occurred while stopping job '{jobType}'");
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("{jobType}/pause")]
|
||||
public async Task<IActionResult> PauseJob(JobType jobType)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await _jobManagementService.PauseJob(jobType);
|
||||
|
||||
if (!result)
|
||||
{
|
||||
return BadRequest($"Failed to pause job '{jobType}'");
|
||||
}
|
||||
return Ok(new { Message = $"Job '{jobType}' paused successfully" });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error pausing job {jobType}", jobType);
|
||||
return StatusCode(500, $"An error occurred while pausing job '{jobType}'");
|
||||
}
|
||||
}
|
||||
var result = await _jobManagementService.TriggerJobOnce(jobType);
|
||||
|
||||
[HttpPost("{jobType}/resume")]
|
||||
public async Task<IActionResult> ResumeJob(JobType jobType)
|
||||
{
|
||||
try
|
||||
if (!result)
|
||||
{
|
||||
var result = await _jobManagementService.ResumeJob(jobType);
|
||||
|
||||
if (!result)
|
||||
{
|
||||
return BadRequest($"Failed to resume job '{jobType}'");
|
||||
}
|
||||
return Ok(new { Message = $"Job '{jobType}' resumed successfully" });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error resuming job {jobType}", jobType);
|
||||
return StatusCode(500, $"An error occurred while resuming job '{jobType}'");
|
||||
return this.ProblemResult(StatusCodes.Status400BadRequest, $"Failed to trigger job '{jobType}' - job may not exist or be configured");
|
||||
}
|
||||
return Ok(new { Message = $"Job '{jobType}' triggered successfully for one-time execution" });
|
||||
}
|
||||
|
||||
[HttpPut("{jobType}/schedule")]
|
||||
public async Task<IActionResult> UpdateJobSchedule(JobType jobType, [FromBody] ScheduleRequest scheduleRequest)
|
||||
{
|
||||
if (scheduleRequest?.Schedule == null)
|
||||
if (jobType == JobType.Seeker)
|
||||
{
|
||||
return BadRequest("Schedule is required");
|
||||
return this.ProblemResult(StatusCodes.Status400BadRequest, "The Seeker job schedule cannot be manually modified");
|
||||
}
|
||||
|
||||
try
|
||||
if (scheduleRequest?.Schedule == null)
|
||||
{
|
||||
var result = await _jobManagementService.UpdateJobSchedule(jobType, scheduleRequest.Schedule);
|
||||
|
||||
if (!result)
|
||||
{
|
||||
return BadRequest($"Failed to update schedule for job '{jobType}'");
|
||||
}
|
||||
return Ok(new { Message = $"Job '{jobType}' schedule updated successfully" });
|
||||
return this.ProblemResult(StatusCodes.Status400BadRequest, "Schedule is required");
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
var result = await _jobManagementService.UpdateJobSchedule(jobType, scheduleRequest.Schedule);
|
||||
|
||||
if (!result)
|
||||
{
|
||||
_logger.LogError(ex, "Error updating job {jobType} schedule", jobType);
|
||||
return StatusCode(500, $"An error occurred while updating schedule for job '{jobType}'");
|
||||
return this.ProblemResult(StatusCodes.Status400BadRequest, $"Failed to update schedule for job '{jobType}'");
|
||||
}
|
||||
return Ok(new { Message = $"Job '{jobType}' schedule updated successfully" });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Events;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Cleanuparr.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
[Authorize]
|
||||
public class ManualEventsController : ControllerBase
|
||||
{
|
||||
private readonly EventsContext _context;
|
||||
|
||||
public ManualEventsController(EventsContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets manual events with pagination and filtering
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<PaginatedResult<ManualEvent>>> GetManualEvents(
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 50,
|
||||
[FromQuery] bool? isResolved = null,
|
||||
[FromQuery] string? severity = null,
|
||||
[FromQuery] DateTimeOffset? fromDate = null,
|
||||
[FromQuery] DateTimeOffset? toDate = null,
|
||||
[FromQuery] string? search = null)
|
||||
{
|
||||
// Validate pagination parameters
|
||||
if (page < 1)
|
||||
{
|
||||
page = 1;
|
||||
}
|
||||
|
||||
if (pageSize < 1)
|
||||
{
|
||||
pageSize = 50;
|
||||
}
|
||||
|
||||
if (pageSize > 500)
|
||||
{
|
||||
pageSize = 500;
|
||||
}
|
||||
|
||||
var query = _context.ManualEvents.AsQueryable();
|
||||
|
||||
// Apply filters
|
||||
if (isResolved.HasValue)
|
||||
{
|
||||
query = query.Where(e => e.IsResolved == isResolved.Value);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(severity))
|
||||
{
|
||||
if (Enum.TryParse<EventSeverity>(severity, true, out var severityEnum))
|
||||
query = query.Where(e => e.Severity == severityEnum);
|
||||
}
|
||||
|
||||
// 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 search filter if provided
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
{
|
||||
string pattern = EventsContext.GetLikePattern(search);
|
||||
query = query.Where(e =>
|
||||
EF.Functions.Like(e.Message, pattern) ||
|
||||
EF.Functions.Like(e.Data, pattern)
|
||||
);
|
||||
}
|
||||
|
||||
// Count total matching records for pagination
|
||||
var 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
|
||||
.OrderByDescending(e => e.Timestamp)
|
||||
.Skip(skip)
|
||||
.Take(pageSize)
|
||||
.ToListAsync();
|
||||
|
||||
// Return paginated result
|
||||
var result = new PaginatedResult<ManualEvent>
|
||||
{
|
||||
Items = events,
|
||||
Page = page,
|
||||
PageSize = pageSize,
|
||||
TotalCount = totalCount,
|
||||
TotalPages = totalPages
|
||||
};
|
||||
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a specific manual event by ID
|
||||
/// </summary>
|
||||
[HttpGet("{id}")]
|
||||
public async Task<ActionResult<ManualEvent>> GetManualEvent(Guid id)
|
||||
{
|
||||
var eventEntity = await _context.ManualEvents.FindAsync(id);
|
||||
|
||||
if (eventEntity == null)
|
||||
return NotFound();
|
||||
|
||||
return Ok(eventEntity);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks a manual event as resolved
|
||||
/// </summary>
|
||||
[HttpPost("{id}/resolve")]
|
||||
public async Task<ActionResult> ResolveManualEvent(Guid id)
|
||||
{
|
||||
var eventEntity = await _context.ManualEvents.FindAsync(id);
|
||||
|
||||
if (eventEntity == null)
|
||||
return NotFound();
|
||||
|
||||
eventEntity.IsResolved = true;
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets manual event statistics
|
||||
/// </summary>
|
||||
[HttpGet("stats")]
|
||||
public async Task<ActionResult<object>> GetManualEventStats()
|
||||
{
|
||||
var stats = new
|
||||
{
|
||||
TotalEvents = await _context.ManualEvents.CountAsync(),
|
||||
UnresolvedEvents = await _context.ManualEvents.CountAsync(e => !e.IsResolved),
|
||||
ResolvedEvents = await _context.ManualEvents.CountAsync(e => e.IsResolved),
|
||||
EventsBySeverity = await _context.ManualEvents
|
||||
.GroupBy(e => e.Severity)
|
||||
.Select(g => new { Severity = g.Key.ToString(), Count = g.Count() })
|
||||
.ToListAsync(),
|
||||
UnresolvedBySeverity = await _context.ManualEvents
|
||||
.Where(e => !e.IsResolved)
|
||||
.GroupBy(e => e.Severity)
|
||||
.Select(g => new { Severity = g.Key.ToString(), Count = g.Count() })
|
||||
.ToListAsync()
|
||||
};
|
||||
|
||||
return Ok(stats);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets unique severities for manual events
|
||||
/// </summary>
|
||||
[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 = DateTimeOffset.UtcNow.AddDays(-retentionDays);
|
||||
|
||||
var deletedCount = await _context.ManualEvents
|
||||
.Where(e => e.IsResolved && e.Timestamp < cutoffDate)
|
||||
.ExecuteDeleteAsync();
|
||||
|
||||
return Ok(new { DeletedCount = deletedCount });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
// Queue rules endpoints have moved to Cleanuparr.Api.Features.QueueCleaner.Controllers
|
||||
@@ -0,0 +1,41 @@
|
||||
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 IStatsService _statsService;
|
||||
|
||||
public StatsController(IStatsService statsService)
|
||||
{
|
||||
_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)
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
using System.Diagnostics;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.Arr;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient;
|
||||
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
|
||||
using Cleanuparr.Persistence;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
@@ -10,269 +10,236 @@ namespace Cleanuparr.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
[Authorize]
|
||||
public class StatusController : ControllerBase
|
||||
{
|
||||
private readonly ILogger<StatusController> _logger;
|
||||
private readonly DataContext _dataContext;
|
||||
private readonly DownloadServiceFactory _downloadServiceFactory;
|
||||
private readonly ArrClientFactory _arrClientFactory;
|
||||
private readonly IArrClientFactory _arrClientFactory;
|
||||
|
||||
public StatusController(
|
||||
ILogger<StatusController> logger,
|
||||
DataContext dataContext,
|
||||
DownloadServiceFactory downloadServiceFactory,
|
||||
ArrClientFactory arrClientFactory)
|
||||
IArrClientFactory arrClientFactory)
|
||||
{
|
||||
_logger = logger;
|
||||
_dataContext = dataContext;
|
||||
_downloadServiceFactory = downloadServiceFactory;
|
||||
_arrClientFactory = arrClientFactory;
|
||||
}
|
||||
|
||||
[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)
|
||||
// Get configuration
|
||||
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
|
||||
{
|
||||
_logger.LogError(ex, "Error retrieving system status");
|
||||
return StatusCode(500, "An error occurred while retrieving system status");
|
||||
}
|
||||
Application = new
|
||||
{
|
||||
Version = GetType().Assembly.GetName().Version?.ToString() ?? "Unknown",
|
||||
process.StartTime,
|
||||
UpTime = DateTimeOffset.UtcNow - process.StartTime.ToUniversalTime(),
|
||||
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
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
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 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)
|
||||
{
|
||||
var clientsStatus = new List<object>();
|
||||
foreach (var client in downloadClients)
|
||||
clientsStatus.Add(new
|
||||
{
|
||||
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;
|
||||
client.Id,
|
||||
client.Name,
|
||||
Type = client.TypeName,
|
||||
client.Host,
|
||||
client.Enabled,
|
||||
IsConnected = client.Enabled, // We can't check connection status without implementing test methods
|
||||
});
|
||||
}
|
||||
|
||||
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");
|
||||
result["Clients"] = clientsStatus;
|
||||
}
|
||||
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
[HttpGet("arrs")]
|
||||
public async Task<IActionResult> GetMediaManagersStatus()
|
||||
{
|
||||
try
|
||||
var status = new Dictionary<string, object>();
|
||||
|
||||
// Get configurations
|
||||
var enabledSonarrInstances = await _dataContext.ArrConfigs
|
||||
.Include(x => x.Instances)
|
||||
.Where(x => x.Type == InstanceType.Sonarr)
|
||||
.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)
|
||||
{
|
||||
var status = new Dictionary<string, object>();
|
||||
|
||||
// Get configurations
|
||||
var enabledSonarrInstances = await _dataContext.ArrConfigs
|
||||
.Include(x => x.Instances)
|
||||
.Where(x => x.Type == InstanceType.Sonarr)
|
||||
.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
|
||||
{
|
||||
try
|
||||
var sonarrClient = _arrClientFactory.GetClient(InstanceType.Sonarr, instance.Version);
|
||||
await sonarrClient.HealthCheckAsync(instance);
|
||||
|
||||
sonarrStatus.Add(new
|
||||
{
|
||||
var sonarrClient = _arrClientFactory.GetClient(InstanceType.Sonarr);
|
||||
await sonarrClient.TestConnectionAsync(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}"
|
||||
});
|
||||
}
|
||||
instance.Name,
|
||||
instance.Url,
|
||||
IsConnected = true,
|
||||
Message = "Successfully connected"
|
||||
});
|
||||
}
|
||||
|
||||
status["Sonarr"] = sonarrStatus;
|
||||
|
||||
// Check Radarr instances
|
||||
var radarrStatus = new List<object>();
|
||||
|
||||
foreach (var instance in enabledRadarrInstances)
|
||||
catch (Exception ex)
|
||||
{
|
||||
try
|
||||
sonarrStatus.Add(new
|
||||
{
|
||||
var radarrClient = _arrClientFactory.GetClient(InstanceType.Radarr);
|
||||
await radarrClient.TestConnectionAsync(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}"
|
||||
});
|
||||
}
|
||||
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);
|
||||
await lidarrClient.TestConnectionAsync(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);
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
status["Sonarr"] = sonarrStatus;
|
||||
|
||||
// Check Radarr instances
|
||||
var radarrStatus = new List<object>();
|
||||
|
||||
foreach (var instance in enabledRadarrInstances)
|
||||
{
|
||||
_logger.LogError(ex, "Error retrieving media managers status");
|
||||
return StatusCode(500, "An error occurred while retrieving media managers status");
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.State;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Cleanuparr.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
[Authorize]
|
||||
public class StrikesController : ControllerBase
|
||||
{
|
||||
private readonly EventsContext _context;
|
||||
|
||||
public StrikesController(EventsContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets download items with their strikes (grouped), with pagination and filtering
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<PaginatedResult<DownloadItemStrikesDto>>> GetStrikes(
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 50,
|
||||
[FromQuery] string? search = null,
|
||||
[FromQuery] string? type = null)
|
||||
{
|
||||
if (page < 1)
|
||||
{
|
||||
page = 1;
|
||||
}
|
||||
|
||||
if (pageSize < 1)
|
||||
{
|
||||
pageSize = 50;
|
||||
}
|
||||
|
||||
if (pageSize > 500)
|
||||
{
|
||||
pageSize = 500;
|
||||
}
|
||||
|
||||
var query = _context.DownloadItems
|
||||
.Include(d => d.Strikes)
|
||||
.Where(d => d.Strikes.Any());
|
||||
|
||||
// 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))
|
||||
query = query.Where(d => d.Strikes.Any(s => s.Type == strikeType));
|
||||
}
|
||||
|
||||
// Apply search filter on title or download hash
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
{
|
||||
string pattern = EventsContext.GetLikePattern(search);
|
||||
query = query.Where(d =>
|
||||
EF.Functions.Like(d.Title, pattern) ||
|
||||
EF.Functions.Like(d.DownloadId, pattern));
|
||||
}
|
||||
|
||||
var totalCount = await query.CountAsync();
|
||||
var totalPages = (int)Math.Ceiling(totalCount / (double)pageSize);
|
||||
var skip = (page - 1) * pageSize;
|
||||
|
||||
var items = await query
|
||||
.OrderByDescending(d => d.Strikes.Max(s => s.CreatedAt))
|
||||
.Skip(skip)
|
||||
.Take(pageSize)
|
||||
.ToListAsync();
|
||||
|
||||
var dtos = items.Select(d => new DownloadItemStrikesDto
|
||||
{
|
||||
DownloadItemId = d.Id,
|
||||
DownloadId = d.DownloadId,
|
||||
Title = d.Title,
|
||||
TotalStrikes = d.Strikes.Count,
|
||||
StrikesByType = d.Strikes
|
||||
.GroupBy(s => s.Type)
|
||||
.ToDictionary(g => g.Key.ToString(), g => g.Count()),
|
||||
LatestStrikeAt = d.Strikes.Max(s => s.CreatedAt),
|
||||
FirstStrikeAt = d.Strikes.Min(s => s.CreatedAt),
|
||||
IsMarkedForRemoval = d.IsMarkedForRemoval,
|
||||
IsRemoved = d.IsRemoved,
|
||||
IsReturning = d.IsReturning,
|
||||
HasDryRunStrikes = d.Strikes.Any(s => s.IsDryRun),
|
||||
Strikes = d.Strikes
|
||||
.OrderByDescending(s => s.CreatedAt)
|
||||
.Select(s => new StrikeDetailDto
|
||||
{
|
||||
Id = s.Id,
|
||||
Type = s.Type.ToString(),
|
||||
CreatedAt = s.CreatedAt,
|
||||
LastDownloadedBytes = s.LastDownloadedBytes,
|
||||
JobRunId = s.JobRunId,
|
||||
IsDryRun = s.IsDryRun,
|
||||
}).ToList(),
|
||||
}).ToList();
|
||||
|
||||
return Ok(new PaginatedResult<DownloadItemStrikesDto>
|
||||
{
|
||||
Items = dtos,
|
||||
Page = page,
|
||||
PageSize = pageSize,
|
||||
TotalCount = totalCount,
|
||||
TotalPages = totalPages,
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the most recent individual strikes with download item info (for dashboard)
|
||||
/// </summary>
|
||||
[HttpGet("recent")]
|
||||
public async Task<ActionResult<List<RecentStrikeDto>>> GetRecentStrikes(
|
||||
[FromQuery] int count = 5)
|
||||
{
|
||||
if (count < 1) count = 1;
|
||||
if (count > 50) count = 50;
|
||||
|
||||
var strikes = await _context.Strikes
|
||||
.Include(s => s.DownloadItem)
|
||||
.OrderByDescending(s => s.CreatedAt)
|
||||
.Take(count)
|
||||
.Select(s => new RecentStrikeDto
|
||||
{
|
||||
Id = s.Id,
|
||||
Type = s.Type.ToString(),
|
||||
CreatedAt = s.CreatedAt,
|
||||
DownloadId = s.DownloadItem.DownloadId,
|
||||
Title = s.DownloadItem.Title,
|
||||
IsDryRun = s.IsDryRun,
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(strikes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all available strike types
|
||||
/// </summary>
|
||||
[HttpGet("types")]
|
||||
public ActionResult<List<string>> GetStrikeTypes()
|
||||
{
|
||||
var types = Enum.GetNames(typeof(StrikeType)).ToList();
|
||||
return Ok(types);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes all strikes for a specific download item
|
||||
/// </summary>
|
||||
[HttpDelete("{downloadItemId:guid}")]
|
||||
public async Task<IActionResult> DeleteStrikesForItem(Guid downloadItemId)
|
||||
{
|
||||
var item = await _context.DownloadItems
|
||||
.Include(d => d.Strikes)
|
||||
.FirstOrDefaultAsync(d => d.Id == downloadItemId);
|
||||
|
||||
if (item == null)
|
||||
return NotFound();
|
||||
|
||||
_context.Strikes.RemoveRange(item.Strikes);
|
||||
_context.DownloadItems.Remove(item);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
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 DateTimeOffset LatestStrikeAt { get; set; }
|
||||
public DateTimeOffset 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 DateTimeOffset 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 DateTimeOffset CreatedAt { get; set; }
|
||||
public string DownloadId { get; set; } = string.Empty;
|
||||
public string Title { get; set; } = string.Empty;
|
||||
public bool IsDryRun { get; set; }
|
||||
}
|
||||
@@ -1,11 +1,14 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json.Serialization;
|
||||
using Cleanuparr.Api.Middleware;
|
||||
using System.Text.Json.Serialization.Metadata;
|
||||
using Cleanuparr.Api.Filters;
|
||||
using Cleanuparr.Api.Json;
|
||||
using Cleanuparr.Infrastructure.Health;
|
||||
using Cleanuparr.Infrastructure.Hubs;
|
||||
using Cleanuparr.Infrastructure.Logging;
|
||||
using Microsoft.AspNetCore.Http.Json;
|
||||
using Microsoft.OpenApi.Models;
|
||||
using System.Text;
|
||||
using Cleanuparr.Api.Middleware;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Cleanuparr.Api.DependencyInjection;
|
||||
|
||||
@@ -15,43 +18,61 @@ public static class ApiDI
|
||||
{
|
||||
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());
|
||||
});
|
||||
|
||||
|
||||
// Make JsonSerializerOptions available for injection
|
||||
services.AddSingleton(sp =>
|
||||
sp.GetRequiredService<IOptions<JsonOptions>>().Value.SerializerOptions);
|
||||
|
||||
// Add API-specific services
|
||||
services
|
||||
.AddControllers()
|
||||
.AddJsonOptions(options =>
|
||||
{
|
||||
options.JsonSerializerOptions.PropertyNameCaseInsensitive = true;
|
||||
options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());
|
||||
options.JsonSerializerOptions.ReferenceHandler = ReferenceHandler.IgnoreCycles;
|
||||
options.JsonSerializerOptions.TypeInfoResolver = new SensitiveDataResolver(
|
||||
options.JsonSerializerOptions.TypeInfoResolver ?? new DefaultJsonTypeInfoResolver());
|
||||
});
|
||||
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());
|
||||
});
|
||||
|
||||
// Add health status broadcaster
|
||||
services.AddHostedService<HealthStatusBroadcaster>();
|
||||
|
||||
services.AddSwaggerGen(options =>
|
||||
|
||||
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.SwaggerDoc("v1", new OpenApiInfo
|
||||
{
|
||||
Title = "Cleanuparr API",
|
||||
Version = "v1",
|
||||
Description = "API for managing media downloads and cleanups",
|
||||
Contact = new OpenApiContact
|
||||
{
|
||||
Name = "Cleanuparr Team"
|
||||
}
|
||||
});
|
||||
options.CustomizeProblemDetails = ctx =>
|
||||
ctx.ProblemDetails.Extensions.TryAdd(
|
||||
"traceId", Activity.Current?.Id ?? ctx.HttpContext.TraceIdentifier);
|
||||
});
|
||||
|
||||
return services;
|
||||
@@ -59,41 +80,32 @@ public static class ApiDI
|
||||
|
||||
public static WebApplication ConfigureApi(this WebApplication app)
|
||||
{
|
||||
ILogger<Program> logger = app.Services.GetRequiredService<ILogger<Program>>();
|
||||
|
||||
// Map unhandled exceptions to RFC 9457 problem-details responses (GlobalExceptionHandler).
|
||||
// Registered first so it also covers exceptions thrown by downstream middleware.
|
||||
app.UseExceptionHandler();
|
||||
|
||||
// Enable compression
|
||||
app.UseResponseCompression();
|
||||
|
||||
// Serve static files with caching
|
||||
|
||||
// Serve static files without caching
|
||||
app.UseStaticFiles(new StaticFileOptions
|
||||
{
|
||||
OnPrepareResponse = ctx =>
|
||||
{
|
||||
// Cache static assets for 30 days
|
||||
// if (ctx.File.Name.EndsWith(".js") || ctx.File.Name.EndsWith(".css"))
|
||||
// {
|
||||
// ctx.Context.Response.Headers.CacheControl = "public,max-age=2592000";
|
||||
// }
|
||||
}
|
||||
OnPrepareResponse = ctx => NoCacheAttribute.Apply(ctx.Context.Response.Headers)
|
||||
});
|
||||
|
||||
// Add the global exception handling middleware first
|
||||
app.UseMiddleware<ExceptionMiddleware>();
|
||||
|
||||
app.UseCors("Any");
|
||||
app.UseRouting();
|
||||
|
||||
// Resolve the real client IP / scheme / host from X-Forwarded-* headers
|
||||
app.UseMiddleware<TrustedForwardedHeadersMiddleware>();
|
||||
|
||||
// Block non-auth requests until setup is complete
|
||||
app.UseMiddleware<SetupGuardMiddleware>();
|
||||
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI(options =>
|
||||
{
|
||||
options.SwaggerEndpoint("v1/swagger.json", "Cleanuparr API v1");
|
||||
options.RoutePrefix = "swagger";
|
||||
options.DocumentTitle = "Cleanuparr API Documentation";
|
||||
});
|
||||
app.UseCors("DevSpa");
|
||||
}
|
||||
app.UseRouting();
|
||||
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.MapControllers();
|
||||
|
||||
@@ -133,12 +145,13 @@ public static class ApiDI
|
||||
);
|
||||
|
||||
context.Response.ContentType = "text/html";
|
||||
NoCacheAttribute.Apply(context.Response.Headers);
|
||||
await context.Response.WriteAsync(indexContent, Encoding.UTF8);
|
||||
});
|
||||
}).AllowAnonymous();
|
||||
|
||||
// Map SignalR hubs
|
||||
app.MapHub<HealthStatusHub>("/api/hubs/health");
|
||||
app.MapHub<AppHub>("/api/hubs/app");
|
||||
app.MapHub<HealthStatusHub>("/api/hubs/health").RequireAuthorization();
|
||||
app.MapHub<AppHub>("/api/hubs/app").RequireAuthorization();
|
||||
|
||||
app.MapGet("/manifest.webmanifest", (HttpContext context) =>
|
||||
{
|
||||
@@ -150,19 +163,25 @@ public static class ApiDI
|
||||
{
|
||||
name = "Cleanuparr",
|
||||
short_name = "Cleanuparr",
|
||||
description = "Automated cleanup for *arr applications and download clients",
|
||||
start_url = basePath,
|
||||
display = "standalone",
|
||||
background_color = "#ffffff",
|
||||
theme_color = "#ffffff",
|
||||
background_color = "#0e0a1a",
|
||||
theme_color = "#1a1135",
|
||||
icons = new[]
|
||||
{
|
||||
new {
|
||||
src = "assets/icons/icon-192x192.png",
|
||||
src = "icons/128.png",
|
||||
sizes = "128x128",
|
||||
type = "image/png"
|
||||
},
|
||||
new {
|
||||
src = "icons/icon-192x192.png",
|
||||
sizes = "192x192",
|
||||
type = "image/png"
|
||||
},
|
||||
new {
|
||||
src = "assets/icons/icon-512x512.png",
|
||||
src = "icons/icon-512x512.png",
|
||||
sizes = "512x512",
|
||||
type = "image/png"
|
||||
}
|
||||
@@ -170,7 +189,7 @@ public static class ApiDI
|
||||
};
|
||||
|
||||
return Results.Json(manifest, contentType: "application/manifest+json");
|
||||
});
|
||||
}).AllowAnonymous();
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
using Cleanuparr.Api.Auth;
|
||||
using Cleanuparr.Infrastructure.Features.Auth;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
namespace Cleanuparr.Api.DependencyInjection;
|
||||
|
||||
public static class AuthDI
|
||||
{
|
||||
private const string SmartScheme = "Smart";
|
||||
|
||||
public static IServiceCollection AddAuthServices(this IServiceCollection services)
|
||||
{
|
||||
// Get the signing key from the JwtService
|
||||
var jwtService = new JwtService();
|
||||
var signingKey = jwtService.GetOrCreateSigningKey();
|
||||
|
||||
services
|
||||
.AddAuthentication(SmartScheme)
|
||||
.AddPolicyScheme(SmartScheme, "JWT or API Key", options =>
|
||||
{
|
||||
// Route to the correct auth handler based on the request
|
||||
options.ForwardDefaultSelector = context =>
|
||||
{
|
||||
if (context.Request.Headers.ContainsKey(ApiKeyAuthenticationDefaults.HeaderName) ||
|
||||
context.Request.Query.ContainsKey(ApiKeyAuthenticationDefaults.QueryParameterName))
|
||||
{
|
||||
return ApiKeyAuthenticationDefaults.AuthenticationScheme;
|
||||
}
|
||||
|
||||
// Check for Bearer token or SignalR access_token
|
||||
if (context.Request.Headers.ContainsKey("Authorization") ||
|
||||
(context.Request.Path.StartsWithSegments("/api/hubs") &&
|
||||
context.Request.Query.ContainsKey("access_token")))
|
||||
{
|
||||
return JwtBearerDefaults.AuthenticationScheme;
|
||||
}
|
||||
|
||||
// Fall through to trusted network handler (returns NoResult if disabled)
|
||||
return TrustedNetworkAuthenticationDefaults.AuthenticationScheme;
|
||||
};
|
||||
})
|
||||
.AddJwtBearer(options =>
|
||||
{
|
||||
options.TokenValidationParameters = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = true,
|
||||
ValidIssuer = "Cleanuparr",
|
||||
ValidateAudience = true,
|
||||
ValidAudience = "Cleanuparr",
|
||||
ValidateLifetime = true,
|
||||
ValidateIssuerSigningKey = true,
|
||||
IssuerSigningKey = new SymmetricSecurityKey(signingKey),
|
||||
ClockSkew = TimeSpan.FromSeconds(30)
|
||||
};
|
||||
|
||||
// Support SignalR token via query string
|
||||
options.Events = new JwtBearerEvents
|
||||
{
|
||||
OnMessageReceived = context =>
|
||||
{
|
||||
var accessToken = context.Request.Query["access_token"];
|
||||
var path = context.HttpContext.Request.Path;
|
||||
|
||||
if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments("/api/hubs"))
|
||||
{
|
||||
context.Token = accessToken;
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
};
|
||||
})
|
||||
.AddScheme<AuthenticationSchemeOptions, ApiKeyAuthenticationHandler>(
|
||||
ApiKeyAuthenticationDefaults.AuthenticationScheme, _ => { })
|
||||
.AddScheme<AuthenticationSchemeOptions, TrustedNetworkAuthenticationHandler>(
|
||||
TrustedNetworkAuthenticationDefaults.AuthenticationScheme, _ => { });
|
||||
|
||||
services.AddAuthorization(options =>
|
||||
{
|
||||
var defaultPolicy = new Microsoft.AspNetCore.Authorization.AuthorizationPolicyBuilder()
|
||||
.RequireAuthenticatedUser()
|
||||
.Build();
|
||||
|
||||
options.DefaultPolicy = defaultPolicy;
|
||||
options.FallbackPolicy = defaultPolicy;
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,5 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Models;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
using Cleanuparr.Infrastructure.Logging;
|
||||
using Serilog;
|
||||
using Serilog.Events;
|
||||
using Serilog.Templates;
|
||||
using Serilog.Templates.Themes;
|
||||
|
||||
namespace Cleanuparr.Api.DependencyInjection;
|
||||
|
||||
@@ -12,82 +7,10 @@ public static class LoggingDI
|
||||
{
|
||||
public static ILoggingBuilder AddLogging(this ILoggingBuilder builder)
|
||||
{
|
||||
Log.Logger = GetDefaultLoggerConfiguration().CreateLogger();
|
||||
Log.Logger = LoggingConfigManager
|
||||
.CreateLoggerConfiguration()
|
||||
.CreateLogger();
|
||||
|
||||
return builder.ClearProviders().AddSerilog();
|
||||
}
|
||||
|
||||
public static LoggerConfiguration GetDefaultLoggerConfiguration()
|
||||
{
|
||||
LoggerConfiguration logConfig = new();
|
||||
const string categoryTemplate = "{#if Category is not null} {Concat('[',Category,']'),CAT_PAD}{#end}";
|
||||
const string jobNameTemplate = "{#if JobName is not null} {Concat('[',JobName,']'),JOB_PAD}{#end}";
|
||||
|
||||
const string consoleOutputTemplate = $"[{{@t:yyyy-MM-dd HH:mm:ss.fff}} {{@l:u3}}]{jobNameTemplate}{categoryTemplate} {{@m}}\n{{@x}}";
|
||||
const string fileOutputTemplate = $"{{@t:yyyy-MM-dd HH:mm:ss.fff zzz}} [{{@l:u3}}]{jobNameTemplate}{categoryTemplate} {{@m:lj}}\n{{@x}}";
|
||||
|
||||
// Determine job name padding
|
||||
List<string> jobNames = [nameof(JobType.QueueCleaner), nameof(JobType.ContentBlocker), nameof(JobType.DownloadCleaner)];
|
||||
int jobPadding = jobNames.Max(x => x.Length) + 2;
|
||||
|
||||
// Determine instance name padding
|
||||
List<string> categoryNames = [
|
||||
InstanceType.Sonarr.ToString(),
|
||||
InstanceType.Radarr.ToString(),
|
||||
InstanceType.Lidarr.ToString(),
|
||||
InstanceType.Readarr.ToString(),
|
||||
InstanceType.Whisparr.ToString(),
|
||||
"SYSTEM"
|
||||
];
|
||||
int catPadding = categoryNames.Max(x => x.Length) + 2;
|
||||
|
||||
// Apply padding values to templates
|
||||
string consoleTemplate = consoleOutputTemplate
|
||||
.Replace("JOB_PAD", jobPadding.ToString())
|
||||
.Replace("CAT_PAD", catPadding.ToString());
|
||||
|
||||
string fileTemplate = fileOutputTemplate
|
||||
.Replace("JOB_PAD", jobPadding.ToString())
|
||||
.Replace("CAT_PAD", catPadding.ToString());
|
||||
|
||||
// Configure base logger with dynamic level control
|
||||
logConfig
|
||||
.MinimumLevel.Is(LogEventLevel.Information)
|
||||
.Enrich.FromLogContext()
|
||||
.WriteTo.Console(new ExpressionTemplate(consoleTemplate, theme: TemplateTheme.Literate));
|
||||
|
||||
// Create the logs directory
|
||||
string logsPath = Path.Combine(ConfigurationPathProvider.GetConfigPath(), "logs");
|
||||
if (!Directory.Exists(logsPath))
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(logsPath);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
throw new Exception($"Failed to create log directory | {logsPath}", exception);
|
||||
}
|
||||
}
|
||||
|
||||
// Add main log file
|
||||
logConfig.WriteTo.File(
|
||||
path: Path.Combine(logsPath, "cleanuparr-.txt"),
|
||||
formatter: new ExpressionTemplate(fileTemplate),
|
||||
fileSizeLimitBytes: 10L * 1024 * 1024,
|
||||
rollingInterval: RollingInterval.Day,
|
||||
rollOnFileSizeLimit: true,
|
||||
shared: true
|
||||
);
|
||||
|
||||
logConfig
|
||||
.MinimumLevel.Override("MassTransit", LogEventLevel.Warning)
|
||||
.MinimumLevel.Override("Microsoft.Hosting.Lifetime", LogEventLevel.Information)
|
||||
.MinimumLevel.Override("Microsoft", LogEventLevel.Warning)
|
||||
.MinimumLevel.Override("Quartz", LogEventLevel.Warning)
|
||||
.MinimumLevel.Override("System.Net.Http.HttpClient", LogEventLevel.Error)
|
||||
.Enrich.WithProperty("ApplicationName", "Cleanuparr");
|
||||
|
||||
return logConfig;
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,11 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using Cleanuparr.Domain.Entities.Arr;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadHunter.Consumers;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadRemover.Consumers;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Consumers;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Models;
|
||||
using Cleanuparr.Infrastructure.Health;
|
||||
using Cleanuparr.Infrastructure.Http;
|
||||
using Cleanuparr.Infrastructure.Http.DynamicHttpClientSystem;
|
||||
using Data.Models.Arr;
|
||||
using MassTransit;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
|
||||
@@ -17,24 +15,23 @@ public static class MainDI
|
||||
{
|
||||
public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration configuration) =>
|
||||
services
|
||||
.AddLogging(builder => builder.ClearProviders().AddConsole())
|
||||
.AddHttpClients(configuration)
|
||||
.AddSingleton<MemoryCache>()
|
||||
.AddSingleton<IMemoryCache>(serviceProvider => serviceProvider.GetRequiredService<MemoryCache>())
|
||||
.AddServices()
|
||||
.AddHealthServices()
|
||||
.AddQuartzServices(configuration)
|
||||
.AddNotifications(configuration)
|
||||
.AddNotifications()
|
||||
.AddMassTransit(config =>
|
||||
{
|
||||
config.DisableUsageTelemetry();
|
||||
|
||||
config.AddConsumer<DownloadRemoverConsumer<SearchItem>>();
|
||||
config.AddConsumer<DownloadRemoverConsumer<SeriesSearchItem>>();
|
||||
config.AddConsumer<DownloadHunterConsumer<SearchItem>>();
|
||||
config.AddConsumer<DownloadHunterConsumer<SeriesSearchItem>>();
|
||||
|
||||
config.AddConsumer<NotificationConsumer<FailedImportStrikeNotification>>();
|
||||
config.AddConsumer<NotificationConsumer<StalledStrikeNotification>>();
|
||||
config.AddConsumer<NotificationConsumer<SlowStrikeNotification>>();
|
||||
config.AddConsumer<NotificationConsumer<SlowSpeedStrikeNotification>>();
|
||||
config.AddConsumer<NotificationConsumer<SlowTimeStrikeNotification>>();
|
||||
config.AddConsumer<NotificationConsumer<QueueItemDeletedNotification>>();
|
||||
config.AddConsumer<NotificationConsumer<DownloadCleanedNotification>>();
|
||||
config.AddConsumer<NotificationConsumer<CategoryChangedNotification>>();
|
||||
@@ -43,6 +40,7 @@ public static class MainDI
|
||||
{
|
||||
cfg.ConfigureJsonSerializerOptions(options =>
|
||||
{
|
||||
options.PropertyNameCaseInsensitive = true;
|
||||
options.Converters.Add(new JsonStringEnumConverter());
|
||||
options.ReferenceHandler = ReferenceHandler.IgnoreCycles;
|
||||
|
||||
@@ -53,14 +51,6 @@ public static class MainDI
|
||||
{
|
||||
e.ConfigureConsumer<DownloadRemoverConsumer<SearchItem>>(context);
|
||||
e.ConfigureConsumer<DownloadRemoverConsumer<SeriesSearchItem>>(context);
|
||||
e.ConcurrentMessageLimit = 2;
|
||||
e.PrefetchCount = 2;
|
||||
});
|
||||
|
||||
cfg.ReceiveEndpoint("download-hunter-queue", e =>
|
||||
{
|
||||
e.ConfigureConsumer<DownloadHunterConsumer<SearchItem>>(context);
|
||||
e.ConfigureConsumer<DownloadHunterConsumer<SeriesSearchItem>>(context);
|
||||
e.ConcurrentMessageLimit = 1;
|
||||
e.PrefetchCount = 1;
|
||||
});
|
||||
@@ -69,7 +59,8 @@ public static class MainDI
|
||||
{
|
||||
e.ConfigureConsumer<NotificationConsumer<FailedImportStrikeNotification>>(context);
|
||||
e.ConfigureConsumer<NotificationConsumer<StalledStrikeNotification>>(context);
|
||||
e.ConfigureConsumer<NotificationConsumer<SlowStrikeNotification>>(context);
|
||||
e.ConfigureConsumer<NotificationConsumer<SlowSpeedStrikeNotification>>(context);
|
||||
e.ConfigureConsumer<NotificationConsumer<SlowTimeStrikeNotification>>(context);
|
||||
e.ConfigureConsumer<NotificationConsumer<QueueItemDeletedNotification>>(context);
|
||||
e.ConfigureConsumer<NotificationConsumer<DownloadCleanedNotification>>(context);
|
||||
e.ConfigureConsumer<NotificationConsumer<CategoryChangedNotification>>(context);
|
||||
@@ -83,10 +74,16 @@ public static class MainDI
|
||||
{
|
||||
// Add the dynamic HTTP client system - this replaces all the previous static configurations
|
||||
services.AddDynamicHttpClients();
|
||||
|
||||
|
||||
// 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,20 +1,30 @@
|
||||
using Cleanuparr.Infrastructure.Features.Notifications;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Apprise;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Discord;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Notifiarr;
|
||||
using Infrastructure.Verticals.Notifications;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Ntfy;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Pushover;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Telegram;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Gotify;
|
||||
|
||||
namespace Cleanuparr.Api.DependencyInjection;
|
||||
|
||||
public static class NotificationsDI
|
||||
{
|
||||
public static IServiceCollection AddNotifications(this IServiceCollection services, IConfiguration configuration) =>
|
||||
public static IServiceCollection AddNotifications(this IServiceCollection services) =>
|
||||
services
|
||||
// Notification configs are now managed through ConfigManager
|
||||
.AddTransient<INotifiarrProxy, NotifiarrProxy>()
|
||||
.AddTransient<INotificationProvider, NotifiarrProvider>()
|
||||
.AddTransient<IAppriseProxy, AppriseProxy>()
|
||||
.AddTransient<INotificationProvider, AppriseProvider>()
|
||||
.AddTransient<INotificationPublisher, NotificationPublisher>()
|
||||
.AddTransient<INotificationFactory, NotificationFactory>()
|
||||
.AddTransient<NotificationService>();
|
||||
.AddScoped<INotifiarrProxy, NotifiarrProxy>()
|
||||
.AddScoped<IAppriseProxy, AppriseProxy>()
|
||||
.AddScoped<IAppriseCliProxy, AppriseCliProxy>()
|
||||
.AddSingleton<IAppriseCliDetector, AppriseCliDetector>()
|
||||
.AddScoped<INtfyProxy, NtfyProxy>()
|
||||
.AddScoped<IPushoverProxy, PushoverProxy>()
|
||||
.AddScoped<ITelegramProxy, TelegramProxy>()
|
||||
.AddScoped<IDiscordProxy, DiscordProxy>()
|
||||
.AddScoped<IGotifyProxy, GotifyProxy>()
|
||||
.AddScoped<INotificationConfigurationService, NotificationConfigurationService>()
|
||||
.AddScoped<INotificationProviderFactory, NotificationProviderFactory>()
|
||||
.AddScoped<NotificationProviderFactory>()
|
||||
.AddScoped<INotificationPublisher, NotificationPublisher>()
|
||||
.AddScoped<NotificationService>();
|
||||
}
|
||||
@@ -1,23 +1,23 @@
|
||||
using Cleanuparr.Application.Features.ContentBlocker;
|
||||
using Cleanuparr.Application.Features.DownloadCleaner;
|
||||
using Cleanuparr.Application.Features.QueueCleaner;
|
||||
using Cleanuparr.Infrastructure.Events;
|
||||
using Cleanuparr.Infrastructure.Events.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.Arr;
|
||||
using Cleanuparr.Infrastructure.Features.ContentBlocker;
|
||||
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.Auth;
|
||||
using Cleanuparr.Infrastructure.Features.BlacklistSync;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadCleaner.Services;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadHunter;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadHunter.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadRemover;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadRemover.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.Files;
|
||||
using Cleanuparr.Infrastructure.Features.ItemStriker;
|
||||
using Cleanuparr.Infrastructure.Features.Security;
|
||||
using Cleanuparr.Infrastructure.Features.Jobs;
|
||||
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
|
||||
using Cleanuparr.Infrastructure.Helpers;
|
||||
using Cleanuparr.Infrastructure.Interceptors;
|
||||
using Cleanuparr.Infrastructure.Services;
|
||||
using Cleanuparr.Infrastructure.Services.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Stats;
|
||||
using Cleanuparr.Persistence;
|
||||
using Infrastructure.Interceptors;
|
||||
using Infrastructure.Verticals.Files;
|
||||
|
||||
namespace Cleanuparr.Api.DependencyInjection;
|
||||
|
||||
@@ -25,32 +25,53 @@ public static class ServicesDI
|
||||
{
|
||||
public static IServiceCollection AddServices(this IServiceCollection services) =>
|
||||
services
|
||||
.AddScoped<IEncryptionService, AesEncryptionService>()
|
||||
.AddScoped<SensitiveDataJsonConverter>()
|
||||
.AddScoped<EventsContext>()
|
||||
.AddScoped<DataContext>()
|
||||
.AddScoped<EventPublisher>()
|
||||
.AddScoped<UsersContext>()
|
||||
.AddSingleton<IJwtService, JwtService>()
|
||||
.AddSingleton<IPasswordService, PasswordService>()
|
||||
.AddSingleton<ITotpService, TotpService>()
|
||||
.AddScoped<IPlexAuthService, PlexAuthService>()
|
||||
.AddScoped<IOidcAuthService, OidcAuthService>()
|
||||
.AddScoped<IEventPublisher, EventPublisher>()
|
||||
.AddHostedService<EventCleanupService>()
|
||||
.AddScoped<IDryRunInterceptor, DryRunInterceptor>()
|
||||
.AddScoped<CertificateValidationService>()
|
||||
.AddScoped<SonarrClient>()
|
||||
.AddScoped<RadarrClient>()
|
||||
.AddScoped<LidarrClient>()
|
||||
.AddScoped<ReadarrClient>()
|
||||
.AddScoped<WhisparrClient>()
|
||||
.AddScoped<ArrClientFactory>()
|
||||
.AddScoped<ISonarrClient, SonarrClient>()
|
||||
.AddScoped<IRadarrClient, RadarrClient>()
|
||||
.AddScoped<ILidarrClient, LidarrClient>()
|
||||
.AddScoped<IReadarrClient, ReadarrClient>()
|
||||
.AddScoped<IWhisparrV2Client, WhisparrV2Client>()
|
||||
.AddScoped<IWhisparrV3Client, WhisparrV3Client>()
|
||||
.AddScoped<IArrClientFactory, ArrClientFactory>()
|
||||
.AddScoped<QueueCleaner>()
|
||||
.AddScoped<ContentBlocker>()
|
||||
.AddScoped<BlacklistSynchronizer>()
|
||||
.AddScoped<MalwareBlocker>()
|
||||
.AddScoped<DownloadCleaner>()
|
||||
.AddScoped<ISeedingRulesCleanupService, SeedingRulesCleanupService>()
|
||||
.AddScoped<IUnlinkedDownloadsService, UnlinkedDownloadsService>()
|
||||
.AddScoped<IDeadTorrentService, DeadTorrentService>()
|
||||
.AddScoped<IOrphanedFilesCleanupService, OrphanedFilesCleanupService>()
|
||||
.AddScoped<Seeker>()
|
||||
.AddScoped<CustomFormatScoreSyncer>()
|
||||
.AddScoped<IQueueItemRemover, QueueItemRemover>()
|
||||
.AddScoped<IDownloadHunter, DownloadHunter>()
|
||||
.AddScoped<IFilenameEvaluator, FilenameEvaluator>()
|
||||
.AddScoped<IHardLinkFileService, HardLinkFileService>()
|
||||
.AddScoped<UnixHardLinkFileService>()
|
||||
.AddScoped<WindowsHardLinkFileService>()
|
||||
.AddScoped<ArrQueueIterator>()
|
||||
.AddScoped<DownloadServiceFactory>()
|
||||
.AddScoped<IUnixHardLinkFileService, UnixHardLinkFileService>()
|
||||
.AddScoped<IWindowsHardLinkFileService, WindowsHardLinkFileService>()
|
||||
.AddScoped<IArrQueueIterator, ArrQueueIterator>()
|
||||
.AddScoped<IDownloadServiceFactory, DownloadServiceFactory>()
|
||||
.AddScoped<IStriker, Striker>()
|
||||
.AddScoped<FileReader>()
|
||||
.AddScoped<IQueueRuleManager, QueueRuleManager>()
|
||||
.AddScoped<IQueueRuleEvaluator, QueueRuleEvaluator>()
|
||||
.AddScoped<ISeedingRuleEvaluator, SeedingRuleEvaluator>()
|
||||
.AddScoped<IRuleIntervalValidator, RuleIntervalValidator>()
|
||||
.AddScoped<IStatsService, StatsService>()
|
||||
.AddSingleton<IJobManagementService, JobManagementService>()
|
||||
.AddSingleton<BlocklistProvider>();
|
||||
.AddSingleton<IBlocklistProvider, BlocklistProvider>()
|
||||
.AddSingleton(TimeProvider.System)
|
||||
.AddSingleton<AppStatusSnapshot>()
|
||||
.AddHostedService<AppStatusRefreshService>()
|
||||
.AddHostedService<SeekerCommandMonitor>();
|
||||
}
|
||||
@@ -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" },
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
namespace Cleanuparr.Api.Extensions;
|
||||
|
||||
public static class HttpRequestExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns the request PathBase as a safe relative path.
|
||||
/// Rejects absolute URLs (e.g. "://" or "//") to prevent open redirect attacks.
|
||||
/// </summary>
|
||||
public static string GetSafeBasePath(this HttpRequest request)
|
||||
{
|
||||
var basePath = request.PathBase.Value?.TrimEnd('/') ?? "";
|
||||
if (basePath.Contains("://") || basePath.StartsWith("//"))
|
||||
{
|
||||
return "";
|
||||
}
|
||||
return basePath;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the external base URL (scheme + host + basePath).
|
||||
/// TrustedForwardedHeadersMiddleware has already applied X-Forwarded-Proto and X-Forwarded-Host to <see cref="HttpRequest.Scheme"/> / <see cref="HttpRequest.Host"/>.
|
||||
/// </summary>
|
||||
public static string GetExternalBaseUrl(this HttpContext context)
|
||||
{
|
||||
var request = context.Request;
|
||||
var basePath = request.GetSafeBasePath();
|
||||
return $"{request.Scheme}://{request.Host}{basePath}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using System;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Arr.Contracts.Requests;
|
||||
|
||||
public sealed record ArrInstanceRequest
|
||||
{
|
||||
public bool Enabled { get; init; } = true;
|
||||
|
||||
[Required]
|
||||
public required string Name { get; init; }
|
||||
|
||||
[Required]
|
||||
public required string Url { get; init; }
|
||||
|
||||
[Required]
|
||||
public required string ApiKey { get; init; }
|
||||
|
||||
[Required]
|
||||
public required float Version { get; init; }
|
||||
|
||||
public string? ExternalUrl { get; init; }
|
||||
|
||||
public ArrInstance ToEntity(Guid configId)
|
||||
{
|
||||
if (ApiKey.IsPlaceholder())
|
||||
{
|
||||
throw new ValidationException("API key is required when creating a new instance");
|
||||
}
|
||||
|
||||
return new()
|
||||
{
|
||||
Enabled = Enabled,
|
||||
Name = Name,
|
||||
Url = new Uri(Url),
|
||||
ExternalUrl = ExternalUrl is not null ? new Uri(ExternalUrl) : null,
|
||||
ApiKey = ApiKey,
|
||||
ArrConfigId = configId,
|
||||
Version = Version,
|
||||
};
|
||||
}
|
||||
|
||||
public void ApplyTo(ArrInstance instance)
|
||||
{
|
||||
instance.Enabled = Enabled;
|
||||
instance.Name = Name;
|
||||
instance.Url = new Uri(Url);
|
||||
instance.ExternalUrl = ExternalUrl is not null ? new Uri(ExternalUrl) : null;
|
||||
instance.ApiKey = ApiKey.IsPlaceholder() ? instance.ApiKey : ApiKey;
|
||||
instance.Version = Version;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using System;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Arr.Contracts.Requests;
|
||||
|
||||
public sealed record TestArrInstanceRequest
|
||||
{
|
||||
[Required]
|
||||
public required string Url { get; init; }
|
||||
|
||||
[Required]
|
||||
public required string ApiKey { get; init; }
|
||||
|
||||
[Required]
|
||||
public required float Version { get; init; }
|
||||
|
||||
public Guid? InstanceId { get; init; }
|
||||
|
||||
public ArrInstance ToTestInstance(string? resolvedApiKey = null)
|
||||
{
|
||||
var apiKey = resolvedApiKey ?? ApiKey;
|
||||
|
||||
if (apiKey.IsPlaceholder())
|
||||
{
|
||||
throw new ValidationException("API key cannot be a placeholder value");
|
||||
}
|
||||
|
||||
return new()
|
||||
{
|
||||
Enabled = true,
|
||||
Name = "Test Instance",
|
||||
Url = new Uri(Url),
|
||||
ApiKey = apiKey,
|
||||
ArrConfigId = Guid.Empty,
|
||||
Version = Version,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace Cleanuparr.Api.Features.Arr.Contracts.Requests;
|
||||
|
||||
public sealed record UpdateArrConfigRequest
|
||||
{
|
||||
public short FailedImportMaxStrikes { get; init; } = -1;
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
using Cleanuparr.Api.Extensions;
|
||||
using Cleanuparr.Api.Features.Arr.Contracts.Requests;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.Arr.Dtos;
|
||||
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
using Mapster;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Arr.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/configuration")]
|
||||
[Authorize]
|
||||
public sealed class ArrConfigController : ControllerBase
|
||||
{
|
||||
private readonly ILogger<ArrConfigController> _logger;
|
||||
private readonly DataContext _dataContext;
|
||||
private readonly IArrClientFactory _arrClientFactory;
|
||||
|
||||
public ArrConfigController(
|
||||
ILogger<ArrConfigController> logger,
|
||||
DataContext dataContext,
|
||||
IArrClientFactory arrClientFactory)
|
||||
{
|
||||
_logger = logger;
|
||||
_dataContext = dataContext;
|
||||
_arrClientFactory = arrClientFactory;
|
||||
}
|
||||
|
||||
[HttpGet("sonarr")]
|
||||
public Task<IActionResult> GetSonarrConfig() => GetArrConfig(InstanceType.Sonarr);
|
||||
|
||||
[HttpGet("radarr")]
|
||||
public Task<IActionResult> GetRadarrConfig() => GetArrConfig(InstanceType.Radarr);
|
||||
|
||||
[HttpGet("lidarr")]
|
||||
public Task<IActionResult> GetLidarrConfig() => GetArrConfig(InstanceType.Lidarr);
|
||||
|
||||
[HttpGet("readarr")]
|
||||
public Task<IActionResult> GetReadarrConfig() => GetArrConfig(InstanceType.Readarr);
|
||||
|
||||
[HttpGet("whisparr")]
|
||||
public Task<IActionResult> GetWhisparrConfig() => GetArrConfig(InstanceType.Whisparr);
|
||||
|
||||
[HttpPut("sonarr")]
|
||||
public Task<IActionResult> UpdateSonarrConfig([FromBody] UpdateArrConfigRequest request)
|
||||
=> UpdateArrConfig(InstanceType.Sonarr, request);
|
||||
|
||||
[HttpPut("radarr")]
|
||||
public Task<IActionResult> UpdateRadarrConfig([FromBody] UpdateArrConfigRequest request)
|
||||
=> UpdateArrConfig(InstanceType.Radarr, request);
|
||||
|
||||
[HttpPut("lidarr")]
|
||||
public Task<IActionResult> UpdateLidarrConfig([FromBody] UpdateArrConfigRequest request)
|
||||
=> UpdateArrConfig(InstanceType.Lidarr, request);
|
||||
|
||||
[HttpPut("readarr")]
|
||||
public Task<IActionResult> UpdateReadarrConfig([FromBody] UpdateArrConfigRequest request)
|
||||
=> UpdateArrConfig(InstanceType.Readarr, request);
|
||||
|
||||
[HttpPut("whisparr")]
|
||||
public Task<IActionResult> UpdateWhisparrConfig([FromBody] UpdateArrConfigRequest request)
|
||||
=> UpdateArrConfig(InstanceType.Whisparr, request);
|
||||
|
||||
[HttpPost("sonarr/instances")]
|
||||
public Task<IActionResult> CreateSonarrInstance([FromBody] ArrInstanceRequest request)
|
||||
=> CreateArrInstance(InstanceType.Sonarr, request);
|
||||
|
||||
[HttpPut("sonarr/instances/{id}")]
|
||||
public Task<IActionResult> UpdateSonarrInstance(Guid id, [FromBody] ArrInstanceRequest request)
|
||||
=> UpdateArrInstance(InstanceType.Sonarr, id, request);
|
||||
|
||||
[HttpDelete("sonarr/instances/{id}")]
|
||||
public Task<IActionResult> DeleteSonarrInstance(Guid id)
|
||||
=> DeleteArrInstance(InstanceType.Sonarr, id);
|
||||
|
||||
[HttpPost("radarr/instances")]
|
||||
public Task<IActionResult> CreateRadarrInstance([FromBody] ArrInstanceRequest request)
|
||||
=> CreateArrInstance(InstanceType.Radarr, request);
|
||||
|
||||
[HttpPut("radarr/instances/{id}")]
|
||||
public Task<IActionResult> UpdateRadarrInstance(Guid id, [FromBody] ArrInstanceRequest request)
|
||||
=> UpdateArrInstance(InstanceType.Radarr, id, request);
|
||||
|
||||
[HttpDelete("radarr/instances/{id}")]
|
||||
public Task<IActionResult> DeleteRadarrInstance(Guid id)
|
||||
=> DeleteArrInstance(InstanceType.Radarr, id);
|
||||
|
||||
[HttpPost("lidarr/instances")]
|
||||
public Task<IActionResult> CreateLidarrInstance([FromBody] ArrInstanceRequest request)
|
||||
=> CreateArrInstance(InstanceType.Lidarr, request);
|
||||
|
||||
[HttpPut("lidarr/instances/{id}")]
|
||||
public Task<IActionResult> UpdateLidarrInstance(Guid id, [FromBody] ArrInstanceRequest request)
|
||||
=> UpdateArrInstance(InstanceType.Lidarr, id, request);
|
||||
|
||||
[HttpDelete("lidarr/instances/{id}")]
|
||||
public Task<IActionResult> DeleteLidarrInstance(Guid id)
|
||||
=> DeleteArrInstance(InstanceType.Lidarr, id);
|
||||
|
||||
[HttpPost("readarr/instances")]
|
||||
public Task<IActionResult> CreateReadarrInstance([FromBody] ArrInstanceRequest request)
|
||||
=> CreateArrInstance(InstanceType.Readarr, request);
|
||||
|
||||
[HttpPut("readarr/instances/{id}")]
|
||||
public Task<IActionResult> UpdateReadarrInstance(Guid id, [FromBody] ArrInstanceRequest request)
|
||||
=> UpdateArrInstance(InstanceType.Readarr, id, request);
|
||||
|
||||
[HttpDelete("readarr/instances/{id}")]
|
||||
public Task<IActionResult> DeleteReadarrInstance(Guid id)
|
||||
=> DeleteArrInstance(InstanceType.Readarr, id);
|
||||
|
||||
[HttpPost("whisparr/instances")]
|
||||
public Task<IActionResult> CreateWhisparrInstance([FromBody] ArrInstanceRequest request)
|
||||
=> CreateArrInstance(InstanceType.Whisparr, request);
|
||||
|
||||
[HttpPut("whisparr/instances/{id}")]
|
||||
public Task<IActionResult> UpdateWhisparrInstance(Guid id, [FromBody] ArrInstanceRequest request)
|
||||
=> UpdateArrInstance(InstanceType.Whisparr, id, request);
|
||||
|
||||
[HttpDelete("whisparr/instances/{id}")]
|
||||
public Task<IActionResult> DeleteWhisparrInstance(Guid id)
|
||||
=> DeleteArrInstance(InstanceType.Whisparr, id);
|
||||
|
||||
[HttpPost("sonarr/instances/test")]
|
||||
public Task<IActionResult> TestSonarrInstance([FromBody] TestArrInstanceRequest request)
|
||||
=> TestArrInstance(InstanceType.Sonarr, request);
|
||||
|
||||
[HttpPost("radarr/instances/test")]
|
||||
public Task<IActionResult> TestRadarrInstance([FromBody] TestArrInstanceRequest request)
|
||||
=> TestArrInstance(InstanceType.Radarr, request);
|
||||
|
||||
[HttpPost("lidarr/instances/test")]
|
||||
public Task<IActionResult> TestLidarrInstance([FromBody] TestArrInstanceRequest request)
|
||||
=> TestArrInstance(InstanceType.Lidarr, request);
|
||||
|
||||
[HttpPost("readarr/instances/test")]
|
||||
public Task<IActionResult> TestReadarrInstance([FromBody] TestArrInstanceRequest request)
|
||||
=> TestArrInstance(InstanceType.Readarr, request);
|
||||
|
||||
[HttpPost("whisparr/instances/test")]
|
||||
public Task<IActionResult> TestWhisparrInstance([FromBody] TestArrInstanceRequest request)
|
||||
=> TestArrInstance(InstanceType.Whisparr, request);
|
||||
|
||||
private async Task<IActionResult> GetArrConfig(InstanceType type)
|
||||
{
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var config = await _dataContext.ArrConfigs
|
||||
.Include(x => x.Instances)
|
||||
.AsNoTracking()
|
||||
.FirstAsync(x => x.Type == type);
|
||||
|
||||
config.Instances = config.Instances
|
||||
.OrderBy(i => i.Name)
|
||||
.ToList();
|
||||
|
||||
return Ok(config.Adapt<ArrConfigDto>());
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<IActionResult> UpdateArrConfig(InstanceType type, UpdateArrConfigRequest request)
|
||||
{
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var config = await _dataContext.ArrConfigs
|
||||
.FirstAsync(x => x.Type == type);
|
||||
|
||||
config.FailedImportMaxStrikes = request.FailedImportMaxStrikes;
|
||||
config.Validate();
|
||||
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
return Ok(new { Message = $"{type} configuration updated successfully" });
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<IActionResult> CreateArrInstance(InstanceType type, ArrInstanceRequest request)
|
||||
{
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var config = await _dataContext.ArrConfigs
|
||||
.FirstAsync(x => x.Type == type);
|
||||
|
||||
var instance = request.ToEntity(config.Id);
|
||||
await _dataContext.ArrInstances.AddAsync(instance);
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
return CreatedAtAction(GetConfigActionName(type), new { id = instance.Id }, instance.Adapt<ArrInstanceDto>());
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<IActionResult> UpdateArrInstance(InstanceType type, Guid id, ArrInstanceRequest request)
|
||||
{
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var config = await _dataContext.ArrConfigs
|
||||
.Include(c => c.Instances)
|
||||
.FirstAsync(x => x.Type == type);
|
||||
|
||||
var instance = config.Instances.FirstOrDefault(i => i.Id == id);
|
||||
if (instance is null)
|
||||
{
|
||||
return this.ProblemResult(StatusCodes.Status404NotFound, $"{type} instance with ID {id} not found");
|
||||
}
|
||||
|
||||
request.ApplyTo(instance);
|
||||
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
return Ok(instance.Adapt<ArrInstanceDto>());
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<IActionResult> DeleteArrInstance(InstanceType type, Guid id)
|
||||
{
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var config = await _dataContext.ArrConfigs
|
||||
.Include(c => c.Instances)
|
||||
.FirstAsync(x => x.Type == type);
|
||||
|
||||
var instance = config.Instances.FirstOrDefault(i => i.Id == id);
|
||||
if (instance is null)
|
||||
{
|
||||
return this.ProblemResult(StatusCodes.Status404NotFound, $"{type} instance with ID {id} not found");
|
||||
}
|
||||
|
||||
config.Instances.Remove(instance);
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<IActionResult> TestArrInstance(InstanceType type, TestArrInstanceRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
string? resolvedApiKey = null;
|
||||
|
||||
if (request.ApiKey.IsPlaceholder() && request.InstanceId.HasValue)
|
||||
{
|
||||
var existingInstance = await _dataContext.ArrInstances
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(i => i.Id == request.InstanceId.Value);
|
||||
|
||||
if (existingInstance is null)
|
||||
{
|
||||
return 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);
|
||||
|
||||
return Ok(new { Message = $"Connection to {type} instance successful" });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to test {Type} instance connection", type);
|
||||
return this.ProblemResult(StatusCodes.Status400BadRequest, $"Connection failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetConfigActionName(InstanceType type) => type switch
|
||||
{
|
||||
InstanceType.Sonarr => nameof(GetSonarrConfig),
|
||||
InstanceType.Radarr => nameof(GetRadarrConfig),
|
||||
InstanceType.Lidarr => nameof(GetLidarrConfig),
|
||||
InstanceType.Readarr => nameof(GetReadarrConfig),
|
||||
InstanceType.Whisparr => nameof(GetWhisparrConfig),
|
||||
_ => nameof(GetSonarrConfig),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Auth.Contracts.Requests;
|
||||
|
||||
public sealed record ChangePasswordRequest
|
||||
{
|
||||
[Required]
|
||||
public required string CurrentPassword { get; init; }
|
||||
|
||||
[Required]
|
||||
[MinLength(8)]
|
||||
[MaxLength(128)]
|
||||
public required string NewPassword { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Auth.Contracts.Requests;
|
||||
|
||||
public sealed record CreateAccountRequest
|
||||
{
|
||||
[Required]
|
||||
[MinLength(3)]
|
||||
[MaxLength(50)]
|
||||
public required string Username { get; init; }
|
||||
|
||||
[Required]
|
||||
[MinLength(8)]
|
||||
[MaxLength(128)]
|
||||
public required string Password { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Auth.Contracts.Requests;
|
||||
|
||||
public sealed record Disable2faRequest
|
||||
{
|
||||
[Required]
|
||||
public required string Password { get; init; }
|
||||
|
||||
[Required]
|
||||
[StringLength(6, MinimumLength = 6)]
|
||||
public required string TotpCode { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Auth.Contracts.Requests;
|
||||
|
||||
public sealed record Enable2faRequest
|
||||
{
|
||||
[Required]
|
||||
public required string Password { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Auth.Contracts.Requests;
|
||||
|
||||
public sealed record LoginRequest
|
||||
{
|
||||
[Required]
|
||||
public required string Username { get; init; }
|
||||
|
||||
[Required]
|
||||
public required string Password { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Auth.Contracts.Requests;
|
||||
|
||||
public sealed record OidcExchangeRequest
|
||||
{
|
||||
[Required]
|
||||
public required string Code { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Auth.Contracts.Requests;
|
||||
|
||||
public sealed record PlexPinRequest
|
||||
{
|
||||
[Required]
|
||||
public required int PinId { 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; }
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Auth.Contracts.Requests;
|
||||
|
||||
public sealed record RefreshTokenRequest
|
||||
{
|
||||
[Required]
|
||||
public required string RefreshToken { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Auth.Contracts.Requests;
|
||||
|
||||
public sealed record Regenerate2faRequest
|
||||
{
|
||||
[Required]
|
||||
public required string Password { get; init; }
|
||||
|
||||
[Required]
|
||||
[StringLength(6, MinimumLength = 6)]
|
||||
public required string TotpCode { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Auth.Contracts.Requests;
|
||||
|
||||
public sealed record TwoFactorRequest
|
||||
{
|
||||
[Required]
|
||||
public required string LoginToken { get; init; }
|
||||
|
||||
[Required]
|
||||
public required string Code { get; init; }
|
||||
|
||||
public bool IsRecoveryCode { get; init; }
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
using Cleanuparr.Infrastructure.Features.Auth;
|
||||
using Cleanuparr.Persistence.Models.Auth;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Auth.Contracts.Requests;
|
||||
|
||||
public sealed record UpdateOidcConfigRequest
|
||||
{
|
||||
public bool Enabled { get; init; }
|
||||
|
||||
public string IssuerUrl { get; init; } = string.Empty;
|
||||
|
||||
public string ClientId { get; init; } = string.Empty;
|
||||
|
||||
public string ClientSecret { get; init; } = string.Empty;
|
||||
|
||||
public string Scopes { get; init; } = "openid profile email";
|
||||
|
||||
public string ProviderName { get; init; } = "OIDC";
|
||||
|
||||
public string RedirectUrl { get; init; } = string.Empty;
|
||||
|
||||
public bool ExclusiveMode { get; init; }
|
||||
|
||||
public void ApplyTo(OidcConfig existingConfig)
|
||||
{
|
||||
var previousIssuerUrl = existingConfig.IssuerUrl;
|
||||
|
||||
existingConfig.Enabled = Enabled;
|
||||
existingConfig.IssuerUrl = IssuerUrl;
|
||||
existingConfig.ClientId = ClientId;
|
||||
existingConfig.Scopes = Scopes;
|
||||
existingConfig.ProviderName = ProviderName;
|
||||
existingConfig.RedirectUrl = RedirectUrl;
|
||||
existingConfig.ExclusiveMode = ExclusiveMode;
|
||||
|
||||
if (!ClientSecret.IsPlaceholder())
|
||||
{
|
||||
existingConfig.ClientSecret = ClientSecret;
|
||||
}
|
||||
|
||||
// AuthorizedSubject is intentionally NOT mapped here — it is set only via the OIDC link callback
|
||||
|
||||
if (previousIssuerUrl != IssuerUrl)
|
||||
{
|
||||
OidcAuthService.ClearDiscoveryCache();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Auth.Contracts.Requests;
|
||||
|
||||
public sealed record VerifyTotpRequest
|
||||
{
|
||||
[Required]
|
||||
[StringLength(6, MinimumLength = 6)]
|
||||
public required string Code { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace Cleanuparr.Api.Features.Auth.Contracts.Responses;
|
||||
|
||||
public sealed record AccountInfoResponse
|
||||
{
|
||||
public required string Username { get; init; }
|
||||
public required bool PlexLinked { get; init; }
|
||||
public string? PlexUsername { get; init; }
|
||||
public required bool TwoFactorEnabled { get; init; }
|
||||
public required string ApiKeyPreview { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace Cleanuparr.Api.Features.Auth.Contracts.Responses;
|
||||
|
||||
public sealed record AuthStatusResponse
|
||||
{
|
||||
public required bool SetupCompleted { get; init; }
|
||||
public bool PlexLinked { get; init; }
|
||||
public bool AuthBypassActive { get; init; }
|
||||
public bool OidcEnabled { get; init; }
|
||||
public string OidcProviderName { get; init; } = string.Empty;
|
||||
public bool OidcExclusiveMode { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,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; }
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace Cleanuparr.Api.Features.Auth.Contracts.Responses;
|
||||
|
||||
public sealed record LoginResponse
|
||||
{
|
||||
public required bool RequiresTwoFactor { get; init; }
|
||||
public string? LoginToken { get; init; }
|
||||
public TokenResponse? Tokens { get; init; }
|
||||
}
|
||||
Loaded 100 of 1376 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user