mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2026-09-10 20:41:22 -04:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20b93f6853 | ||
|
|
2ce204c1bc | ||
|
|
d653b7fa3f | ||
|
|
0dcb42efa2 | ||
|
|
820d254553 | ||
|
|
715ef5711b | ||
|
|
ea3244367e | ||
|
|
f26768bcf7 | ||
|
|
87bb92fac0 | ||
|
|
01dc90bfa7 | ||
|
|
c37e6384a5 | ||
|
|
70fc955d37 | ||
|
|
a44f226e8a | ||
|
|
edafde5810 | ||
|
|
54cd037cd2 | ||
|
|
2ebf67d44d | ||
|
|
d2d294b93c | ||
|
|
39eb91ac48 | ||
|
|
53376d94d9 | ||
|
|
f51973bb7b |
No files matched your search
@@ -74,9 +74,9 @@ jobs:
|
||||
token: ${{ env.REPO_READONLY_PAT }}
|
||||
|
||||
- name: Setup dotnet
|
||||
uses: actions/setup-dotnet@v4
|
||||
uses: actions/setup-dotnet@v5
|
||||
with:
|
||||
dotnet-version: 10.0.x
|
||||
dotnet-version: 10.0.200
|
||||
|
||||
- name: Cache NuGet packages
|
||||
uses: actions/cache@v4
|
||||
|
||||
@@ -2,6 +2,12 @@ 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:
|
||||
@@ -22,7 +28,7 @@ jobs:
|
||||
timeout-minutes: 1
|
||||
with:
|
||||
repository: ${{ github.repository }}
|
||||
ref: ${{ github.ref_name }}
|
||||
ref: ${{ inputs.ref || github.ref_name }}
|
||||
token: ${{ env.REPO_READONLY_PAT }}
|
||||
|
||||
- name: Setup Node.js
|
||||
|
||||
@@ -84,9 +84,9 @@ jobs:
|
||||
path: code/frontend/dist/ui/browser
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
uses: actions/setup-dotnet@v5
|
||||
with:
|
||||
dotnet-version: 10.0.x
|
||||
dotnet-version: 10.0.200
|
||||
|
||||
- name: Restore .NET dependencies
|
||||
run: |
|
||||
|
||||
@@ -8,6 +8,11 @@ on:
|
||||
type: string
|
||||
required: false
|
||||
default: ''
|
||||
ref:
|
||||
description: 'Git ref to checkout (branch, tag, or SHA). Defaults to github.ref_name.'
|
||||
type: string
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
jobs:
|
||||
build-windows-installer:
|
||||
@@ -58,7 +63,7 @@ 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: Download frontend artifact
|
||||
@@ -68,9 +73,9 @@ jobs:
|
||||
path: code/frontend/dist/ui/browser
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
uses: actions/setup-dotnet@v5
|
||||
with:
|
||||
dotnet-version: 10.0.x
|
||||
dotnet-version: 10.0.200
|
||||
|
||||
- name: Restore .NET dependencies
|
||||
run: |
|
||||
|
||||
@@ -20,6 +20,10 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Create directory for static files
|
||||
run: |
|
||||
mkdir -p Cloudflare/static
|
||||
|
||||
- name: Copy root static files to Cloudflare static directory
|
||||
run: |
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
name: E2E Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'code/**'
|
||||
- 'e2e/**'
|
||||
- '.github/workflows/e2e.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'code/**'
|
||||
- 'e2e/**'
|
||||
- '.github/workflows/e2e.yml'
|
||||
workflow_call:
|
||||
|
||||
concurrency:
|
||||
group: E2E Tests-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
e2e:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
timeout-minutes: 1
|
||||
|
||||
- name: Get vault secrets
|
||||
uses: hashicorp/vault-action@v2
|
||||
with:
|
||||
url: ${{ secrets.VAULT_HOST }}
|
||||
method: approle
|
||||
roleId: ${{ secrets.VAULT_ROLE_ID }}
|
||||
secretId: ${{ secrets.VAULT_SECRET_ID }}
|
||||
secrets:
|
||||
secrets/data/github packages_pat | PACKAGES_PAT
|
||||
|
||||
- name: Start services
|
||||
working-directory: e2e
|
||||
run: docker compose -f docker-compose.e2e.yml up -d --build
|
||||
env:
|
||||
PACKAGES_USERNAME: ${{ github.repository_owner }}
|
||||
PACKAGES_PAT: ${{ env.PACKAGES_PAT }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Install E2E dependencies
|
||||
working-directory: e2e
|
||||
run: npm ci
|
||||
|
||||
- name: Install Playwright browsers
|
||||
working-directory: e2e
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: Wait for Keycloak
|
||||
run: |
|
||||
echo "Waiting for Keycloak realm to be ready..."
|
||||
timeout 120 bash -c 'until curl -sf http://localhost:8080/realms/cleanuparr-test/.well-known/openid-configuration; do sleep 3; done'
|
||||
echo "Keycloak ready!"
|
||||
|
||||
- name: Wait for app
|
||||
run: |
|
||||
echo "Waiting for Cleanuparr to be ready..."
|
||||
timeout 120 bash -c 'until curl -sf http://localhost:5000/health; do sleep 3; done'
|
||||
echo "App ready!"
|
||||
|
||||
- name: Run E2E tests
|
||||
working-directory: e2e
|
||||
run: npx playwright test
|
||||
|
||||
- name: Upload test results
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: e2e-test-results
|
||||
path: |
|
||||
e2e/playwright-report/
|
||||
e2e/test-results/
|
||||
retention-days: 7
|
||||
|
||||
- name: Stop services
|
||||
if: always()
|
||||
working-directory: e2e
|
||||
run: docker compose -f docker-compose.e2e.yml down
|
||||
@@ -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
|
||||
});
|
||||
@@ -96,24 +96,33 @@ jobs:
|
||||
uses: ./.github/workflows/test.yml
|
||||
secrets: inherit
|
||||
|
||||
# Run E2E tests
|
||||
e2e:
|
||||
needs: validate
|
||||
if: ${{ needs.validate.outputs.is_tag == 'true' || github.event.inputs.runTests == 'true' }}
|
||||
uses: ./.github/workflows/e2e.yml
|
||||
secrets: inherit
|
||||
|
||||
# Build frontend once for all build jobs and cache it
|
||||
build-frontend:
|
||||
needs: [validate, test]
|
||||
needs: [validate, test, e2e]
|
||||
if: |
|
||||
always() &&
|
||||
needs.validate.result == 'success' &&
|
||||
(needs.test.result == 'success' || needs.test.result == 'skipped') &&
|
||||
(needs.e2e.result == 'success' || needs.e2e.result == 'skipped') &&
|
||||
(needs.validate.outputs.is_tag == 'true' || github.event.inputs.buildBinaries == 'true')
|
||||
uses: ./.github/workflows/build-frontend.yml
|
||||
secrets: inherit
|
||||
|
||||
# Build portable executables
|
||||
build-executables:
|
||||
needs: [validate, test, build-frontend]
|
||||
needs: [validate, test, e2e, build-frontend]
|
||||
if: |
|
||||
always() &&
|
||||
needs.validate.result == 'success' &&
|
||||
(needs.test.result == 'success' || needs.test.result == 'skipped') &&
|
||||
(needs.e2e.result == 'success' || needs.e2e.result == 'skipped') &&
|
||||
needs.build-frontend.result == 'success' &&
|
||||
(needs.validate.outputs.is_tag == 'true' || github.event.inputs.buildBinaries == 'true')
|
||||
uses: ./.github/workflows/build-executable.yml
|
||||
@@ -123,11 +132,12 @@ jobs:
|
||||
|
||||
# Build Windows installer
|
||||
build-windows-installer:
|
||||
needs: [validate, test, build-frontend]
|
||||
needs: [validate, test, e2e, build-frontend]
|
||||
if: |
|
||||
always() &&
|
||||
needs.validate.result == 'success' &&
|
||||
(needs.test.result == 'success' || needs.test.result == 'skipped') &&
|
||||
(needs.e2e.result == 'success' || needs.e2e.result == 'skipped') &&
|
||||
needs.build-frontend.result == 'success' &&
|
||||
(needs.validate.outputs.is_tag == 'true' || github.event.inputs.buildBinaries == 'true')
|
||||
uses: ./.github/workflows/build-windows-installer.yml
|
||||
@@ -137,11 +147,12 @@ jobs:
|
||||
|
||||
# Build macOS installers (Intel and ARM)
|
||||
build-macos:
|
||||
needs: [validate, test, build-frontend]
|
||||
needs: [validate, test, e2e, build-frontend]
|
||||
if: |
|
||||
always() &&
|
||||
needs.validate.result == 'success' &&
|
||||
(needs.test.result == 'success' || needs.test.result == 'skipped') &&
|
||||
(needs.e2e.result == 'success' || needs.e2e.result == 'skipped') &&
|
||||
needs.build-frontend.result == 'success' &&
|
||||
(needs.validate.outputs.is_tag == 'true' || github.event.inputs.buildBinaries == 'true')
|
||||
uses: ./.github/workflows/build-macos-installer.yml
|
||||
@@ -151,11 +162,12 @@ jobs:
|
||||
|
||||
# Build and push Docker image(s)
|
||||
build-docker:
|
||||
needs: [validate, test]
|
||||
needs: [validate, test, e2e]
|
||||
if: |
|
||||
always() &&
|
||||
needs.validate.result == 'success' &&
|
||||
(needs.test.result == 'success' || needs.test.result == 'skipped') &&
|
||||
(needs.e2e.result == 'success' || needs.e2e.result == 'skipped') &&
|
||||
(needs.validate.outputs.is_tag == 'true' || github.event.inputs.buildDocker == 'true')
|
||||
uses: ./.github/workflows/build-docker.yml
|
||||
with:
|
||||
@@ -232,7 +244,7 @@ jobs:
|
||||
|
||||
# Summary job
|
||||
summary:
|
||||
needs: [validate, test, build-frontend, build-executables, build-windows-installer, build-macos, build-docker]
|
||||
needs: [validate, test, e2e, build-frontend, build-executables, build-windows-installer, build-macos, build-docker]
|
||||
runs-on: ubuntu-latest
|
||||
if: always()
|
||||
|
||||
@@ -277,6 +289,7 @@ jobs:
|
||||
}
|
||||
|
||||
print_result "Tests" "${{ needs.test.result }}"
|
||||
print_result "E2E Tests" "${{ needs.e2e.result }}"
|
||||
print_result "Frontend Build" "${{ needs.build-frontend.result }}"
|
||||
print_result "Portable Executables" "${{ needs.build-executables.result }}"
|
||||
print_result "Windows Installer" "${{ needs.build-windows-installer.result }}"
|
||||
|
||||
@@ -15,7 +15,7 @@ on:
|
||||
|
||||
# Cancel in-progress runs for the same PR
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
group: Tests-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
@@ -29,9 +29,9 @@ jobs:
|
||||
timeout-minutes: 1
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
uses: actions/setup-dotnet@v5
|
||||
with:
|
||||
dotnet-version: 10.0.x
|
||||
dotnet-version: 10.0.200
|
||||
|
||||
- name: Cache NuGet packages
|
||||
uses: actions/cache@v4
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
thepirateheaven.org
|
||||
RARBG.work
|
||||
@@ -24,4 +24,8 @@
|
||||
<ProjectReference Include="..\Cleanuparr.Api\Cleanuparr.Api.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,14 +1,17 @@
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Xunit;
|
||||
|
||||
// Integration tests share file-system state (config-dir used by SetupGuardMiddleware),
|
||||
// so they must be run sequentially to avoid interference between factories.
|
||||
[assembly: CollectionBehavior(DisableTestParallelization = true)]
|
||||
|
||||
namespace Cleanuparr.Api.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Custom WebApplicationFactory that uses an isolated SQLite database for each test fixture.
|
||||
/// The database file is created in a temp directory so both DI and static contexts share the same data.
|
||||
/// Custom WebApplicationFactory that redirects all database contexts to an isolated temp directory
|
||||
/// </summary>
|
||||
public class CustomWebApplicationFactory : WebApplicationFactory<Program>
|
||||
{
|
||||
@@ -18,6 +21,8 @@ public class CustomWebApplicationFactory : WebApplicationFactory<Program>
|
||||
{
|
||||
_tempDir = Path.Combine(Path.GetTempPath(), $"cleanuparr-test-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_tempDir);
|
||||
|
||||
ConfigurationPathProvider.SetConfigPath(_tempDir);
|
||||
}
|
||||
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
@@ -26,26 +31,12 @@ public class CustomWebApplicationFactory : WebApplicationFactory<Program>
|
||||
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
// Remove the existing UsersContext registration
|
||||
var descriptor = services.SingleOrDefault(d => d.ServiceType == typeof(DbContextOptions<UsersContext>));
|
||||
if (descriptor != null) services.Remove(descriptor);
|
||||
|
||||
// Also remove the DbContext registration itself
|
||||
var contextDescriptor = services.SingleOrDefault(d => d.ServiceType == typeof(UsersContext));
|
||||
if (contextDescriptor != null) services.Remove(contextDescriptor);
|
||||
|
||||
var dbPath = Path.Combine(_tempDir, "users.db");
|
||||
|
||||
services.AddDbContext<UsersContext>(options =>
|
||||
// Remove all hosted services (Quartz scheduler, BackgroundJobManager) to prevent
|
||||
// Quartz.Logging.LogProvider.ResolvedLogProvider (a cached Lazy<T>) from being accessed
|
||||
foreach (var hostedService in services.Where(d => d.ServiceType == typeof(IHostedService)).ToList())
|
||||
{
|
||||
options.UseSqlite($"Data Source={dbPath}");
|
||||
});
|
||||
|
||||
// Ensure DB is created
|
||||
var sp = services.BuildServiceProvider();
|
||||
using var scope = sp.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<UsersContext>();
|
||||
db.Database.EnsureCreated();
|
||||
services.Remove(hostedService);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,423 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Cleanuparr.Infrastructure.Features.Auth;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Auth;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Shouldly;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Features.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for the OIDC account linking flow (POST /api/account/oidc/link and
|
||||
/// GET /api/account/oidc/link/callback). Uses a mock IOidcAuthService that tracks the
|
||||
/// initiatorUserId passed from StartOidcLink so OidcLinkCallback can complete the flow.
|
||||
/// </summary>
|
||||
[Collection("Auth Integration Tests")]
|
||||
[TestCaseOrderer("Cleanuparr.Api.Tests.PriorityOrderer", "Cleanuparr.Api.Tests")]
|
||||
public class AccountControllerOidcTests : IClassFixture<AccountControllerOidcTests.OidcLinkWebApplicationFactory>
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
private readonly OidcLinkWebApplicationFactory _factory;
|
||||
|
||||
// Shared across ordered tests
|
||||
private static string? _accessToken;
|
||||
|
||||
public AccountControllerOidcTests(OidcLinkWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
_client = factory.CreateClient(new WebApplicationFactoryClientOptions
|
||||
{
|
||||
AllowAutoRedirect = false
|
||||
});
|
||||
|
||||
if (_accessToken is not null)
|
||||
{
|
||||
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _accessToken);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact, TestPriority(0)]
|
||||
public async Task Setup_CreateAccountAndComplete()
|
||||
{
|
||||
var createResponse = await _client.PostAsJsonAsync("/api/auth/setup/account", new
|
||||
{
|
||||
username = "linkadmin",
|
||||
password = "LinkPassword123!"
|
||||
});
|
||||
createResponse.StatusCode.ShouldBe(HttpStatusCode.Created);
|
||||
|
||||
var completeResponse = await _client.PostAsJsonAsync("/api/auth/setup/complete", new { });
|
||||
completeResponse.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(1)]
|
||||
public async Task Login_StoreAccessToken()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/login", new
|
||||
{
|
||||
username = "linkadmin",
|
||||
password = "LinkPassword123!"
|
||||
});
|
||||
|
||||
var bodyText = await response.Content.ReadAsStringAsync();
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK, $"Login failed. Body: {bodyText}");
|
||||
|
||||
var body = JsonSerializer.Deserialize<JsonElement>(bodyText);
|
||||
body.TryGetProperty("requiresTwoFactor", out var rtf)
|
||||
.ShouldBeTrue($"Missing 'requiresTwoFactor' in body: {bodyText}");
|
||||
rtf.GetBoolean().ShouldBeFalse();
|
||||
// Tokens are nested: { "requiresTwoFactor": false, "tokens": { "accessToken": "..." } }
|
||||
_accessToken = body.GetProperty("tokens").GetProperty("accessToken").GetString();
|
||||
_accessToken.ShouldNotBeNullOrEmpty();
|
||||
|
||||
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _accessToken);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(2)]
|
||||
public async Task OidcLink_WhenOidcDisabled_ReturnsBadRequest()
|
||||
{
|
||||
var response = await _client.PostAsync("/api/account/oidc/link", null);
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
body.GetProperty("error").GetString().ShouldContain("OIDC is not enabled");
|
||||
}
|
||||
|
||||
[Fact, TestPriority(3)]
|
||||
public async Task EnableOidcConfig_ViaDirectDbUpdate()
|
||||
{
|
||||
await _factory.EnableOidcAsync();
|
||||
|
||||
var statusResponse = await _client.GetAsync("/api/auth/status");
|
||||
statusResponse.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var body = await statusResponse.Content.ReadFromJsonAsync<JsonElement>();
|
||||
body.GetProperty("oidcEnabled").GetBoolean().ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact, TestPriority(4)]
|
||||
public async Task OidcLink_WhenAuthenticated_ReturnsAuthorizationUrl()
|
||||
{
|
||||
var response = await _client.PostAsync("/api/account/oidc/link", null);
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
var authUrl = body.GetProperty("authorizationUrl").GetString();
|
||||
authUrl.ShouldNotBeNullOrEmpty();
|
||||
authUrl.ShouldContain("authorize");
|
||||
}
|
||||
|
||||
[Fact, TestPriority(5)]
|
||||
public async Task OidcLinkCallback_WithErrorParam_RedirectsToSettingsWithError()
|
||||
{
|
||||
var response = await _client.GetAsync("/api/account/oidc/link/callback?error=access_denied");
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Redirect);
|
||||
var location = response.Headers.Location?.ToString();
|
||||
location.ShouldNotBeNull();
|
||||
location.ShouldContain("/settings/account");
|
||||
location.ShouldContain("oidc_link_error=failed");
|
||||
}
|
||||
|
||||
[Fact, TestPriority(6)]
|
||||
public async Task OidcLinkCallback_MissingCodeOrState_RedirectsWithError()
|
||||
{
|
||||
var noParams = await _client.GetAsync("/api/account/oidc/link/callback");
|
||||
noParams.StatusCode.ShouldBe(HttpStatusCode.Redirect);
|
||||
noParams.Headers.Location?.ToString().ShouldContain("oidc_link_error=failed");
|
||||
|
||||
var onlyCode = await _client.GetAsync("/api/account/oidc/link/callback?code=some-code");
|
||||
onlyCode.StatusCode.ShouldBe(HttpStatusCode.Redirect);
|
||||
onlyCode.Headers.Location?.ToString().ShouldContain("oidc_link_error=failed");
|
||||
}
|
||||
|
||||
[Fact, TestPriority(7)]
|
||||
public async Task OidcLinkCallback_ValidFlow_SavesSubjectAndRedirectsToSuccess()
|
||||
{
|
||||
// First trigger StartOidcLink so the mock captures the initiatorUserId
|
||||
var linkResponse = await _client.PostAsync("/api/account/oidc/link", null);
|
||||
linkResponse.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
// Now simulate the IdP callback with the mock's success state
|
||||
var callbackResponse = await _client.GetAsync(
|
||||
$"/api/account/oidc/link/callback?code=valid-code&state={MockOidcAuthService.LinkSuccessState}");
|
||||
|
||||
callbackResponse.StatusCode.ShouldBe(HttpStatusCode.Redirect);
|
||||
var location = callbackResponse.Headers.Location?.ToString();
|
||||
location.ShouldNotBeNull();
|
||||
location.ShouldContain("/settings/account");
|
||||
location.ShouldContain("oidc_link=success");
|
||||
location.ShouldNotContain("oidc_link_error");
|
||||
|
||||
// Verify the subject was saved to config
|
||||
var savedSubject = await _factory.GetAuthorizedSubjectAsync();
|
||||
savedSubject.ShouldBe(MockOidcAuthService.LinkedSubject);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(8)]
|
||||
public async Task OidcLinkCallback_NoInitiatorUserId_RedirectsWithError()
|
||||
{
|
||||
var response = await _client.GetAsync(
|
||||
$"/api/account/oidc/link/callback?code=valid-code&state={MockOidcAuthService.NoInitiatorState}");
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Redirect);
|
||||
var location = response.Headers.Location?.ToString();
|
||||
location.ShouldNotBeNull();
|
||||
location.ShouldContain("oidc_link_error=failed");
|
||||
}
|
||||
|
||||
[Fact, TestPriority(9)]
|
||||
public async Task OidcLink_WhenUnauthenticated_ReturnsUnauthorized()
|
||||
{
|
||||
// Create a fresh unauthenticated client
|
||||
var unauthClient = _factory.CreateClient(new WebApplicationFactoryClientOptions
|
||||
{
|
||||
AllowAutoRedirect = false
|
||||
});
|
||||
|
||||
var response = await unauthClient.PostAsync("/api/account/oidc/link", null);
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
|
||||
}
|
||||
|
||||
#region Exclusive Mode
|
||||
|
||||
[Fact, TestPriority(10)]
|
||||
public async Task EnableExclusiveMode_ViaDirectDbUpdate()
|
||||
{
|
||||
await _factory.SetOidcExclusiveModeAsync(true);
|
||||
|
||||
var response = await _client.GetAsync("/api/auth/status");
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
body.GetProperty("oidcExclusiveMode").GetBoolean().ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact, TestPriority(11)]
|
||||
public async Task ChangePassword_Blocked_WhenExclusiveModeActive()
|
||||
{
|
||||
var response = await _client.PutAsJsonAsync("/api/account/password", new
|
||||
{
|
||||
currentPassword = "LinkPassword123!",
|
||||
newPassword = "NewPassword456!"
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Forbidden);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(12)]
|
||||
public async Task PlexLink_Blocked_WhenExclusiveModeActive()
|
||||
{
|
||||
var response = await _client.PostAsync("/api/account/plex/link", null);
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Forbidden);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(13)]
|
||||
public async Task PlexUnlink_Blocked_WhenExclusiveModeActive()
|
||||
{
|
||||
var response = await _client.DeleteAsync("/api/account/plex/link");
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Forbidden);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(14)]
|
||||
public async Task OidcConfigUpdate_StillWorks_WhenExclusiveModeActive()
|
||||
{
|
||||
var response = await _client.PutAsJsonAsync("/api/account/oidc", new
|
||||
{
|
||||
enabled = true,
|
||||
issuerUrl = "https://mock-oidc-provider.test",
|
||||
clientId = "test-client",
|
||||
clientSecret = "test-secret",
|
||||
scopes = "openid profile email",
|
||||
authorizedSubject = MockOidcAuthService.LinkedSubject,
|
||||
providerName = "TestProvider",
|
||||
redirectUrl = "",
|
||||
exclusiveMode = true
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(15)]
|
||||
public async Task OidcUnlink_ResetsExclusiveMode()
|
||||
{
|
||||
var response = await _client.DeleteAsync("/api/account/oidc/link");
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
// Verify exclusive mode was reset
|
||||
var exclusiveMode = await _factory.GetExclusiveModeAsync();
|
||||
exclusiveMode.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact, TestPriority(16)]
|
||||
public async Task DisableExclusiveMode_PasswordChangeWorks_Again()
|
||||
{
|
||||
// Re-enable OIDC with a linked subject but without exclusive mode
|
||||
await _factory.EnableOidcAsync();
|
||||
await _factory.SetOidcExclusiveModeAsync(false);
|
||||
|
||||
var response = await _client.PutAsJsonAsync("/api/account/password", new
|
||||
{
|
||||
currentPassword = "LinkPassword123!",
|
||||
newPassword = "NewPassword789!"
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Test Infrastructure
|
||||
|
||||
public class OidcLinkWebApplicationFactory : CustomWebApplicationFactory
|
||||
{
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
base.ConfigureWebHost(builder);
|
||||
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
var oidcDescriptor = services.SingleOrDefault(d => d.ServiceType == typeof(IOidcAuthService));
|
||||
if (oidcDescriptor != null) services.Remove(oidcDescriptor);
|
||||
|
||||
services.AddSingleton<IOidcAuthService, MockOidcAuthService>();
|
||||
});
|
||||
}
|
||||
|
||||
public async Task EnableOidcAsync()
|
||||
{
|
||||
using var scope = Services.CreateScope();
|
||||
var usersContext = scope.ServiceProvider.GetRequiredService<UsersContext>();
|
||||
|
||||
var user = await usersContext.Users.FirstOrDefaultAsync();
|
||||
if (user is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
user.Oidc = new OidcConfig
|
||||
{
|
||||
Enabled = true,
|
||||
IssuerUrl = "https://mock-oidc-provider.test",
|
||||
ClientId = "test-client",
|
||||
ClientSecret = "test-secret",
|
||||
Scopes = "openid profile email",
|
||||
AuthorizedSubject = "initial-subject",
|
||||
ProviderName = "TestProvider"
|
||||
};
|
||||
|
||||
await usersContext.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task<string?> GetAuthorizedSubjectAsync()
|
||||
{
|
||||
using var scope = Services.CreateScope();
|
||||
var usersContext = scope.ServiceProvider.GetRequiredService<UsersContext>();
|
||||
|
||||
var user = await usersContext.Users.AsNoTracking().FirstOrDefaultAsync();
|
||||
return user?.Oidc.AuthorizedSubject;
|
||||
}
|
||||
|
||||
public async Task SetOidcExclusiveModeAsync(bool enabled)
|
||||
{
|
||||
using var scope = Services.CreateScope();
|
||||
var usersContext = scope.ServiceProvider.GetRequiredService<UsersContext>();
|
||||
|
||||
var user = await usersContext.Users.FirstOrDefaultAsync();
|
||||
if (user is not null)
|
||||
{
|
||||
user.Oidc.ExclusiveMode = enabled;
|
||||
await usersContext.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> GetExclusiveModeAsync()
|
||||
{
|
||||
using var scope = Services.CreateScope();
|
||||
var usersContext = scope.ServiceProvider.GetRequiredService<UsersContext>();
|
||||
|
||||
var user = await usersContext.Users.AsNoTracking().FirstOrDefaultAsync();
|
||||
return user?.Oidc.ExclusiveMode ?? false;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private sealed class MockOidcAuthService : IOidcAuthService
|
||||
{
|
||||
public const string LinkSuccessState = "mock-link-success-state";
|
||||
public const string NoInitiatorState = "mock-no-initiator-state";
|
||||
public const string LinkedSubject = "newly-linked-subject-123";
|
||||
|
||||
private string? _lastInitiatorUserId;
|
||||
private readonly ConcurrentDictionary<string, OidcTokenExchangeResult> _oneTimeCodes = new();
|
||||
|
||||
public Task<OidcAuthorizationResult> StartAuthorization(string redirectUri, string? initiatorUserId = null)
|
||||
{
|
||||
_lastInitiatorUserId = initiatorUserId;
|
||||
return Task.FromResult(new OidcAuthorizationResult
|
||||
{
|
||||
AuthorizationUrl = $"https://mock-oidc-provider.test/authorize?state={LinkSuccessState}",
|
||||
State = LinkSuccessState
|
||||
});
|
||||
}
|
||||
|
||||
public Task<OidcCallbackResult> HandleCallback(string code, string state, string redirectUri)
|
||||
{
|
||||
if (state == LinkSuccessState)
|
||||
{
|
||||
return Task.FromResult(new OidcCallbackResult
|
||||
{
|
||||
Success = true,
|
||||
Subject = LinkedSubject,
|
||||
PreferredUsername = "linkuser",
|
||||
Email = "link@example.com",
|
||||
InitiatorUserId = _lastInitiatorUserId
|
||||
});
|
||||
}
|
||||
|
||||
if (state == NoInitiatorState)
|
||||
{
|
||||
return Task.FromResult(new OidcCallbackResult
|
||||
{
|
||||
Success = true,
|
||||
Subject = LinkedSubject,
|
||||
InitiatorUserId = null // No initiator — controller should redirect with error
|
||||
});
|
||||
}
|
||||
|
||||
return Task.FromResult(new OidcCallbackResult
|
||||
{
|
||||
Success = false,
|
||||
Error = "Invalid or expired OIDC state"
|
||||
});
|
||||
}
|
||||
|
||||
public string StoreOneTimeCode(string accessToken, string refreshToken, int expiresIn)
|
||||
{
|
||||
var code = Guid.NewGuid().ToString("N");
|
||||
_oneTimeCodes.TryAdd(code, new OidcTokenExchangeResult
|
||||
{
|
||||
AccessToken = accessToken,
|
||||
RefreshToken = refreshToken,
|
||||
ExpiresIn = expiresIn
|
||||
});
|
||||
return code;
|
||||
}
|
||||
|
||||
public OidcTokenExchangeResult? ExchangeOneTimeCode(string code) =>
|
||||
_oneTimeCodes.TryRemove(code, out var result) ? result : null;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -10,6 +10,7 @@ namespace Cleanuparr.Api.Tests.Features.Auth;
|
||||
/// Uses a single shared factory to avoid static state conflicts.
|
||||
/// Tests are ordered to build on each other: setup → login → protected endpoints.
|
||||
/// </summary>
|
||||
[Collection("Auth Integration Tests")]
|
||||
[TestCaseOrderer("Cleanuparr.Api.Tests.PriorityOrderer", "Cleanuparr.Api.Tests")]
|
||||
public class AuthControllerTests : IClassFixture<CustomWebApplicationFactory>
|
||||
{
|
||||
@@ -31,6 +32,16 @@ public class AuthControllerTests : IClassFixture<CustomWebApplicationFactory>
|
||||
body.GetProperty("setupCompleted").GetBoolean().ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact, TestPriority(0)]
|
||||
public async Task AuthEndpoints_AlwaysReturnNoCacheHeaders()
|
||||
{
|
||||
var response = await _client.GetAsync("/api/auth/status");
|
||||
|
||||
response.Headers.CacheControl.ShouldNotBeNull();
|
||||
response.Headers.CacheControl!.NoCache.ShouldBeTrue();
|
||||
response.Headers.CacheControl!.NoStore.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact, TestPriority(1)]
|
||||
public async Task Setup_CreateAccount_ReturnsCreated()
|
||||
{
|
||||
@@ -233,6 +244,30 @@ public class AuthControllerTests : IClassFixture<CustomWebApplicationFactory>
|
||||
body.GetProperty("setupCompleted").GetBoolean().ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact, TestPriority(16)]
|
||||
public async Task OidcExchange_WithNonexistentCode_ReturnsNotFound()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/oidc/exchange", new
|
||||
{
|
||||
code = "nonexistent-one-time-code"
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.NotFound);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(17)]
|
||||
public async Task AuthStatus_IncludesOidcFields()
|
||||
{
|
||||
var response = await _client.GetAsync("/api/auth/status");
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
// Verify OIDC fields exist in the response (values depend on shared static DB state)
|
||||
body.TryGetProperty("oidcEnabled", out _).ShouldBeTrue();
|
||||
body.TryGetProperty("oidcProviderName", out _).ShouldBeTrue();
|
||||
}
|
||||
|
||||
#region TOTP helpers
|
||||
|
||||
private static string _totpSecret = "";
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using Shouldly;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Features.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Tests that the login endpoint always runs BCrypt verification regardless of
|
||||
/// username validity, preventing timing-based username enumeration.
|
||||
/// </summary>
|
||||
[Collection("Login Timing Tests")]
|
||||
[TestCaseOrderer("Cleanuparr.Api.Tests.PriorityOrderer", "Cleanuparr.Api.Tests")]
|
||||
public class LoginTimingTests : IClassFixture<TimingTestWebApplicationFactory>
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
private readonly TimingTestWebApplicationFactory _factory;
|
||||
|
||||
public LoginTimingTests(TimingTestWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
_client = factory.CreateClient();
|
||||
}
|
||||
|
||||
[Fact, TestPriority(0)]
|
||||
public async Task Login_NoUserExists_StillCallsPasswordVerification()
|
||||
{
|
||||
_factory.TrackingPasswordService.Reset();
|
||||
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/login", new
|
||||
{
|
||||
username = "nouser",
|
||||
password = "SomePassword123!"
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
|
||||
_factory.TrackingPasswordService.VerifyPasswordCallCount.ShouldBeGreaterThanOrEqualTo(1);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(1)]
|
||||
public async Task Setup_CreateAccountAndComplete()
|
||||
{
|
||||
var createResponse = await _client.PostAsJsonAsync("/api/auth/setup/account", new
|
||||
{
|
||||
username = "timingtest",
|
||||
password = "TimingTestPassword123!"
|
||||
});
|
||||
createResponse.StatusCode.ShouldBe(HttpStatusCode.Created);
|
||||
|
||||
var completeResponse = await _client.PostAsJsonAsync("/api/auth/setup/complete", new { });
|
||||
completeResponse.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(2)]
|
||||
public async Task Login_ValidUsername_CallsPasswordVerification()
|
||||
{
|
||||
_factory.TrackingPasswordService.Reset();
|
||||
|
||||
await _client.PostAsJsonAsync("/api/auth/login", new
|
||||
{
|
||||
username = "timingtest",
|
||||
password = "TimingTestPassword123!"
|
||||
});
|
||||
|
||||
_factory.TrackingPasswordService.VerifyPasswordCallCount.ShouldBeGreaterThanOrEqualTo(1);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(3)]
|
||||
public async Task Login_NonexistentUsername_StillCallsPasswordVerification()
|
||||
{
|
||||
_factory.TrackingPasswordService.Reset();
|
||||
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/login", new
|
||||
{
|
||||
username = "doesnotexist",
|
||||
password = "SomePassword123!"
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
|
||||
_factory.TrackingPasswordService.VerifyPasswordCallCount.ShouldBeGreaterThanOrEqualTo(1);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(4)]
|
||||
public async Task Login_LockedOutUser_StillCallsPasswordVerification()
|
||||
{
|
||||
// Trigger lockout by making several failed login attempts
|
||||
for (var i = 0; i < 5; i++)
|
||||
{
|
||||
await _client.PostAsJsonAsync("/api/auth/login", new
|
||||
{
|
||||
username = "timingtest",
|
||||
password = "WrongPassword!"
|
||||
});
|
||||
}
|
||||
|
||||
_factory.TrackingPasswordService.Reset();
|
||||
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/login", new
|
||||
{
|
||||
username = "timingtest",
|
||||
password = "WrongPassword!"
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.TooManyRequests);
|
||||
_factory.TrackingPasswordService.VerifyPasswordCallCount.ShouldBeGreaterThanOrEqualTo(1);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(5)]
|
||||
public async Task Login_TimingConsistency_InvalidAndValidUsernamesTakeSimilarTime()
|
||||
{
|
||||
const int iterations = 10;
|
||||
|
||||
// Warm up the server and BCrypt static init
|
||||
await _client.PostAsJsonAsync("/api/auth/login", new
|
||||
{
|
||||
username = "warmup",
|
||||
password = "WarmupPassword123!"
|
||||
});
|
||||
|
||||
var invalidTimings = new List<long>(iterations);
|
||||
var validTimings = new List<long>(iterations);
|
||||
|
||||
for (var i = 0; i < iterations; i++)
|
||||
{
|
||||
// Alternate to avoid ordering bias
|
||||
var invalidSw = Stopwatch.StartNew();
|
||||
await _client.PostAsJsonAsync("/api/auth/login", new
|
||||
{
|
||||
username = $"nonexistent_{i}",
|
||||
password = "SomePassword123!"
|
||||
});
|
||||
invalidSw.Stop();
|
||||
invalidTimings.Add(invalidSw.ElapsedMilliseconds);
|
||||
|
||||
var validSw = Stopwatch.StartNew();
|
||||
await _client.PostAsJsonAsync("/api/auth/login", new
|
||||
{
|
||||
username = "timingtest",
|
||||
password = "WrongPasswordForTiming!"
|
||||
});
|
||||
validSw.Stop();
|
||||
validTimings.Add(validSw.ElapsedMilliseconds);
|
||||
}
|
||||
|
||||
var invalidMedian = Median(invalidTimings);
|
||||
var validMedian = Median(validTimings);
|
||||
|
||||
// The invalid-username path must not be suspiciously fast
|
||||
invalidMedian.ShouldBeGreaterThan(50,
|
||||
$"Non-existent username median too fast ({invalidMedian}ms) — BCrypt may have been skipped");
|
||||
|
||||
// Medians should be in the same ballpark
|
||||
var ratio = invalidMedian > validMedian
|
||||
? (double)invalidMedian / validMedian
|
||||
: (double)validMedian / invalidMedian;
|
||||
|
||||
ratio.ShouldBeLessThan(3.0,
|
||||
$"Timing difference too large: invalid median={invalidMedian}ms, valid median={validMedian}ms (ratio={ratio:F1}x)");
|
||||
}
|
||||
|
||||
private static long Median(List<long> values)
|
||||
{
|
||||
values.Sort();
|
||||
var mid = values.Count / 2;
|
||||
return values.Count % 2 == 0
|
||||
? (values[mid - 1] + values[mid]) / 2
|
||||
: values[mid];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,628 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Cleanuparr.Infrastructure.Features.Auth;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Auth;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Shouldly;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Features.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for the OIDC authentication flow.
|
||||
/// Uses a mock IOidcAuthService to simulate IdP behavior.
|
||||
/// Tests are ordered to build on each other: setup → enable OIDC → test flow.
|
||||
/// </summary>
|
||||
[Collection("Auth Integration Tests")]
|
||||
[TestCaseOrderer("Cleanuparr.Api.Tests.PriorityOrderer", "Cleanuparr.Api.Tests")]
|
||||
public class OidcAuthControllerTests : IClassFixture<OidcAuthControllerTests.OidcWebApplicationFactory>
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
private readonly OidcWebApplicationFactory _factory;
|
||||
|
||||
public OidcAuthControllerTests(OidcWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
_client = factory.CreateClient(new WebApplicationFactoryClientOptions
|
||||
{
|
||||
AllowAutoRedirect = false // We want to inspect redirects
|
||||
});
|
||||
}
|
||||
|
||||
[Fact, TestPriority(0)]
|
||||
public async Task OidcStart_BeforeSetup_ReturnsBadRequest()
|
||||
{
|
||||
var response = await _client.PostAsync("/api/auth/oidc/start", null);
|
||||
|
||||
// OIDC start is on /api/auth/ path (not blocked by SetupGuardMiddleware)
|
||||
// but the controller returns BadRequest because OIDC is not configured
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(1)]
|
||||
public async Task Setup_CreateAccountAndComplete()
|
||||
{
|
||||
// Create account
|
||||
var createResponse = await _client.PostAsJsonAsync("/api/auth/setup/account", new
|
||||
{
|
||||
username = "admin",
|
||||
password = "TestPassword123!"
|
||||
});
|
||||
createResponse.StatusCode.ShouldBe(HttpStatusCode.Created);
|
||||
|
||||
// Complete setup (skip 2FA for this test suite)
|
||||
var completeResponse = await _client.PostAsJsonAsync("/api/auth/setup/complete", new { });
|
||||
completeResponse.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(2)]
|
||||
public async Task OidcStart_WhenDisabled_ReturnsBadRequest()
|
||||
{
|
||||
var response = await _client.PostAsync("/api/auth/oidc/start", null);
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
body.GetProperty("error").GetString()!.ShouldContain("OIDC is not enabled");
|
||||
}
|
||||
|
||||
[Fact, TestPriority(3)]
|
||||
public async Task OidcExchange_WhenDisabled_ReturnsNotFound()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/oidc/exchange", new
|
||||
{
|
||||
code = "some-random-code"
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.NotFound);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(4)]
|
||||
public async Task OidcCallback_WithErrorParam_RedirectsToLoginWithError()
|
||||
{
|
||||
var response = await _client.GetAsync("/api/auth/oidc/callback?error=access_denied");
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Redirect);
|
||||
var location = response.Headers.Location?.ToString();
|
||||
location.ShouldNotBeNull();
|
||||
location.ShouldContain("/auth/login");
|
||||
location.ShouldContain("oidc_error=provider_error");
|
||||
}
|
||||
|
||||
[Fact, TestPriority(5)]
|
||||
public async Task OidcCallback_WithoutCodeOrState_RedirectsToLoginWithError()
|
||||
{
|
||||
var response = await _client.GetAsync("/api/auth/oidc/callback");
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Redirect);
|
||||
var location = response.Headers.Location?.ToString();
|
||||
location.ShouldNotBeNull();
|
||||
location.ShouldContain("oidc_error=invalid_request");
|
||||
}
|
||||
|
||||
[Fact, TestPriority(6)]
|
||||
public async Task OidcCallback_WithOnlyCode_RedirectsToLoginWithError()
|
||||
{
|
||||
var response = await _client.GetAsync("/api/auth/oidc/callback?code=some-code");
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Redirect);
|
||||
var location = response.Headers.Location?.ToString();
|
||||
location.ShouldNotBeNull();
|
||||
location.ShouldContain("oidc_error=invalid_request");
|
||||
}
|
||||
|
||||
[Fact, TestPriority(7)]
|
||||
public async Task OidcCallback_WithInvalidState_RedirectsToLoginWithError()
|
||||
{
|
||||
// Even with code and state, if the state is invalid the mock will return failure
|
||||
var response = await _client.GetAsync("/api/auth/oidc/callback?code=some-code&state=invalid-state");
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Redirect);
|
||||
var location = response.Headers.Location?.ToString();
|
||||
location.ShouldNotBeNull();
|
||||
location.ShouldContain("oidc_error=authentication_failed");
|
||||
}
|
||||
|
||||
[Fact, TestPriority(8)]
|
||||
public async Task EnableOidcConfig_ViaDirectDbUpdate()
|
||||
{
|
||||
// Simulate enabling OIDC via direct DB manipulation (since we'd normally do this through settings UI)
|
||||
await _factory.EnableOidcAsync();
|
||||
|
||||
// Verify auth status reflects OIDC enabled
|
||||
var response = await _client.GetAsync("/api/auth/status");
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
body.GetProperty("oidcEnabled").GetBoolean().ShouldBeTrue();
|
||||
body.GetProperty("oidcProviderName").GetString().ShouldBe("TestProvider");
|
||||
}
|
||||
|
||||
[Fact, TestPriority(9)]
|
||||
public async Task OidcStart_WhenEnabled_ReturnsAuthorizationUrl()
|
||||
{
|
||||
var response = await _client.PostAsync("/api/auth/oidc/start", null);
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
var authUrl = body.GetProperty("authorizationUrl").GetString();
|
||||
authUrl.ShouldNotBeNullOrEmpty();
|
||||
authUrl.ShouldContain("authorize");
|
||||
}
|
||||
|
||||
[Fact, TestPriority(10)]
|
||||
public async Task OidcCallback_ValidFlow_RedirectsWithOneTimeCode()
|
||||
{
|
||||
// Use the mock's valid state to simulate a successful callback
|
||||
var response = await _client.GetAsync(
|
||||
$"/api/auth/oidc/callback?code=valid-auth-code&state={MockOidcAuthService.ValidState}");
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Redirect);
|
||||
var location = response.Headers.Location?.ToString();
|
||||
location.ShouldNotBeNull();
|
||||
location.ShouldContain("/auth/oidc/callback");
|
||||
location.ShouldContain("code=");
|
||||
// Should NOT contain oidc_error
|
||||
location.ShouldNotContain("oidc_error");
|
||||
}
|
||||
|
||||
[Fact, TestPriority(11)]
|
||||
public async Task OidcExchange_ValidOneTimeCode_ReturnsTokens()
|
||||
{
|
||||
// First, trigger a valid callback to get a one-time code
|
||||
var callbackResponse = await _client.GetAsync(
|
||||
$"/api/auth/oidc/callback?code=valid-auth-code&state={MockOidcAuthService.ValidState}");
|
||||
callbackResponse.StatusCode.ShouldBe(HttpStatusCode.Redirect);
|
||||
|
||||
var location = callbackResponse.Headers.Location?.ToString();
|
||||
location.ShouldNotBeNull();
|
||||
|
||||
// Extract the one-time code from the redirect URL
|
||||
var uri = new Uri("http://localhost" + location);
|
||||
var queryParams = System.Web.HttpUtility.ParseQueryString(uri.Query);
|
||||
var oneTimeCode = queryParams["code"];
|
||||
oneTimeCode.ShouldNotBeNullOrEmpty();
|
||||
|
||||
// Exchange the one-time code for tokens
|
||||
var exchangeResponse = await _client.PostAsJsonAsync("/api/auth/oidc/exchange", new
|
||||
{
|
||||
code = oneTimeCode
|
||||
});
|
||||
|
||||
exchangeResponse.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var body = await exchangeResponse.Content.ReadFromJsonAsync<JsonElement>();
|
||||
body.GetProperty("accessToken").GetString().ShouldNotBeNullOrEmpty();
|
||||
body.GetProperty("refreshToken").GetString().ShouldNotBeNullOrEmpty();
|
||||
body.GetProperty("expiresIn").GetInt32().ShouldBeGreaterThan(0);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(12)]
|
||||
public async Task OidcExchange_SameCodeTwice_SecondFails()
|
||||
{
|
||||
// First, trigger a valid callback
|
||||
var callbackResponse = await _client.GetAsync(
|
||||
$"/api/auth/oidc/callback?code=valid-auth-code&state={MockOidcAuthService.ValidState}");
|
||||
var location = callbackResponse.Headers.Location?.ToString()!;
|
||||
var uri = new Uri("http://localhost" + location);
|
||||
var queryParams = System.Web.HttpUtility.ParseQueryString(uri.Query);
|
||||
var oneTimeCode = queryParams["code"]!;
|
||||
|
||||
// First exchange succeeds
|
||||
var response1 = await _client.PostAsJsonAsync("/api/auth/oidc/exchange", new { code = oneTimeCode });
|
||||
response1.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
// Second exchange with same code fails
|
||||
var response2 = await _client.PostAsJsonAsync("/api/auth/oidc/exchange", new { code = oneTimeCode });
|
||||
response2.StatusCode.ShouldBe(HttpStatusCode.NotFound);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(13)]
|
||||
public async Task OidcExchange_InvalidCode_ReturnsNotFound()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/oidc/exchange", new
|
||||
{
|
||||
code = "completely-invalid-code"
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.NotFound);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(14)]
|
||||
public async Task OidcCallback_UnauthorizedSubject_RedirectsWithError()
|
||||
{
|
||||
// Use the mock's state that returns a different subject
|
||||
var response = await _client.GetAsync(
|
||||
$"/api/auth/oidc/callback?code=valid-auth-code&state={MockOidcAuthService.WrongSubjectState}");
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Redirect);
|
||||
var location = response.Headers.Location?.ToString();
|
||||
location.ShouldNotBeNull();
|
||||
location.ShouldContain("oidc_error=unauthorized");
|
||||
}
|
||||
|
||||
[Fact, TestPriority(15)]
|
||||
public async Task AuthStatus_IncludesOidcFields()
|
||||
{
|
||||
var response = await _client.GetAsync("/api/auth/status");
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
body.GetProperty("setupCompleted").GetBoolean().ShouldBeTrue();
|
||||
body.GetProperty("oidcEnabled").GetBoolean().ShouldBeTrue();
|
||||
body.GetProperty("oidcProviderName").GetString().ShouldBe("TestProvider");
|
||||
}
|
||||
|
||||
[Fact, TestPriority(16)]
|
||||
public async Task PasswordLogin_StillWorks_AfterOidcEnabled()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/login", new
|
||||
{
|
||||
username = "admin",
|
||||
password = "TestPassword123!"
|
||||
});
|
||||
|
||||
// Should succeed (no 2FA since we skipped it in setup)
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
// No 2FA, so should have tokens directly
|
||||
body.GetProperty("requiresTwoFactor").GetBoolean().ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact, TestPriority(17)]
|
||||
public async Task OidcStatus_WhenSubjectCleared_StillEnabled()
|
||||
{
|
||||
// Clearing the authorized subject should NOT disable OIDC — it just means any user can log in
|
||||
await _factory.SetOidcAuthorizedSubjectAsync("");
|
||||
|
||||
var response = await _client.GetAsync("/api/auth/status");
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
body.GetProperty("oidcEnabled").GetBoolean().ShouldBeTrue();
|
||||
|
||||
// Restore for subsequent tests
|
||||
await _factory.SetOidcAuthorizedSubjectAsync(MockOidcAuthService.AuthorizedSubject);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(17)]
|
||||
public async Task OidcStatus_WhenMissingIssuerUrl_ReturnsFalse()
|
||||
{
|
||||
// OIDC should be disabled when essential config (IssuerUrl) is missing
|
||||
await _factory.SetOidcIssuerUrlAsync("");
|
||||
|
||||
var response = await _client.GetAsync("/api/auth/status");
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
body.GetProperty("oidcEnabled").GetBoolean().ShouldBeFalse();
|
||||
|
||||
// Restore for subsequent tests
|
||||
await _factory.SetOidcIssuerUrlAsync("https://mock-oidc-provider.test");
|
||||
}
|
||||
|
||||
[Fact, TestPriority(17)]
|
||||
public async Task OidcCallback_WithoutLinkedSubject_AllowsAnyUser()
|
||||
{
|
||||
// Clear the authorized subject — any OIDC user should be allowed
|
||||
await _factory.SetOidcAuthorizedSubjectAsync("");
|
||||
|
||||
// Use the "wrong subject" state — this returns a different subject than the authorized one
|
||||
// With no linked subject, it should still succeed
|
||||
var response = await _client.GetAsync(
|
||||
$"/api/auth/oidc/callback?code=valid-auth-code&state={MockOidcAuthService.WrongSubjectState}");
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Redirect);
|
||||
var location = response.Headers.Location?.ToString();
|
||||
location.ShouldNotBeNull();
|
||||
location.ShouldContain("code=");
|
||||
location.ShouldNotContain("oidc_error");
|
||||
|
||||
// Restore for subsequent tests
|
||||
await _factory.SetOidcAuthorizedSubjectAsync(MockOidcAuthService.AuthorizedSubject);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(18)]
|
||||
public async Task OidcExchange_RandomCode_ReturnsNotFound()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/oidc/exchange", new
|
||||
{
|
||||
code = "completely-random-nonexistent-code"
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.NotFound);
|
||||
}
|
||||
|
||||
#region Exclusive Mode
|
||||
|
||||
[Fact, TestPriority(19)]
|
||||
public async Task EnableExclusiveMode_AuthStatusReflectsIt()
|
||||
{
|
||||
await _factory.SetOidcExclusiveModeAsync(true);
|
||||
|
||||
var response = await _client.GetAsync("/api/auth/status");
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
body.GetProperty("oidcExclusiveMode").GetBoolean().ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact, TestPriority(20)]
|
||||
public async Task PasswordLogin_Blocked_WhenExclusiveModeActive()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/login", new
|
||||
{
|
||||
username = "admin",
|
||||
password = "TestPassword123!"
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Forbidden);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(21)]
|
||||
public async Task TwoFactorLogin_Blocked_WhenExclusiveModeActive()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/login/2fa", new
|
||||
{
|
||||
loginToken = "some-token",
|
||||
code = "123456"
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Forbidden);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(22)]
|
||||
public async Task PlexLoginPin_Blocked_WhenExclusiveModeActive()
|
||||
{
|
||||
var response = await _client.PostAsync("/api/auth/login/plex/pin", null);
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Forbidden);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(23)]
|
||||
public async Task PlexLoginVerify_Blocked_WhenExclusiveModeActive()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/login/plex/verify", new
|
||||
{
|
||||
pinId = 12345
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Forbidden);
|
||||
}
|
||||
|
||||
[Fact, TestPriority(24)]
|
||||
public async Task OidcStart_StillWorks_WhenExclusiveModeActive()
|
||||
{
|
||||
var response = await _client.PostAsync("/api/auth/oidc/start", null);
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
body.GetProperty("authorizationUrl").GetString().ShouldNotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact, TestPriority(25)]
|
||||
public async Task OidcCallback_StillWorks_WhenExclusiveModeActive()
|
||||
{
|
||||
var response = await _client.GetAsync(
|
||||
$"/api/auth/oidc/callback?code=valid-auth-code&state={MockOidcAuthService.ValidState}");
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Redirect);
|
||||
var location = response.Headers.Location?.ToString();
|
||||
location.ShouldNotBeNull();
|
||||
location.ShouldContain("code=");
|
||||
location.ShouldNotContain("oidc_error");
|
||||
}
|
||||
|
||||
[Fact, TestPriority(26)]
|
||||
public async Task DisableExclusiveMode_PasswordLoginWorks_Again()
|
||||
{
|
||||
await _factory.SetOidcExclusiveModeAsync(false);
|
||||
|
||||
var response = await _client.PostAsJsonAsync("/api/auth/login", new
|
||||
{
|
||||
username = "admin",
|
||||
password = "TestPassword123!"
|
||||
});
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Test Infrastructure
|
||||
|
||||
/// <summary>
|
||||
/// Custom factory that replaces IOidcAuthService with a mock for testing.
|
||||
/// </summary>
|
||||
public class OidcWebApplicationFactory : WebApplicationFactory<Program>
|
||||
{
|
||||
private readonly string _tempDir;
|
||||
|
||||
public OidcWebApplicationFactory()
|
||||
{
|
||||
_tempDir = Path.Combine(Path.GetTempPath(), $"cleanuparr-oidc-test-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_tempDir);
|
||||
|
||||
// Redirect all database contexts to this factory's temp directory.
|
||||
ConfigurationPathProvider.SetConfigPath(_tempDir);
|
||||
}
|
||||
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
builder.UseEnvironment("Testing");
|
||||
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
// Replace IOidcAuthService with mock
|
||||
var oidcDescriptor = services.SingleOrDefault(d => d.ServiceType == typeof(IOidcAuthService));
|
||||
if (oidcDescriptor != null) services.Remove(oidcDescriptor);
|
||||
|
||||
services.AddSingleton<IOidcAuthService, MockOidcAuthService>();
|
||||
|
||||
// Remove all hosted services (Quartz scheduler, BackgroundJobManager) to prevent
|
||||
// Quartz.Logging.LogProvider.ResolvedLogProvider (a cached Lazy<T>) from being accessed
|
||||
// with a disposed ILoggerFactory from the previous factory lifecycle.
|
||||
// Auth tests don't depend on background job scheduling, so this is safe.
|
||||
foreach (var hostedService in services.Where(d => d.ServiceType == typeof(IHostedService)).ToList())
|
||||
services.Remove(hostedService);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enables OIDC on the user in the UsersContext database.
|
||||
/// </summary>
|
||||
public async Task EnableOidcAsync()
|
||||
{
|
||||
using var scope = Services.CreateScope();
|
||||
var usersContext = scope.ServiceProvider.GetRequiredService<UsersContext>();
|
||||
|
||||
var user = await usersContext.Users.FirstOrDefaultAsync();
|
||||
if (user is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
user.Oidc = new OidcConfig
|
||||
{
|
||||
Enabled = true,
|
||||
IssuerUrl = "https://mock-oidc-provider.test",
|
||||
ClientId = "test-client",
|
||||
ClientSecret = "test-secret",
|
||||
Scopes = "openid profile email",
|
||||
AuthorizedSubject = MockOidcAuthService.AuthorizedSubject,
|
||||
ProviderName = "TestProvider"
|
||||
};
|
||||
|
||||
await usersContext.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task SetOidcIssuerUrlAsync(string issuerUrl)
|
||||
{
|
||||
using var scope = Services.CreateScope();
|
||||
var usersContext = scope.ServiceProvider.GetRequiredService<UsersContext>();
|
||||
|
||||
var user = await usersContext.Users.FirstOrDefaultAsync();
|
||||
if (user is not null)
|
||||
{
|
||||
user.Oidc.IssuerUrl = issuerUrl;
|
||||
await usersContext.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SetOidcAuthorizedSubjectAsync(string subject)
|
||||
{
|
||||
using var scope = Services.CreateScope();
|
||||
var usersContext = scope.ServiceProvider.GetRequiredService<UsersContext>();
|
||||
|
||||
var user = await usersContext.Users.FirstOrDefaultAsync();
|
||||
if (user is not null)
|
||||
{
|
||||
user.Oidc.AuthorizedSubject = subject;
|
||||
await usersContext.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SetOidcExclusiveModeAsync(bool enabled)
|
||||
{
|
||||
using var scope = Services.CreateScope();
|
||||
var usersContext = scope.ServiceProvider.GetRequiredService<UsersContext>();
|
||||
|
||||
var user = await usersContext.Users.FirstOrDefaultAsync();
|
||||
if (user is not null)
|
||||
{
|
||||
user.Oidc.ExclusiveMode = enabled;
|
||||
await usersContext.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
base.Dispose(disposing);
|
||||
|
||||
if (disposing && Directory.Exists(_tempDir))
|
||||
{
|
||||
try { Directory.Delete(_tempDir, true); } catch { /* best effort */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mock OIDC auth service that simulates IdP behavior without network calls.
|
||||
/// </summary>
|
||||
private sealed class MockOidcAuthService : IOidcAuthService
|
||||
{
|
||||
public const string ValidState = "mock-valid-state";
|
||||
public const string WrongSubjectState = "mock-wrong-subject-state";
|
||||
public const string AuthorizedSubject = "mock-authorized-subject-123";
|
||||
|
||||
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, OidcTokenExchangeResult> _oneTimeCodes = new();
|
||||
|
||||
public Task<OidcAuthorizationResult> StartAuthorization(string redirectUri, string? initiatorUserId = null)
|
||||
{
|
||||
return Task.FromResult(new OidcAuthorizationResult
|
||||
{
|
||||
AuthorizationUrl = $"https://mock-oidc-provider.test/authorize?redirect_uri={Uri.EscapeDataString(redirectUri)}&state={ValidState}",
|
||||
State = ValidState
|
||||
});
|
||||
}
|
||||
|
||||
public Task<OidcCallbackResult> HandleCallback(string code, string state, string redirectUri)
|
||||
{
|
||||
if (state == ValidState)
|
||||
{
|
||||
return Task.FromResult(new OidcCallbackResult
|
||||
{
|
||||
Success = true,
|
||||
Subject = AuthorizedSubject,
|
||||
PreferredUsername = "testuser",
|
||||
Email = "testuser@example.com"
|
||||
});
|
||||
}
|
||||
|
||||
if (state == WrongSubjectState)
|
||||
{
|
||||
return Task.FromResult(new OidcCallbackResult
|
||||
{
|
||||
Success = true,
|
||||
Subject = "wrong-subject-that-doesnt-match",
|
||||
PreferredUsername = "wronguser",
|
||||
Email = "wrong@example.com"
|
||||
});
|
||||
}
|
||||
|
||||
return Task.FromResult(new OidcCallbackResult
|
||||
{
|
||||
Success = false,
|
||||
Error = "Invalid or expired OIDC state"
|
||||
});
|
||||
}
|
||||
|
||||
public string StoreOneTimeCode(string accessToken, string refreshToken, int expiresIn)
|
||||
{
|
||||
var code = Guid.NewGuid().ToString("N");
|
||||
_oneTimeCodes.TryAdd(code, new OidcTokenExchangeResult
|
||||
{
|
||||
AccessToken = accessToken,
|
||||
RefreshToken = refreshToken,
|
||||
ExpiresIn = expiresIn
|
||||
});
|
||||
return code;
|
||||
}
|
||||
|
||||
public OidcTokenExchangeResult? ExchangeOneTimeCode(string code)
|
||||
{
|
||||
return _oneTimeCodes.TryRemove(code, out var result) ? result : null;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using Cleanuparr.Infrastructure.Features.Auth;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Features.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Factory variant that replaces <see cref="IPasswordService"/> with a
|
||||
/// <see cref="TrackingPasswordService"/> spy so tests can assert that
|
||||
/// password verification is always called regardless of username validity.
|
||||
/// </summary>
|
||||
public class TimingTestWebApplicationFactory : CustomWebApplicationFactory
|
||||
{
|
||||
public TrackingPasswordService TrackingPasswordService { get; } = new();
|
||||
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
base.ConfigureWebHost(builder);
|
||||
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
// Replace IPasswordService with our tracking spy
|
||||
var descriptor = services.SingleOrDefault(d => d.ServiceType == typeof(IPasswordService));
|
||||
if (descriptor != null) services.Remove(descriptor);
|
||||
|
||||
services.AddSingleton<IPasswordService>(TrackingPasswordService);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using Cleanuparr.Infrastructure.Features.Auth;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Features.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Spy wrapper around <see cref="PasswordService"/> that tracks calls to
|
||||
/// <see cref="VerifyPassword"/> for behavioral assertions in timing tests.
|
||||
/// </summary>
|
||||
public sealed class TrackingPasswordService : IPasswordService
|
||||
{
|
||||
private readonly PasswordService _inner = new();
|
||||
private int _verifyPasswordCallCount;
|
||||
|
||||
public int VerifyPasswordCallCount => _verifyPasswordCallCount;
|
||||
|
||||
public string DummyHash => _inner.DummyHash;
|
||||
|
||||
public string HashPassword(string password)
|
||||
{
|
||||
return _inner.HashPassword(password);
|
||||
}
|
||||
|
||||
public bool VerifyPassword(string password, string hash)
|
||||
{
|
||||
Interlocked.Increment(ref _verifyPasswordCallCount);
|
||||
return _inner.VerifyPassword(password, hash);
|
||||
}
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
Interlocked.Exchange(ref _verifyPasswordCallCount, 0);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
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;
|
||||
@@ -314,4 +317,65 @@ public class SensitiveDataInputTests
|
||||
}
|
||||
|
||||
#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
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace Cleanuparr.Api.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Auth integration tests share the file-system config directory (users.db via
|
||||
/// SetupGuardMiddleware.CreateStaticInstance). Grouping them in one collection
|
||||
/// forces sequential execution and prevents inter-factory interference.
|
||||
/// </summary>
|
||||
[CollectionDefinition("Auth Integration Tests")]
|
||||
public class AuthIntegrationTestsCollection { }
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
|
||||
"parallelizeAssembly": false,
|
||||
"parallelizeTestCollections": false
|
||||
}
|
||||
@@ -77,6 +77,7 @@ public class StrikesController : ControllerBase
|
||||
IsMarkedForRemoval = d.IsMarkedForRemoval,
|
||||
IsRemoved = d.IsRemoved,
|
||||
IsReturning = d.IsReturning,
|
||||
HasDryRunStrikes = d.Strikes.Any(s => s.IsDryRun),
|
||||
Strikes = d.Strikes
|
||||
.OrderByDescending(s => s.CreatedAt)
|
||||
.Select(s => new StrikeDetailDto
|
||||
@@ -86,6 +87,7 @@ public class StrikesController : ControllerBase
|
||||
CreatedAt = s.CreatedAt,
|
||||
LastDownloadedBytes = s.LastDownloadedBytes,
|
||||
JobRunId = s.JobRunId,
|
||||
IsDryRun = s.IsDryRun,
|
||||
}).ToList(),
|
||||
}).ToList();
|
||||
|
||||
@@ -120,6 +122,7 @@ public class StrikesController : ControllerBase
|
||||
CreatedAt = s.CreatedAt,
|
||||
DownloadId = s.DownloadItem.DownloadId,
|
||||
Title = s.DownloadItem.Title,
|
||||
IsDryRun = s.IsDryRun,
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
@@ -169,6 +172,7 @@ public class DownloadItemStrikesDto
|
||||
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; } = [];
|
||||
}
|
||||
|
||||
@@ -179,6 +183,7 @@ public class StrikeDetailDto
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public long? LastDownloadedBytes { get; set; }
|
||||
public Guid JobRunId { get; set; }
|
||||
public bool IsDryRun { get; set; }
|
||||
}
|
||||
|
||||
public class RecentStrikeDto
|
||||
@@ -188,4 +193,5 @@ public class RecentStrikeDto
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public string DownloadId { get; set; } = string.Empty;
|
||||
public string Title { get; set; } = string.Empty;
|
||||
public bool IsDryRun { get; set; }
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Text.Json.Serialization.Metadata;
|
||||
using Cleanuparr.Api.Filters;
|
||||
using Cleanuparr.Api.Json;
|
||||
using Cleanuparr.Infrastructure.Health;
|
||||
using Cleanuparr.Infrastructure.Hubs;
|
||||
@@ -64,10 +65,10 @@ public static class ApiDI
|
||||
// Enable compression
|
||||
app.UseResponseCompression();
|
||||
|
||||
// Serve static files with caching
|
||||
// Serve static files without caching
|
||||
app.UseStaticFiles(new StaticFileOptions
|
||||
{
|
||||
OnPrepareResponse = _ => {}
|
||||
OnPrepareResponse = ctx => NoCacheAttribute.Apply(ctx.Context.Response.Headers)
|
||||
});
|
||||
|
||||
// Add the global exception handling middleware first
|
||||
@@ -119,6 +120,7 @@ public static class ApiDI
|
||||
);
|
||||
|
||||
context.Response.ContentType = "text/html";
|
||||
NoCacheAttribute.Apply(context.Response.Headers);
|
||||
await context.Response.WriteAsync(indexContent, Encoding.UTF8);
|
||||
}).AllowAnonymous();
|
||||
|
||||
|
||||
@@ -94,6 +94,9 @@ public static class MainDI
|
||||
// Add HTTP client for Plex authentication
|
||||
services.AddHttpClient("PlexAuth");
|
||||
|
||||
// Add HTTP client for OIDC authentication
|
||||
services.AddHttpClient("OidcAuth");
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ public static class ServicesDI
|
||||
.AddSingleton<IPasswordService, PasswordService>()
|
||||
.AddSingleton<ITotpService, TotpService>()
|
||||
.AddScoped<IPlexAuthService, PlexAuthService>()
|
||||
.AddScoped<IOidcAuthService, OidcAuthService>()
|
||||
.AddScoped<IEventPublisher, EventPublisher>()
|
||||
.AddHostedService<EventCleanupService>()
|
||||
.AddScoped<IDryRunInterceptor, DryRunInterceptor>()
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
using Cleanuparr.Infrastructure.Extensions;
|
||||
|
||||
namespace Cleanuparr.Api.Extensions;
|
||||
|
||||
public static class HttpRequestExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns the request PathBase as a safe relative path.
|
||||
/// Rejects absolute URLs (e.g. "://" or "//") to prevent open redirect attacks.
|
||||
/// </summary>
|
||||
public static string GetSafeBasePath(this HttpRequest request)
|
||||
{
|
||||
var basePath = request.PathBase.Value?.TrimEnd('/') ?? "";
|
||||
if (basePath.Contains("://") || basePath.StartsWith("//"))
|
||||
{
|
||||
return "";
|
||||
}
|
||||
return basePath;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the external base URL (scheme + host + basePath), respecting
|
||||
/// X-Forwarded-Proto and X-Forwarded-Host headers when the connection
|
||||
/// originates from a local address.
|
||||
/// </summary>
|
||||
public static string GetExternalBaseUrl(this HttpContext context)
|
||||
{
|
||||
var request = context.Request;
|
||||
var scheme = request.Scheme;
|
||||
var host = request.Host.ToString();
|
||||
var remoteIp = context.Connection.RemoteIpAddress;
|
||||
|
||||
// Trust forwarded headers only from local connections
|
||||
// (consistent with TrustedNetworkAuthenticationHandler)
|
||||
if (remoteIp is not null && remoteIp.IsLocalAddress())
|
||||
{
|
||||
scheme = request.Headers["X-Forwarded-Proto"].FirstOrDefault() ?? scheme;
|
||||
host = request.Headers["X-Forwarded-Host"].FirstOrDefault() ?? host;
|
||||
}
|
||||
|
||||
var basePath = request.GetSafeBasePath();
|
||||
return $"{scheme}://{host}{basePath}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Auth.Contracts.Requests;
|
||||
|
||||
public sealed record OidcExchangeRequest
|
||||
{
|
||||
[Required]
|
||||
public required string Code { get; init; }
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
using Cleanuparr.Infrastructure.Features.Auth;
|
||||
using Cleanuparr.Persistence.Models.Auth;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Auth.Contracts.Requests;
|
||||
|
||||
public sealed record UpdateOidcConfigRequest
|
||||
{
|
||||
public bool Enabled { get; init; }
|
||||
|
||||
public string IssuerUrl { get; init; } = string.Empty;
|
||||
|
||||
public string ClientId { get; init; } = string.Empty;
|
||||
|
||||
public string ClientSecret { get; init; } = string.Empty;
|
||||
|
||||
public string Scopes { get; init; } = "openid profile email";
|
||||
|
||||
public string ProviderName { get; init; } = "OIDC";
|
||||
|
||||
public string RedirectUrl { get; init; } = string.Empty;
|
||||
|
||||
public bool ExclusiveMode { get; init; }
|
||||
|
||||
public void ApplyTo(OidcConfig existingConfig)
|
||||
{
|
||||
var previousIssuerUrl = existingConfig.IssuerUrl;
|
||||
|
||||
existingConfig.Enabled = Enabled;
|
||||
existingConfig.IssuerUrl = IssuerUrl;
|
||||
existingConfig.ClientId = ClientId;
|
||||
existingConfig.Scopes = Scopes;
|
||||
existingConfig.ProviderName = ProviderName;
|
||||
existingConfig.RedirectUrl = RedirectUrl;
|
||||
existingConfig.ExclusiveMode = ExclusiveMode;
|
||||
|
||||
if (!ClientSecret.IsPlaceholder())
|
||||
{
|
||||
existingConfig.ClientSecret = ClientSecret;
|
||||
}
|
||||
|
||||
// AuthorizedSubject is intentionally NOT mapped here — it is set only via the OIDC link callback
|
||||
|
||||
if (previousIssuerUrl != IssuerUrl)
|
||||
{
|
||||
OidcAuthService.ClearDiscoveryCache();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,4 +5,7 @@ public sealed record AuthStatusResponse
|
||||
public required bool SetupCompleted { get; init; }
|
||||
public bool PlexLinked { get; init; }
|
||||
public bool AuthBypassActive { get; init; }
|
||||
public bool OidcEnabled { get; init; }
|
||||
public string OidcProviderName { get; init; } = string.Empty;
|
||||
public bool OidcExclusiveMode { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace Cleanuparr.Api.Features.Auth.Contracts.Responses;
|
||||
|
||||
public sealed record OidcStartResponse
|
||||
{
|
||||
public required string AuthorizationUrl { get; init; }
|
||||
}
|
||||
@@ -1,25 +1,30 @@
|
||||
using System.Security.Claims;
|
||||
using System.Security.Cryptography;
|
||||
using Cleanuparr.Api.Extensions;
|
||||
using Cleanuparr.Api.Features.Auth.Contracts.Requests;
|
||||
using Cleanuparr.Api.Features.Auth.Contracts.Responses;
|
||||
using Cleanuparr.Api.Filters;
|
||||
using Cleanuparr.Infrastructure.Features.Auth;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Auth;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Auth.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/account")]
|
||||
[Authorize]
|
||||
[NoCache]
|
||||
public sealed class AccountController : ControllerBase
|
||||
{
|
||||
private readonly UsersContext _usersContext;
|
||||
private readonly IPasswordService _passwordService;
|
||||
private readonly ITotpService _totpService;
|
||||
private readonly IPlexAuthService _plexAuthService;
|
||||
private readonly IOidcAuthService _oidcAuthService;
|
||||
private readonly ILogger<AccountController> _logger;
|
||||
|
||||
public AccountController(
|
||||
@@ -27,12 +32,14 @@ public sealed class AccountController : ControllerBase
|
||||
IPasswordService passwordService,
|
||||
ITotpService totpService,
|
||||
IPlexAuthService plexAuthService,
|
||||
IOidcAuthService oidcAuthService,
|
||||
ILogger<AccountController> logger)
|
||||
{
|
||||
_usersContext = usersContext;
|
||||
_passwordService = passwordService;
|
||||
_totpService = totpService;
|
||||
_plexAuthService = plexAuthService;
|
||||
_oidcAuthService = oidcAuthService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -40,7 +47,10 @@ public sealed class AccountController : ControllerBase
|
||||
public async Task<IActionResult> GetAccountInfo()
|
||||
{
|
||||
var user = await GetCurrentUser();
|
||||
if (user is null) return Unauthorized();
|
||||
if (user is null)
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
return Ok(new AccountInfoResponse
|
||||
{
|
||||
@@ -55,247 +65,230 @@ public sealed class AccountController : ControllerBase
|
||||
[HttpPut("password")]
|
||||
public async Task<IActionResult> ChangePassword([FromBody] ChangePasswordRequest request)
|
||||
{
|
||||
await UsersContext.Lock.WaitAsync();
|
||||
try
|
||||
if (await IsOidcExclusiveModeActive())
|
||||
{
|
||||
var user = await GetCurrentUser();
|
||||
if (user is null) return Unauthorized();
|
||||
|
||||
if (!_passwordService.VerifyPassword(request.CurrentPassword, user.PasswordHash))
|
||||
{
|
||||
return BadRequest(new { error = "Current password is incorrect" });
|
||||
}
|
||||
|
||||
DateTime now = DateTime.UtcNow;
|
||||
|
||||
user.PasswordHash = _passwordService.HashPassword(request.NewPassword);
|
||||
user.UpdatedAt = now;
|
||||
|
||||
// Revoke all existing refresh tokens so old sessions can't be reused
|
||||
var activeTokens = await _usersContext.RefreshTokens
|
||||
.Where(r => r.UserId == user.Id && r.RevokedAt == null)
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var token in activeTokens)
|
||||
{
|
||||
token.RevokedAt = now;
|
||||
}
|
||||
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Password changed for user {Username}", user.Username);
|
||||
|
||||
return Ok(new { message = "Password changed" });
|
||||
return StatusCode(403, new { error = "Password changes are disabled while OIDC exclusive mode is active." });
|
||||
}
|
||||
finally
|
||||
|
||||
var user = await GetCurrentUser();
|
||||
if (user is null)
|
||||
{
|
||||
UsersContext.Lock.Release();
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
if (!_passwordService.VerifyPassword(request.CurrentPassword, user.PasswordHash))
|
||||
{
|
||||
return BadRequest(new { error = "Current password is incorrect" });
|
||||
}
|
||||
|
||||
DateTime now = DateTime.UtcNow;
|
||||
|
||||
user.PasswordHash = _passwordService.HashPassword(request.NewPassword);
|
||||
user.UpdatedAt = now;
|
||||
|
||||
// Revoke all existing refresh tokens so old sessions can't be reused
|
||||
var activeTokens = await _usersContext.RefreshTokens
|
||||
.Where(r => r.UserId == user.Id && r.RevokedAt == null)
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var token in activeTokens)
|
||||
{
|
||||
token.RevokedAt = now;
|
||||
}
|
||||
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Password changed for user {Username}", user.Username);
|
||||
|
||||
return Ok(new { message = "Password changed" });
|
||||
}
|
||||
|
||||
[HttpPost("2fa/regenerate")]
|
||||
public async Task<IActionResult> Regenerate2fa([FromBody] Regenerate2faRequest request)
|
||||
{
|
||||
await UsersContext.Lock.WaitAsync();
|
||||
try
|
||||
var user = await GetCurrentUser(includeRecoveryCodes: true);
|
||||
if (user is null)
|
||||
{
|
||||
var user = await GetCurrentUser(includeRecoveryCodes: true);
|
||||
if (user is null) return Unauthorized();
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
// Verify current credentials
|
||||
if (!_passwordService.VerifyPassword(request.Password, user.PasswordHash))
|
||||
// Verify current credentials
|
||||
if (!_passwordService.VerifyPassword(request.Password, user.PasswordHash))
|
||||
{
|
||||
return BadRequest(new { error = "Incorrect password" });
|
||||
}
|
||||
|
||||
if (!_totpService.ValidateCode(user.TotpSecret, request.TotpCode))
|
||||
{
|
||||
return BadRequest(new { error = "Invalid 2FA code" });
|
||||
}
|
||||
|
||||
// Generate new TOTP
|
||||
var secret = _totpService.GenerateSecret();
|
||||
var qrUri = _totpService.GetQrCodeUri(secret, user.Username);
|
||||
var recoveryCodes = _totpService.GenerateRecoveryCodes();
|
||||
|
||||
user.TotpSecret = secret;
|
||||
user.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
// Replace recovery codes
|
||||
_usersContext.RecoveryCodes.RemoveRange(user.RecoveryCodes);
|
||||
|
||||
foreach (var code in recoveryCodes)
|
||||
{
|
||||
_usersContext.RecoveryCodes.Add(new RecoveryCode
|
||||
{
|
||||
return BadRequest(new { error = "Incorrect password" });
|
||||
}
|
||||
|
||||
if (!_totpService.ValidateCode(user.TotpSecret, request.TotpCode))
|
||||
{
|
||||
return BadRequest(new { error = "Invalid 2FA code" });
|
||||
}
|
||||
|
||||
// Generate new TOTP
|
||||
var secret = _totpService.GenerateSecret();
|
||||
var qrUri = _totpService.GetQrCodeUri(secret, user.Username);
|
||||
var recoveryCodes = _totpService.GenerateRecoveryCodes();
|
||||
|
||||
user.TotpSecret = secret;
|
||||
user.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
// Replace recovery codes
|
||||
_usersContext.RecoveryCodes.RemoveRange(user.RecoveryCodes);
|
||||
|
||||
foreach (var code in recoveryCodes)
|
||||
{
|
||||
_usersContext.RecoveryCodes.Add(new RecoveryCode
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = user.Id,
|
||||
CodeHash = _totpService.HashRecoveryCode(code),
|
||||
IsUsed = false
|
||||
});
|
||||
}
|
||||
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("2FA regenerated for user {Username}", user.Username);
|
||||
|
||||
return Ok(new TotpSetupResponse
|
||||
{
|
||||
Secret = secret,
|
||||
QrCodeUri = qrUri,
|
||||
RecoveryCodes = recoveryCodes
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = user.Id,
|
||||
CodeHash = _totpService.HashRecoveryCode(code),
|
||||
IsUsed = false
|
||||
});
|
||||
}
|
||||
finally
|
||||
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("2FA regenerated for user {Username}", user.Username);
|
||||
|
||||
return Ok(new TotpSetupResponse
|
||||
{
|
||||
UsersContext.Lock.Release();
|
||||
}
|
||||
Secret = secret,
|
||||
QrCodeUri = qrUri,
|
||||
RecoveryCodes = recoveryCodes
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("2fa/enable")]
|
||||
public async Task<IActionResult> Enable2fa([FromBody] Enable2faRequest request)
|
||||
{
|
||||
await UsersContext.Lock.WaitAsync();
|
||||
try
|
||||
var user = await GetCurrentUser(includeRecoveryCodes: true);
|
||||
if (user is null)
|
||||
{
|
||||
var user = await GetCurrentUser(includeRecoveryCodes: true);
|
||||
if (user is null) return Unauthorized();
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
if (user.TotpEnabled)
|
||||
if (user.TotpEnabled)
|
||||
{
|
||||
return Conflict(new { error = "2FA is already enabled" });
|
||||
}
|
||||
|
||||
if (!_passwordService.VerifyPassword(request.Password, user.PasswordHash))
|
||||
{
|
||||
return BadRequest(new { error = "Incorrect password" });
|
||||
}
|
||||
|
||||
// Generate new TOTP
|
||||
var secret = _totpService.GenerateSecret();
|
||||
var qrUri = _totpService.GetQrCodeUri(secret, user.Username);
|
||||
var recoveryCodes = _totpService.GenerateRecoveryCodes();
|
||||
|
||||
user.TotpSecret = secret;
|
||||
user.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
// Replace any existing recovery codes
|
||||
_usersContext.RecoveryCodes.RemoveRange(user.RecoveryCodes);
|
||||
|
||||
foreach (var code in recoveryCodes)
|
||||
{
|
||||
_usersContext.RecoveryCodes.Add(new RecoveryCode
|
||||
{
|
||||
return Conflict(new { error = "2FA is already enabled" });
|
||||
}
|
||||
|
||||
if (!_passwordService.VerifyPassword(request.Password, user.PasswordHash))
|
||||
{
|
||||
return BadRequest(new { error = "Incorrect password" });
|
||||
}
|
||||
|
||||
// Generate new TOTP
|
||||
var secret = _totpService.GenerateSecret();
|
||||
var qrUri = _totpService.GetQrCodeUri(secret, user.Username);
|
||||
var recoveryCodes = _totpService.GenerateRecoveryCodes();
|
||||
|
||||
user.TotpSecret = secret;
|
||||
user.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
// Replace any existing recovery codes
|
||||
_usersContext.RecoveryCodes.RemoveRange(user.RecoveryCodes);
|
||||
|
||||
foreach (var code in recoveryCodes)
|
||||
{
|
||||
_usersContext.RecoveryCodes.Add(new RecoveryCode
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = user.Id,
|
||||
CodeHash = _totpService.HashRecoveryCode(code),
|
||||
IsUsed = false
|
||||
});
|
||||
}
|
||||
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("2FA setup generated for user {Username}", user.Username);
|
||||
|
||||
return Ok(new TotpSetupResponse
|
||||
{
|
||||
Secret = secret,
|
||||
QrCodeUri = qrUri,
|
||||
RecoveryCodes = recoveryCodes
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = user.Id,
|
||||
CodeHash = _totpService.HashRecoveryCode(code),
|
||||
IsUsed = false
|
||||
});
|
||||
}
|
||||
finally
|
||||
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("2FA setup generated for user {Username}", user.Username);
|
||||
|
||||
return Ok(new TotpSetupResponse
|
||||
{
|
||||
UsersContext.Lock.Release();
|
||||
}
|
||||
Secret = secret,
|
||||
QrCodeUri = qrUri,
|
||||
RecoveryCodes = recoveryCodes
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("2fa/enable/verify")]
|
||||
public async Task<IActionResult> VerifyEnable2fa([FromBody] VerifyTotpRequest request)
|
||||
{
|
||||
await UsersContext.Lock.WaitAsync();
|
||||
try
|
||||
var user = await GetCurrentUser();
|
||||
if (user is null)
|
||||
{
|
||||
var user = await GetCurrentUser();
|
||||
if (user is null) return Unauthorized();
|
||||
|
||||
if (user.TotpEnabled)
|
||||
{
|
||||
return Conflict(new { error = "2FA is already enabled" });
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(user.TotpSecret))
|
||||
{
|
||||
return BadRequest(new { error = "Generate 2FA setup first" });
|
||||
}
|
||||
|
||||
if (!_totpService.ValidateCode(user.TotpSecret, request.Code))
|
||||
{
|
||||
return BadRequest(new { error = "Invalid verification code" });
|
||||
}
|
||||
|
||||
user.TotpEnabled = true;
|
||||
user.UpdatedAt = DateTime.UtcNow;
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("2FA enabled for user {Username}", user.Username);
|
||||
|
||||
return Ok(new { message = "2FA enabled" });
|
||||
return Unauthorized();
|
||||
}
|
||||
finally
|
||||
|
||||
if (user.TotpEnabled)
|
||||
{
|
||||
UsersContext.Lock.Release();
|
||||
return Conflict(new { error = "2FA is already enabled" });
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(user.TotpSecret))
|
||||
{
|
||||
return BadRequest(new { error = "Generate 2FA setup first" });
|
||||
}
|
||||
|
||||
if (!_totpService.ValidateCode(user.TotpSecret, request.Code))
|
||||
{
|
||||
return BadRequest(new { error = "Invalid verification code" });
|
||||
}
|
||||
|
||||
user.TotpEnabled = true;
|
||||
user.UpdatedAt = DateTime.UtcNow;
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("2FA enabled for user {Username}", user.Username);
|
||||
|
||||
return Ok(new { message = "2FA enabled" });
|
||||
}
|
||||
|
||||
[HttpPost("2fa/disable")]
|
||||
public async Task<IActionResult> Disable2fa([FromBody] Disable2faRequest request)
|
||||
{
|
||||
await UsersContext.Lock.WaitAsync();
|
||||
try
|
||||
var user = await GetCurrentUser(includeRecoveryCodes: true);
|
||||
if (user is null)
|
||||
{
|
||||
var user = await GetCurrentUser(includeRecoveryCodes: true);
|
||||
if (user is null) return Unauthorized();
|
||||
|
||||
if (!user.TotpEnabled)
|
||||
{
|
||||
return BadRequest(new { error = "2FA is not enabled" });
|
||||
}
|
||||
|
||||
if (!_passwordService.VerifyPassword(request.Password, user.PasswordHash))
|
||||
{
|
||||
return BadRequest(new { error = "Incorrect password" });
|
||||
}
|
||||
|
||||
if (!_totpService.ValidateCode(user.TotpSecret, request.TotpCode))
|
||||
{
|
||||
return BadRequest(new { error = "Invalid 2FA code" });
|
||||
}
|
||||
|
||||
user.TotpEnabled = false;
|
||||
user.TotpSecret = string.Empty;
|
||||
user.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
// Remove all recovery codes
|
||||
_usersContext.RecoveryCodes.RemoveRange(user.RecoveryCodes);
|
||||
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("2FA disabled for user {Username}", user.Username);
|
||||
|
||||
return Ok(new { message = "2FA disabled" });
|
||||
return Unauthorized();
|
||||
}
|
||||
finally
|
||||
|
||||
if (!user.TotpEnabled)
|
||||
{
|
||||
UsersContext.Lock.Release();
|
||||
return BadRequest(new { error = "2FA is not enabled" });
|
||||
}
|
||||
|
||||
if (!_passwordService.VerifyPassword(request.Password, user.PasswordHash))
|
||||
{
|
||||
return BadRequest(new { error = "Incorrect password" });
|
||||
}
|
||||
|
||||
if (!_totpService.ValidateCode(user.TotpSecret, request.TotpCode))
|
||||
{
|
||||
return BadRequest(new { error = "Invalid 2FA code" });
|
||||
}
|
||||
|
||||
user.TotpEnabled = false;
|
||||
user.TotpSecret = string.Empty;
|
||||
user.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
// Remove all recovery codes
|
||||
_usersContext.RecoveryCodes.RemoveRange(user.RecoveryCodes);
|
||||
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("2FA disabled for user {Username}", user.Username);
|
||||
|
||||
return Ok(new { message = "2FA disabled" });
|
||||
}
|
||||
|
||||
[HttpGet("api-key")]
|
||||
public async Task<IActionResult> GetApiKey()
|
||||
{
|
||||
var user = await GetCurrentUser();
|
||||
if (user is null) return Unauthorized();
|
||||
if (user is null)
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
return Ok(new { apiKey = user.ApiKey });
|
||||
}
|
||||
@@ -303,33 +296,33 @@ public sealed class AccountController : ControllerBase
|
||||
[HttpPost("api-key/regenerate")]
|
||||
public async Task<IActionResult> RegenerateApiKey()
|
||||
{
|
||||
await UsersContext.Lock.WaitAsync();
|
||||
try
|
||||
var user = await GetCurrentUser();
|
||||
if (user is null)
|
||||
{
|
||||
var user = await GetCurrentUser();
|
||||
if (user is null) return Unauthorized();
|
||||
|
||||
var bytes = new byte[32];
|
||||
using var rng = RandomNumberGenerator.Create();
|
||||
rng.GetBytes(bytes);
|
||||
|
||||
user.ApiKey = Convert.ToHexString(bytes).ToLowerInvariant();
|
||||
user.UpdatedAt = DateTime.UtcNow;
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("API key regenerated for user {Username}", user.Username);
|
||||
|
||||
return Ok(new { apiKey = user.ApiKey });
|
||||
}
|
||||
finally
|
||||
{
|
||||
UsersContext.Lock.Release();
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
var bytes = new byte[32];
|
||||
using var rng = RandomNumberGenerator.Create();
|
||||
rng.GetBytes(bytes);
|
||||
|
||||
user.ApiKey = Convert.ToHexString(bytes).ToLowerInvariant();
|
||||
user.UpdatedAt = DateTime.UtcNow;
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("API key regenerated for user {Username}", user.Username);
|
||||
|
||||
return Ok(new { apiKey = user.ApiKey });
|
||||
}
|
||||
|
||||
[HttpPost("plex/link")]
|
||||
public async Task<IActionResult> StartPlexLink()
|
||||
{
|
||||
if (await IsOidcExclusiveModeActive())
|
||||
{
|
||||
return StatusCode(403, new { error = "Plex account management is disabled while OIDC exclusive mode is active." });
|
||||
}
|
||||
|
||||
var pin = await _plexAuthService.RequestPin();
|
||||
|
||||
return Ok(new { pinId = pin.PinId, authUrl = pin.AuthUrl });
|
||||
@@ -338,6 +331,11 @@ public sealed class AccountController : ControllerBase
|
||||
[HttpPost("plex/link/verify")]
|
||||
public async Task<IActionResult> VerifyPlexLink([FromBody] PlexPinRequest request)
|
||||
{
|
||||
if (await IsOidcExclusiveModeActive())
|
||||
{
|
||||
return StatusCode(403, new { error = "Plex account management is disabled while OIDC exclusive mode is active." });
|
||||
}
|
||||
|
||||
var pinResult = await _plexAuthService.CheckPin(request.PinId);
|
||||
|
||||
if (!pinResult.Completed || pinResult.AuthToken is null)
|
||||
@@ -347,23 +345,194 @@ public sealed class AccountController : ControllerBase
|
||||
|
||||
var plexAccount = await _plexAuthService.GetAccount(pinResult.AuthToken);
|
||||
|
||||
var user = await GetCurrentUser();
|
||||
if (user is null)
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
user.PlexAccountId = plexAccount.AccountId;
|
||||
user.PlexUsername = plexAccount.Username;
|
||||
user.PlexEmail = plexAccount.Email;
|
||||
user.PlexAuthToken = pinResult.AuthToken;
|
||||
user.UpdatedAt = DateTime.UtcNow;
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Plex account linked for user {Username}: {PlexUsername}",
|
||||
user.Username, plexAccount.Username);
|
||||
|
||||
return Ok(new { completed = true, plexUsername = plexAccount.Username });
|
||||
}
|
||||
|
||||
[HttpDelete("plex/link")]
|
||||
public async Task<IActionResult> UnlinkPlex()
|
||||
{
|
||||
if (await IsOidcExclusiveModeActive())
|
||||
{
|
||||
return StatusCode(403, new { error = "Plex account management is disabled while OIDC exclusive mode is active." });
|
||||
}
|
||||
|
||||
var user = await GetCurrentUser();
|
||||
if (user is null)
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
user.PlexAccountId = null;
|
||||
user.PlexUsername = null;
|
||||
user.PlexEmail = null;
|
||||
user.PlexAuthToken = null;
|
||||
user.UpdatedAt = DateTime.UtcNow;
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Plex account unlinked for user {Username}", user.Username);
|
||||
|
||||
return Ok(new { message = "Plex account unlinked" });
|
||||
}
|
||||
|
||||
[HttpGet("oidc")]
|
||||
public async Task<IActionResult> GetOidcConfig()
|
||||
{
|
||||
var user = await GetCurrentUser();
|
||||
if (user is null)
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
return Ok(user.Oidc);
|
||||
}
|
||||
|
||||
[HttpPut("oidc")]
|
||||
public async Task<IActionResult> UpdateOidcConfig([FromBody] UpdateOidcConfigRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
var user = await GetCurrentUser();
|
||||
if (user is null)
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
request.ApplyTo(user.Oidc);
|
||||
user.Oidc.Validate();
|
||||
user.UpdatedAt = DateTime.UtcNow;
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
return Ok(new { message = "OIDC configuration updated" });
|
||||
}
|
||||
catch (ValidationException ex)
|
||||
{
|
||||
return BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("oidc/link")]
|
||||
public async Task<IActionResult> StartOidcLink()
|
||||
{
|
||||
var user = await GetCurrentUser();
|
||||
if (user is null)
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
if (user.Oidc is not { Enabled: true })
|
||||
{
|
||||
return BadRequest(new { error = "OIDC is not enabled" });
|
||||
}
|
||||
|
||||
var redirectUri = GetOidcLinkCallbackUrl(user.Oidc.RedirectUrl);
|
||||
_logger.LogDebug("OIDC link start: using redirect URI {RedirectUri}", redirectUri);
|
||||
|
||||
try
|
||||
{
|
||||
var result = await _oidcAuthService.StartAuthorization(redirectUri, user.Id.ToString());
|
||||
return Ok(new OidcStartResponse { AuthorizationUrl = result.AuthorizationUrl });
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to start OIDC link authorization");
|
||||
return StatusCode(429, new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// This endpoint must be [AllowAnonymous] because the IdP redirects the user's browser here
|
||||
/// without a Bearer token. Security is ensured by validating that the OIDC flow was initiated
|
||||
/// by an authenticated user (InitiatorUserId stored in the flow state during StartOidcLink).
|
||||
/// </remarks>
|
||||
[AllowAnonymous]
|
||||
[HttpGet("oidc/link/callback")]
|
||||
public async Task<IActionResult> OidcLinkCallback(
|
||||
[FromQuery] string? code,
|
||||
[FromQuery] string? state,
|
||||
[FromQuery] string? error)
|
||||
{
|
||||
var basePath = HttpContext.Request.GetSafeBasePath();
|
||||
|
||||
if (!string.IsNullOrEmpty(error) || string.IsNullOrEmpty(code) || string.IsNullOrEmpty(state))
|
||||
{
|
||||
return Redirect($"{basePath}/settings/account?oidc_link_error=failed");
|
||||
}
|
||||
|
||||
// Fetch any user to get the configured redirect URL for the OIDC callback
|
||||
var oidcConfig = (await _usersContext.Users.AsNoTracking().FirstOrDefaultAsync())?.Oidc;
|
||||
var redirectUri = GetOidcLinkCallbackUrl(oidcConfig?.RedirectUrl);
|
||||
_logger.LogDebug("OIDC link callback: using redirect URI {RedirectUri}", redirectUri);
|
||||
var result = await _oidcAuthService.HandleCallback(code, state, redirectUri);
|
||||
|
||||
if (!result.Success || string.IsNullOrEmpty(result.Subject))
|
||||
{
|
||||
_logger.LogWarning("OIDC link callback failed: {Error}", result.Error);
|
||||
return Redirect($"{basePath}/settings/account?oidc_link_error=failed");
|
||||
}
|
||||
|
||||
// Verify the flow was initiated by an authenticated user
|
||||
if (string.IsNullOrEmpty(result.InitiatorUserId) ||
|
||||
!Guid.TryParse(result.InitiatorUserId, out var initiatorId))
|
||||
{
|
||||
_logger.LogWarning("OIDC link callback missing initiator user ID");
|
||||
return Redirect($"{basePath}/settings/account?oidc_link_error=failed");
|
||||
}
|
||||
|
||||
// Save the authorized subject to the user's OIDC config
|
||||
var user = await _usersContext.Users.FirstOrDefaultAsync(u => u.Id == initiatorId);
|
||||
|
||||
if (user is null)
|
||||
{
|
||||
_logger.LogWarning("OIDC link callback initiator user not found: {UserId}", result.InitiatorUserId);
|
||||
return Redirect($"{basePath}/settings/account?oidc_link_error=failed");
|
||||
}
|
||||
|
||||
user.Oidc.AuthorizedSubject = result.Subject;
|
||||
user.UpdatedAt = DateTime.UtcNow;
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("OIDC account linked with subject: {Subject} by user: {Username}",
|
||||
result.Subject, user.Username);
|
||||
|
||||
return Redirect($"{basePath}/settings/account?oidc_link=success");
|
||||
}
|
||||
|
||||
[HttpDelete("oidc/link")]
|
||||
public async Task<IActionResult> UnlinkOidc()
|
||||
{
|
||||
await UsersContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var user = await GetCurrentUser();
|
||||
if (user is null) return Unauthorized();
|
||||
if (user is null)
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
user.PlexAccountId = plexAccount.AccountId;
|
||||
user.PlexUsername = plexAccount.Username;
|
||||
user.PlexEmail = plexAccount.Email;
|
||||
user.PlexAuthToken = pinResult.AuthToken;
|
||||
user.Oidc.AuthorizedSubject = string.Empty;
|
||||
user.Oidc.ExclusiveMode = false;
|
||||
user.UpdatedAt = DateTime.UtcNow;
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Plex account linked for user {Username}: {PlexUsername}",
|
||||
user.Username, plexAccount.Username);
|
||||
_logger.LogInformation("OIDC account unlinked for user {Username}", user.Username);
|
||||
|
||||
return Ok(new { completed = true, plexUsername = plexAccount.Username });
|
||||
return Ok(new { message = "OIDC account unlinked" });
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -371,30 +540,24 @@ public sealed class AccountController : ControllerBase
|
||||
}
|
||||
}
|
||||
|
||||
[HttpDelete("plex/link")]
|
||||
public async Task<IActionResult> UnlinkPlex()
|
||||
private string GetOidcLinkCallbackUrl(string? redirectUrl = null)
|
||||
{
|
||||
await UsersContext.Lock.WaitAsync();
|
||||
try
|
||||
var baseUrl = string.IsNullOrEmpty(redirectUrl)
|
||||
? HttpContext.GetExternalBaseUrl()
|
||||
: redirectUrl.TrimEnd('/');
|
||||
return $"{baseUrl}/api/account/oidc/link/callback";
|
||||
}
|
||||
|
||||
private async Task<bool> IsOidcExclusiveModeActive()
|
||||
{
|
||||
var user = await _usersContext.Users.AsNoTracking().FirstOrDefaultAsync();
|
||||
if (user is not { SetupCompleted: true })
|
||||
{
|
||||
var user = await GetCurrentUser();
|
||||
if (user is null) return Unauthorized();
|
||||
|
||||
user.PlexAccountId = null;
|
||||
user.PlexUsername = null;
|
||||
user.PlexEmail = null;
|
||||
user.PlexAuthToken = null;
|
||||
user.UpdatedAt = DateTime.UtcNow;
|
||||
await _usersContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Plex account unlinked for user {Username}", user.Username);
|
||||
|
||||
return Ok(new { message = "Plex account unlinked" });
|
||||
}
|
||||
finally
|
||||
{
|
||||
UsersContext.Lock.Release();
|
||||
return false;
|
||||
}
|
||||
|
||||
var oidc = user.Oidc;
|
||||
return oidc is { Enabled: true, ExclusiveMode: true };
|
||||
}
|
||||
|
||||
private async Task<User?> GetCurrentUser(bool includeRecoveryCodes = false)
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
using System.Security.Cryptography;
|
||||
using Cleanuparr.Api.Auth;
|
||||
using Cleanuparr.Api.Extensions;
|
||||
using Cleanuparr.Api.Features.Auth.Contracts.Requests;
|
||||
using Cleanuparr.Api.Features.Auth.Contracts.Responses;
|
||||
using Cleanuparr.Api.Filters;
|
||||
using Cleanuparr.Infrastructure.Features.Auth;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Auth;
|
||||
@@ -14,28 +16,35 @@ namespace Cleanuparr.Api.Features.Auth.Controllers;
|
||||
[ApiController]
|
||||
[Route("api/auth")]
|
||||
[AllowAnonymous]
|
||||
[NoCache]
|
||||
public sealed class AuthController : ControllerBase
|
||||
{
|
||||
private readonly UsersContext _usersContext;
|
||||
private readonly DataContext _dataContext;
|
||||
private readonly IJwtService _jwtService;
|
||||
private readonly IPasswordService _passwordService;
|
||||
private readonly ITotpService _totpService;
|
||||
private readonly IPlexAuthService _plexAuthService;
|
||||
private readonly IOidcAuthService _oidcAuthService;
|
||||
private readonly ILogger<AuthController> _logger;
|
||||
|
||||
public AuthController(
|
||||
UsersContext usersContext,
|
||||
DataContext dataContext,
|
||||
IJwtService jwtService,
|
||||
IPasswordService passwordService,
|
||||
ITotpService totpService,
|
||||
IPlexAuthService plexAuthService,
|
||||
IOidcAuthService oidcAuthService,
|
||||
ILogger<AuthController> logger)
|
||||
{
|
||||
_usersContext = usersContext;
|
||||
_dataContext = dataContext;
|
||||
_jwtService = jwtService;
|
||||
_passwordService = passwordService;
|
||||
_totpService = totpService;
|
||||
_plexAuthService = plexAuthService;
|
||||
_oidcAuthService = oidcAuthService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -45,8 +54,7 @@ public sealed class AuthController : ControllerBase
|
||||
var user = await _usersContext.Users.AsNoTracking().FirstOrDefaultAsync();
|
||||
|
||||
var authBypass = false;
|
||||
await using var dataContext = DataContext.CreateStaticInstance();
|
||||
var generalConfig = await dataContext.GeneralConfigs.AsNoTracking().FirstOrDefaultAsync();
|
||||
var generalConfig = await _dataContext.GeneralConfigs.AsNoTracking().FirstOrDefaultAsync();
|
||||
if (generalConfig is { Auth.DisableAuthForLocalAddresses: true })
|
||||
{
|
||||
var clientIp = TrustedNetworkAuthenticationHandler.ResolveClientIp(
|
||||
@@ -58,11 +66,21 @@ public sealed class AuthController : ControllerBase
|
||||
}
|
||||
}
|
||||
|
||||
var oidcConfig = user?.Oidc;
|
||||
var oidcEnabled = oidcConfig is { Enabled: true } &&
|
||||
!string.IsNullOrEmpty(oidcConfig.IssuerUrl) &&
|
||||
!string.IsNullOrEmpty(oidcConfig.ClientId);
|
||||
|
||||
var oidcExclusiveMode = oidcEnabled && oidcConfig!.ExclusiveMode;
|
||||
|
||||
return Ok(new AuthStatusResponse
|
||||
{
|
||||
SetupCompleted = user is { SetupCompleted: true },
|
||||
PlexLinked = user?.PlexAccountId is not null,
|
||||
AuthBypassActive = authBypass
|
||||
AuthBypassActive = authBypass,
|
||||
OidcEnabled = oidcEnabled,
|
||||
OidcProviderName = oidcEnabled ? oidcConfig!.ProviderName : string.Empty,
|
||||
OidcExclusiveMode = oidcExclusiveMode
|
||||
});
|
||||
}
|
||||
|
||||
@@ -239,8 +257,18 @@ public sealed class AuthController : ControllerBase
|
||||
[HttpPost("login")]
|
||||
public async Task<IActionResult> Login([FromBody] LoginRequest request)
|
||||
{
|
||||
if (await IsOidcExclusiveModeActive())
|
||||
{
|
||||
return StatusCode(403, new { error = "Login with credentials is disabled. Use OIDC to sign in." });
|
||||
}
|
||||
|
||||
var user = await _usersContext.Users.AsNoTracking().FirstOrDefaultAsync();
|
||||
|
||||
// Always verify the submitted password to prevent timing-based username enumeration
|
||||
var userHasPassword = user?.PasswordHash is not null;
|
||||
var passwordHash = user?.PasswordHash ?? _passwordService.DummyHash;
|
||||
var passwordValid = _passwordService.VerifyPassword(request.Password, passwordHash) && userHasPassword;
|
||||
|
||||
if (user is null || !user.SetupCompleted)
|
||||
{
|
||||
return Unauthorized(new { error = "Invalid credentials" });
|
||||
@@ -249,12 +277,11 @@ public sealed class AuthController : ControllerBase
|
||||
// Check lockout
|
||||
if (user.LockoutEnd.HasValue && user.LockoutEnd.Value > DateTime.UtcNow)
|
||||
{
|
||||
var remaining = (int)(user.LockoutEnd.Value - DateTime.UtcNow).TotalSeconds;
|
||||
var remaining = (int)Math.Ceiling((user.LockoutEnd.Value - DateTime.UtcNow).TotalSeconds);
|
||||
return StatusCode(429, new { error = "Account is locked", retryAfterSeconds = remaining });
|
||||
}
|
||||
|
||||
if (!_passwordService.VerifyPassword(request.Password, user.PasswordHash) ||
|
||||
!string.Equals(user.Username, request.Username, StringComparison.OrdinalIgnoreCase))
|
||||
if (!passwordValid || !string.Equals(user.Username, request.Username, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var retryAfterSeconds = await IncrementFailedAttempts(user.Id);
|
||||
return Unauthorized(new { error = "Invalid credentials", retryAfterSeconds });
|
||||
@@ -292,6 +319,11 @@ public sealed class AuthController : ControllerBase
|
||||
[HttpPost("login/2fa")]
|
||||
public async Task<IActionResult> VerifyTwoFactor([FromBody] TwoFactorRequest request)
|
||||
{
|
||||
if (await IsOidcExclusiveModeActive())
|
||||
{
|
||||
return StatusCode(403, new { error = "Login with credentials is disabled. Use OIDC to sign in." });
|
||||
}
|
||||
|
||||
var userId = _jwtService.ValidateLoginToken(request.LoginToken);
|
||||
if (userId is null)
|
||||
{
|
||||
@@ -453,6 +485,11 @@ public sealed class AuthController : ControllerBase
|
||||
[HttpPost("login/plex/pin")]
|
||||
public async Task<IActionResult> RequestPlexPin()
|
||||
{
|
||||
if (await IsOidcExclusiveModeActive())
|
||||
{
|
||||
return StatusCode(403, new { error = "Plex login is disabled. Use OIDC to sign in." });
|
||||
}
|
||||
|
||||
var user = await _usersContext.Users.AsNoTracking().FirstOrDefaultAsync();
|
||||
if (user is null || !user.SetupCompleted || user.PlexAccountId is null)
|
||||
{
|
||||
@@ -471,6 +508,11 @@ public sealed class AuthController : ControllerBase
|
||||
[HttpPost("login/plex/verify")]
|
||||
public async Task<IActionResult> VerifyPlexLogin([FromBody] PlexPinRequest request)
|
||||
{
|
||||
if (await IsOidcExclusiveModeActive())
|
||||
{
|
||||
return StatusCode(403, new { error = "Plex login is disabled. Use OIDC to sign in." });
|
||||
}
|
||||
|
||||
var user = await _usersContext.Users.FirstOrDefaultAsync();
|
||||
if (user is null || !user.SetupCompleted || user.PlexAccountId is null)
|
||||
{
|
||||
@@ -507,6 +549,119 @@ public sealed class AuthController : ControllerBase
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("oidc/start")]
|
||||
public async Task<IActionResult> StartOidc()
|
||||
{
|
||||
var user = await _usersContext.Users.AsNoTracking().FirstOrDefaultAsync();
|
||||
var oidcConfig = user?.Oidc;
|
||||
|
||||
if (oidcConfig is not { Enabled: true } ||
|
||||
string.IsNullOrEmpty(oidcConfig.IssuerUrl) ||
|
||||
string.IsNullOrEmpty(oidcConfig.ClientId))
|
||||
{
|
||||
return BadRequest(new { error = "OIDC is not enabled or not configured" });
|
||||
}
|
||||
|
||||
var redirectUri = GetOidcCallbackUrl(oidcConfig.RedirectUrl);
|
||||
_logger.LogDebug("OIDC login start: using redirect URI {RedirectUri}", redirectUri);
|
||||
|
||||
try
|
||||
{
|
||||
var result = await _oidcAuthService.StartAuthorization(redirectUri);
|
||||
return Ok(new OidcStartResponse { AuthorizationUrl = result.AuthorizationUrl });
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to start OIDC authorization");
|
||||
return StatusCode(429, new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("oidc/callback")]
|
||||
public async Task<IActionResult> OidcCallback(
|
||||
[FromQuery] string? code,
|
||||
[FromQuery] string? state,
|
||||
[FromQuery] string? error)
|
||||
{
|
||||
var basePath = HttpContext.Request.GetSafeBasePath();
|
||||
|
||||
// Handle IdP error responses
|
||||
if (!string.IsNullOrEmpty(error))
|
||||
{
|
||||
_logger.LogWarning("OIDC callback received error: {Error}", error);
|
||||
return Redirect($"{basePath}/auth/login?oidc_error=provider_error");
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(code) || string.IsNullOrEmpty(state))
|
||||
{
|
||||
return Redirect($"{basePath}/auth/login?oidc_error=invalid_request");
|
||||
}
|
||||
|
||||
// Load the user early so we can use the configured redirect URL
|
||||
var user = await _usersContext.Users.FirstOrDefaultAsync(u => u.SetupCompleted);
|
||||
if (user is null)
|
||||
{
|
||||
return Redirect($"{basePath}/auth/login?oidc_error=no_account");
|
||||
}
|
||||
|
||||
var redirectUri = GetOidcCallbackUrl(user.Oidc.RedirectUrl);
|
||||
_logger.LogDebug("OIDC login callback: using redirect URI {RedirectUri}", redirectUri);
|
||||
var result = await _oidcAuthService.HandleCallback(code, state, redirectUri);
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
_logger.LogWarning("OIDC callback failed: {Error}", result.Error);
|
||||
return Redirect($"{basePath}/auth/login?oidc_error=authentication_failed");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(user.Oidc.AuthorizedSubject) &&
|
||||
result.Subject != user.Oidc.AuthorizedSubject)
|
||||
{
|
||||
_logger.LogWarning("OIDC subject mismatch. Expected: {Expected}, Got: {Got}",
|
||||
user.Oidc.AuthorizedSubject, result.Subject);
|
||||
return Redirect($"{basePath}/auth/login?oidc_error=unauthorized");
|
||||
}
|
||||
|
||||
var tokenResponse = await GenerateTokenResponse(user);
|
||||
|
||||
// Store tokens with a one-time code (never put tokens in the URL)
|
||||
var oneTimeCode = _oidcAuthService.StoreOneTimeCode(
|
||||
tokenResponse.AccessToken,
|
||||
tokenResponse.RefreshToken,
|
||||
tokenResponse.ExpiresIn);
|
||||
|
||||
_logger.LogInformation("User {Username} authenticated via OIDC (subject: {Subject})",
|
||||
user.Username, result.Subject);
|
||||
|
||||
return Redirect($"{basePath}/auth/oidc/callback?code={Uri.EscapeDataString(oneTimeCode)}");
|
||||
}
|
||||
|
||||
[HttpPost("oidc/exchange")]
|
||||
public IActionResult ExchangeOidcCode([FromBody] OidcExchangeRequest request)
|
||||
{
|
||||
var result = _oidcAuthService.ExchangeOneTimeCode(request.Code);
|
||||
|
||||
if (result is null)
|
||||
{
|
||||
return NotFound(new { error = "Invalid or expired code" });
|
||||
}
|
||||
|
||||
return Ok(new TokenResponse
|
||||
{
|
||||
AccessToken = result.AccessToken,
|
||||
RefreshToken = result.RefreshToken,
|
||||
ExpiresIn = result.ExpiresIn
|
||||
});
|
||||
}
|
||||
|
||||
private string GetOidcCallbackUrl(string? redirectUrl = null)
|
||||
{
|
||||
var baseUrl = string.IsNullOrEmpty(redirectUrl)
|
||||
? HttpContext.GetExternalBaseUrl()
|
||||
: redirectUrl.TrimEnd('/');
|
||||
return $"{baseUrl}/api/auth/oidc/callback";
|
||||
}
|
||||
|
||||
private async Task<TokenResponse> GenerateTokenResponse(User user)
|
||||
{
|
||||
var accessToken = _jwtService.GenerateAccessToken(user);
|
||||
@@ -608,4 +763,16 @@ public sealed class AuthController : ControllerBase
|
||||
var hash = SHA256.HashData(bytes);
|
||||
return Convert.ToBase64String(hash);
|
||||
}
|
||||
|
||||
private async Task<bool> IsOidcExclusiveModeActive()
|
||||
{
|
||||
var user = await _usersContext.Users.AsNoTracking().FirstOrDefaultAsync();
|
||||
if (user is not { SetupCompleted: true })
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var oidc = user.Oidc;
|
||||
return oidc is { Enabled: true, ExclusiveMode: true };
|
||||
}
|
||||
}
|
||||
+27
-7
@@ -63,14 +63,34 @@ public sealed class GeneralConfigController : ControllerBase
|
||||
|
||||
if (wasDryRun && !config.DryRun)
|
||||
{
|
||||
var deletedStrikes = await eventsContext.Strikes.ExecuteDeleteAsync();
|
||||
var deletedItems = await eventsContext.DownloadItems
|
||||
.Where(d => !d.Strikes.Any())
|
||||
.ExecuteDeleteAsync();
|
||||
await using var transaction = await eventsContext.Database.BeginTransactionAsync();
|
||||
|
||||
_logger.LogWarning(
|
||||
"Dry run disabled — purged all strikes: {Strikes} strikes, {Items} download items removed",
|
||||
deletedStrikes, deletedItems);
|
||||
try
|
||||
{
|
||||
var deletedStrikes = await eventsContext.Strikes
|
||||
.Where(s => s.IsDryRun)
|
||||
.ExecuteDeleteAsync();
|
||||
var deletedEvents = await eventsContext.Events
|
||||
.Where(e => e.IsDryRun)
|
||||
.ExecuteDeleteAsync();
|
||||
var deletedManualEvents = await eventsContext.ManualEvents
|
||||
.Where(e => e.IsDryRun)
|
||||
.ExecuteDeleteAsync();
|
||||
var deletedItems = await eventsContext.DownloadItems
|
||||
.Where(d => !d.Strikes.Any())
|
||||
.ExecuteDeleteAsync();
|
||||
|
||||
_logger.LogWarning(
|
||||
"Dry run disabled — purged dry-run data: {Strikes} strikes, {Events} events, {ManualEvents} manual events, {Items} orphaned download items removed",
|
||||
deletedStrikes, deletedEvents, deletedManualEvents, deletedItems);
|
||||
|
||||
await transaction.CommitAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
await transaction.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
return Ok(new { Message = "General configuration updated successfully" });
|
||||
|
||||
+2
-2
@@ -16,7 +16,7 @@ public sealed record UpdateMalwareBlockerConfigRequest
|
||||
|
||||
public bool DeletePrivate { get; init; }
|
||||
|
||||
public bool DeleteKnownMalware { get; init; }
|
||||
public bool ProcessNoContentId { get; init; }
|
||||
|
||||
public BlocklistSettings Sonarr { get; init; } = new();
|
||||
|
||||
@@ -37,7 +37,7 @@ public sealed record UpdateMalwareBlockerConfigRequest
|
||||
config.UseAdvancedScheduling = UseAdvancedScheduling;
|
||||
config.IgnorePrivate = IgnorePrivate;
|
||||
config.DeletePrivate = DeletePrivate;
|
||||
config.DeleteKnownMalware = DeleteKnownMalware;
|
||||
config.ProcessNoContentId = ProcessNoContentId;
|
||||
config.Sonarr = Sonarr;
|
||||
config.Radarr = Radarr;
|
||||
config.Lidarr = Lidarr;
|
||||
|
||||
+3
-1
@@ -13,6 +13,8 @@ public sealed record UpdateQueueCleanerConfigRequest
|
||||
public FailedImportConfig FailedImport { get; init; } = new();
|
||||
|
||||
public ushort DownloadingMetadataMaxStrikes { get; init; }
|
||||
|
||||
|
||||
public bool ProcessNoContentId { get; init; }
|
||||
|
||||
public List<string> IgnoredDownloads { get; set; } = [];
|
||||
}
|
||||
+1
@@ -69,6 +69,7 @@ public sealed class QueueCleanerConfigController : ControllerBase
|
||||
oldConfig.UseAdvancedScheduling = newConfigDto.UseAdvancedScheduling;
|
||||
oldConfig.FailedImport = newConfigDto.FailedImport;
|
||||
oldConfig.DownloadingMetadataMaxStrikes = newConfigDto.DownloadingMetadataMaxStrikes;
|
||||
oldConfig.ProcessNoContentId = newConfigDto.ProcessNoContentId;
|
||||
oldConfig.IgnoredDownloads = newConfigDto.IgnoredDownloads;
|
||||
|
||||
oldConfig.Validate();
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
using Microsoft.AspNetCore.Mvc.Filters;
|
||||
|
||||
namespace Cleanuparr.Api.Filters;
|
||||
|
||||
/// <summary>
|
||||
/// Prevents caching of sensitive responses by setting appropriate HTTP headers.
|
||||
/// Applies Cache-Control: no-cache, no-store, Pragma: no-cache, and a past Expires date
|
||||
/// for maximum compatibility with HTTP/1.0 and HTTP/1.1 clients and intermediaries.
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
|
||||
public sealed class NoCacheAttribute : ActionFilterAttribute
|
||||
{
|
||||
public static void Apply(IHeaderDictionary headers)
|
||||
{
|
||||
headers.CacheControl = "no-cache, no-store";
|
||||
headers.Pragma = "no-cache";
|
||||
headers.Expires = "Thu, 01 Jan 1970 00:00:00 GMT";
|
||||
}
|
||||
|
||||
public override void OnResultExecuting(ResultExecutingContext context)
|
||||
{
|
||||
Apply(context.HttpContext.Response.Headers);
|
||||
base.OnResultExecuting(context);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,4 @@
|
||||
using System.Reflection;
|
||||
using Cleanuparr.Infrastructure.Health;
|
||||
using Cleanuparr.Infrastructure.Logging;
|
||||
using Cleanuparr.Infrastructure.Services;
|
||||
using Cleanuparr.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
@@ -11,5 +11,4 @@ public enum DeleteReason
|
||||
AllFilesSkipped,
|
||||
AllFilesSkippedByQBit,
|
||||
AllFilesBlocked,
|
||||
MalwareFileFound,
|
||||
}
|
||||
@@ -45,19 +45,8 @@ public class EventPublisherTests : IDisposable
|
||||
clientsMock.Setup(c => c.All).Returns(_clientProxyMock.Object);
|
||||
_hubContextMock.Setup(h => h.Clients).Returns(clientsMock.Object);
|
||||
|
||||
// Setup dry run interceptor to execute the delegate
|
||||
_dryRunInterceptorMock.Setup(d => d.InterceptAsync(It.IsAny<Delegate>(), It.IsAny<object[]>()))
|
||||
.Returns<Delegate, object[]>(async (del, args) =>
|
||||
{
|
||||
if (del is Func<AppEvent, Task> func && args.Length > 0 && args[0] is AppEvent appEvent)
|
||||
{
|
||||
await func(appEvent);
|
||||
}
|
||||
else if (del is Func<ManualEvent, Task> manualFunc && args.Length > 0 && args[0] is ManualEvent manualEvent)
|
||||
{
|
||||
await manualFunc(manualEvent);
|
||||
}
|
||||
});
|
||||
// Setup dry run interceptor to report dry run as disabled by default
|
||||
_dryRunInterceptorMock.Setup(d => d.IsDryRunEnabled()).ReturnsAsync(false);
|
||||
|
||||
_publisher = new EventPublisher(
|
||||
_context,
|
||||
@@ -251,10 +240,10 @@ public class EventPublisherTests : IDisposable
|
||||
|
||||
#endregion
|
||||
|
||||
#region DryRun Interceptor Tests
|
||||
#region DryRun Tests
|
||||
|
||||
[Fact]
|
||||
public async Task PublishAsync_UsesDryRunInterceptor()
|
||||
public async Task PublishAsync_ChecksDryRunStatus()
|
||||
{
|
||||
// Arrange
|
||||
var eventType = EventType.StalledStrike;
|
||||
@@ -265,13 +254,11 @@ public class EventPublisherTests : IDisposable
|
||||
await _publisher.PublishAsync(eventType, message, severity);
|
||||
|
||||
// Assert
|
||||
_dryRunInterceptorMock.Verify(d => d.InterceptAsync(
|
||||
It.IsAny<Delegate>(),
|
||||
It.IsAny<object[]>()), Times.Once);
|
||||
_dryRunInterceptorMock.Verify(d => d.IsDryRunEnabled(), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublishManualAsync_UsesDryRunInterceptor()
|
||||
public async Task PublishManualAsync_ChecksDryRunStatus()
|
||||
{
|
||||
// Arrange
|
||||
var message = "Manual test";
|
||||
@@ -281,9 +268,77 @@ public class EventPublisherTests : IDisposable
|
||||
await _publisher.PublishManualAsync(message, severity);
|
||||
|
||||
// Assert
|
||||
_dryRunInterceptorMock.Verify(d => d.InterceptAsync(
|
||||
It.IsAny<Delegate>(),
|
||||
It.IsAny<object[]>()), Times.Once);
|
||||
_dryRunInterceptorMock.Verify(d => d.IsDryRunEnabled(), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublishAsync_WhenDryRunEnabled_SetsIsDryRunTrue()
|
||||
{
|
||||
// Arrange
|
||||
_dryRunInterceptorMock.Setup(d => d.IsDryRunEnabled()).ReturnsAsync(true);
|
||||
var eventType = EventType.StalledStrike;
|
||||
var message = "Dry run event";
|
||||
var severity = EventSeverity.Warning;
|
||||
|
||||
// Act
|
||||
await _publisher.PublishAsync(eventType, message, severity);
|
||||
|
||||
// Assert
|
||||
var savedEvent = await _context.Events.FirstOrDefaultAsync();
|
||||
Assert.NotNull(savedEvent);
|
||||
Assert.True(savedEvent.IsDryRun);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublishAsync_WhenDryRunDisabled_SetsIsDryRunFalse()
|
||||
{
|
||||
// Arrange
|
||||
var eventType = EventType.QueueItemDeleted;
|
||||
var message = "Normal event";
|
||||
var severity = EventSeverity.Important;
|
||||
|
||||
// Act
|
||||
await _publisher.PublishAsync(eventType, message, severity);
|
||||
|
||||
// Assert
|
||||
var savedEvent = await _context.Events.FirstOrDefaultAsync();
|
||||
Assert.NotNull(savedEvent);
|
||||
Assert.False(savedEvent.IsDryRun);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublishManualAsync_WhenDryRunEnabled_SetsIsDryRunTrue()
|
||||
{
|
||||
// Arrange
|
||||
_dryRunInterceptorMock.Setup(d => d.IsDryRunEnabled()).ReturnsAsync(true);
|
||||
var message = "Dry run manual event";
|
||||
var severity = EventSeverity.Important;
|
||||
|
||||
// Act
|
||||
await _publisher.PublishManualAsync(message, severity);
|
||||
|
||||
// Assert
|
||||
var savedEvent = await _context.ManualEvents.FirstOrDefaultAsync();
|
||||
Assert.NotNull(savedEvent);
|
||||
Assert.True(savedEvent.IsDryRun);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublishAsync_WhenDryRunEnabled_StillSavesToDatabase()
|
||||
{
|
||||
// Arrange
|
||||
_dryRunInterceptorMock.Setup(d => d.IsDryRunEnabled()).ReturnsAsync(true);
|
||||
var eventType = EventType.StalledStrike;
|
||||
var message = "Should be saved";
|
||||
var severity = EventSeverity.Warning;
|
||||
|
||||
// Act
|
||||
await _publisher.PublishAsync(eventType, message, severity);
|
||||
|
||||
// Assert
|
||||
var savedEvent = await _context.Events.FirstOrDefaultAsync();
|
||||
Assert.NotNull(savedEvent);
|
||||
Assert.Equal(message, savedEvent.Message);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
using Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
using Cleanuparr.Infrastructure.Features.Arr;
|
||||
using Cleanuparr.Infrastructure.Features.ItemStriker;
|
||||
using Cleanuparr.Infrastructure.Interceptors;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.Arr;
|
||||
|
||||
@@ -35,144 +33,4 @@ public class WhisparrV2ClientTests
|
||||
_dryRunInterceptorMock.Object
|
||||
);
|
||||
}
|
||||
|
||||
#region IsRecordValid Tests
|
||||
|
||||
[Fact]
|
||||
public void IsRecordValid_WhenEpisodeIdIsZero_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var record = new QueueRecord
|
||||
{
|
||||
Id = 1,
|
||||
Title = "Test Episode",
|
||||
DownloadId = "abc123",
|
||||
Protocol = "torrent",
|
||||
EpisodeId = 0,
|
||||
SeriesId = 1
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _client.IsRecordValid(record);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
_loggerMock.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Debug,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("episode id and/or series id missing")),
|
||||
It.IsAny<Exception?>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()
|
||||
),
|
||||
Times.Once
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsRecordValid_WhenSeriesIdIsZero_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var record = new QueueRecord
|
||||
{
|
||||
Id = 1,
|
||||
Title = "Test Episode",
|
||||
DownloadId = "abc123",
|
||||
Protocol = "torrent",
|
||||
EpisodeId = 1,
|
||||
SeriesId = 0
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _client.IsRecordValid(record);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsRecordValid_WhenBothIdsAreZero_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var record = new QueueRecord
|
||||
{
|
||||
Id = 1,
|
||||
Title = "Test Episode",
|
||||
DownloadId = "abc123",
|
||||
Protocol = "torrent",
|
||||
EpisodeId = 0,
|
||||
SeriesId = 0
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _client.IsRecordValid(record);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsRecordValid_WhenBothIdsAreSet_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var record = new QueueRecord
|
||||
{
|
||||
Id = 1,
|
||||
Title = "Test Episode",
|
||||
DownloadId = "abc123",
|
||||
Protocol = "torrent",
|
||||
EpisodeId = 42,
|
||||
SeriesId = 10
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _client.IsRecordValid(record);
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsRecordValid_WhenDownloadIdIsNull_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var record = new QueueRecord
|
||||
{
|
||||
Id = 1,
|
||||
Title = "Test Episode",
|
||||
DownloadId = null!,
|
||||
Protocol = "torrent",
|
||||
EpisodeId = 42,
|
||||
SeriesId = 10
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _client.IsRecordValid(record);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsRecordValid_WhenDownloadIdIsEmpty_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var record = new QueueRecord
|
||||
{
|
||||
Id = 1,
|
||||
Title = "Test Episode",
|
||||
DownloadId = "",
|
||||
Protocol = "torrent",
|
||||
EpisodeId = 42,
|
||||
SeriesId = 10
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _client.IsRecordValid(record);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -1,10 +1,8 @@
|
||||
using Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
using Cleanuparr.Infrastructure.Features.Arr;
|
||||
using Cleanuparr.Infrastructure.Features.ItemStriker;
|
||||
using Cleanuparr.Infrastructure.Interceptors;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.Arr;
|
||||
|
||||
@@ -35,98 +33,4 @@ public class WhisparrV3ClientTests
|
||||
_dryRunInterceptorMock.Object
|
||||
);
|
||||
}
|
||||
|
||||
#region IsRecordValid Tests
|
||||
|
||||
[Fact]
|
||||
public void IsRecordValid_WhenMovieIdIsZero_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var record = new QueueRecord
|
||||
{
|
||||
Id = 1,
|
||||
Title = "Test Movie",
|
||||
DownloadId = "abc123",
|
||||
Protocol = "torrent",
|
||||
MovieId = 0
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _client.IsRecordValid(record);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
_loggerMock.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Debug,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("movie id missing")),
|
||||
It.IsAny<Exception?>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()
|
||||
),
|
||||
Times.Once
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsRecordValid_WhenMovieIdIsSet_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var record = new QueueRecord
|
||||
{
|
||||
Id = 1,
|
||||
Title = "Test Movie",
|
||||
DownloadId = "abc123",
|
||||
Protocol = "torrent",
|
||||
MovieId = 42
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _client.IsRecordValid(record);
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsRecordValid_WhenDownloadIdIsNull_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var record = new QueueRecord
|
||||
{
|
||||
Id = 1,
|
||||
Title = "Test Movie",
|
||||
DownloadId = null!,
|
||||
Protocol = "torrent",
|
||||
MovieId = 42
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _client.IsRecordValid(record);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsRecordValid_WhenDownloadIdIsEmpty_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var record = new QueueRecord
|
||||
{
|
||||
Id = 1,
|
||||
Title = "Test Movie",
|
||||
DownloadId = "",
|
||||
Protocol = "torrent",
|
||||
MovieId = 42
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _client.IsRecordValid(record);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
+7
-11
@@ -149,21 +149,17 @@ public class TransmissionItemWrapperTests
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1024L, 512L, 2.0)] // Uploaded more than downloaded
|
||||
[InlineData(512L, 1024L, 0.5)] // Uploaded less than downloaded
|
||||
[InlineData(1024L, 1024L, 1.0)] // Equal
|
||||
[InlineData(0L, 1024L, 0.0)] // No upload
|
||||
[InlineData(1024L, 0L, 0.0)] // No download
|
||||
[InlineData(null, 1024L, 0.0)] // Null upload
|
||||
[InlineData(1024L, null, 0.0)] // Null download
|
||||
[InlineData(null, null, 0.0)] // Both null
|
||||
public void Ratio_ReturnsCorrectValue(long? uploadedEver, long? downloadedEver, double expected)
|
||||
[InlineData(2.0, 2.0)]
|
||||
[InlineData(0.5, 0.5)]
|
||||
[InlineData(1.0, 1.0)]
|
||||
[InlineData(0.0, 0.0)]
|
||||
[InlineData(null, 0.0)]
|
||||
public void Ratio_ReturnsCorrectValue(double? uploadRatio, double expected)
|
||||
{
|
||||
// Arrange
|
||||
var torrentInfo = new TorrentInfo
|
||||
{
|
||||
UploadedEver = uploadedEver,
|
||||
DownloadedEver = downloadedEver
|
||||
uploadRatio = uploadRatio
|
||||
};
|
||||
var wrapper = new TransmissionItemWrapper(torrentInfo);
|
||||
|
||||
|
||||
+64
-5
@@ -3,6 +3,7 @@ using Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Events;
|
||||
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.Context;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadHunter.Models;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadRemover;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadRemover.Models;
|
||||
@@ -34,6 +35,7 @@ public class QueueItemRemoverTests : IDisposable
|
||||
private readonly EventPublisher _eventPublisher;
|
||||
private readonly EventsContext _eventsContext;
|
||||
private readonly QueueItemRemover _queueItemRemover;
|
||||
private readonly Guid _jobRunId;
|
||||
|
||||
public QueueItemRemoverTests()
|
||||
{
|
||||
@@ -50,13 +52,20 @@ public class QueueItemRemoverTests : IDisposable
|
||||
// Create real EventPublisher with mocked dependencies
|
||||
_eventsContext = TestEventsContextFactory.Create();
|
||||
|
||||
// Create a JobRun so event FK constraints are satisfied when events are saved
|
||||
_jobRunId = Guid.NewGuid();
|
||||
_eventsContext.JobRuns.Add(new Persistence.Models.State.JobRun { Id = _jobRunId, Type = JobType.QueueCleaner });
|
||||
_eventsContext.SaveChanges();
|
||||
ContextProvider.SetJobRunId(_jobRunId);
|
||||
|
||||
var hubContextMock = new Mock<IHubContext<AppHub>>();
|
||||
var clientsMock = new Mock<IHubClients>();
|
||||
clientsMock.Setup(c => c.All).Returns(Mock.Of<IClientProxy>());
|
||||
hubContextMock.Setup(h => h.Clients).Returns(clientsMock.Object);
|
||||
|
||||
var dryRunInterceptorMock = new Mock<IDryRunInterceptor>();
|
||||
// Setup interceptor to skip actual database saves (these tests verify QueueItemRemover, not EventPublisher)
|
||||
dryRunInterceptorMock.Setup(d => d.IsDryRunEnabled()).ReturnsAsync(false);
|
||||
// Setup interceptor for other uses (e.g., ArrClient deletion)
|
||||
dryRunInterceptorMock
|
||||
.Setup(d => d.InterceptAsync(It.IsAny<Delegate>(), It.IsAny<object[]>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
@@ -275,6 +284,55 @@ public class QueueItemRemoverTests : IDisposable
|
||||
|
||||
#endregion
|
||||
|
||||
#region RemoveQueueItemAsync - SkipSearch Tests
|
||||
|
||||
[Fact]
|
||||
public async Task RemoveQueueItemAsync_WhenSkipSearch_DoesNotPublishHuntRequest()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateRemoveRequest(skipSearch: true);
|
||||
|
||||
_arrClientMock
|
||||
.Setup(c => c.DeleteQueueItemAsync(
|
||||
It.IsAny<ArrInstance>(),
|
||||
It.IsAny<QueueRecord>(),
|
||||
It.IsAny<bool>(),
|
||||
It.IsAny<DeleteReason>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _queueItemRemover.RemoveQueueItemAsync(request);
|
||||
|
||||
// Assert
|
||||
_busMock.Verify(b => b.Publish(
|
||||
It.IsAny<DownloadHuntRequest<SearchItem>>(),
|
||||
It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RemoveQueueItemAsync_WhenSkipSearch_AndHashIsNotRecurring_DoesNotModifyRecurringHashes()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateRemoveRequest(skipSearch: true);
|
||||
var hash = request.Record.DownloadId.ToLowerInvariant();
|
||||
|
||||
_arrClientMock
|
||||
.Setup(c => c.DeleteQueueItemAsync(
|
||||
It.IsAny<ArrInstance>(),
|
||||
It.IsAny<QueueRecord>(),
|
||||
It.IsAny<bool>(),
|
||||
It.IsAny<DeleteReason>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _queueItemRemover.RemoveQueueItemAsync(request);
|
||||
|
||||
// Assert - hash was never in recurring, should still not be there
|
||||
Assert.False(Striker.RecurringHashes.ContainsKey(hash));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region RemoveQueueItemAsync - HTTP Error Tests
|
||||
|
||||
[Fact]
|
||||
@@ -377,7 +435,6 @@ public class QueueItemRemoverTests : IDisposable
|
||||
[InlineData(DeleteReason.SlowSpeed)]
|
||||
[InlineData(DeleteReason.SlowTime)]
|
||||
[InlineData(DeleteReason.DownloadingMetadata)]
|
||||
[InlineData(DeleteReason.MalwareFileFound)]
|
||||
public async Task RemoveQueueItemAsync_PassesCorrectDeleteReason(DeleteReason deleteReason)
|
||||
{
|
||||
// Arrange
|
||||
@@ -433,10 +490,11 @@ public class QueueItemRemoverTests : IDisposable
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private static QueueItemRemoveRequest<SearchItem> CreateRemoveRequest(
|
||||
private QueueItemRemoveRequest<SearchItem> CreateRemoveRequest(
|
||||
InstanceType instanceType = InstanceType.Sonarr,
|
||||
bool removeFromClient = true,
|
||||
DeleteReason deleteReason = DeleteReason.Stalled)
|
||||
DeleteReason deleteReason = DeleteReason.Stalled,
|
||||
bool skipSearch = false)
|
||||
{
|
||||
return new QueueItemRemoveRequest<SearchItem>
|
||||
{
|
||||
@@ -446,7 +504,8 @@ public class QueueItemRemoverTests : IDisposable
|
||||
Record = CreateQueueRecord(),
|
||||
RemoveFromClient = removeFromClient,
|
||||
DeleteReason = deleteReason,
|
||||
JobRunId = Guid.NewGuid()
|
||||
SkipSearch = skipSearch,
|
||||
JobRunId = _jobRunId
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+192
-19
@@ -159,24 +159,21 @@ public class MalwareBlockerTests : IDisposable
|
||||
_fixture.ArrClientFactory.Verify(x => x.GetClient(InstanceType.Sonarr, It.IsAny<float>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteInternalAsync_WhenDeleteKnownMalwareEnabled_ProcessesAllArrs()
|
||||
[Theory]
|
||||
[InlineData(InstanceType.Radarr)]
|
||||
[InlineData(InstanceType.Lidarr)]
|
||||
[InlineData(InstanceType.Readarr)]
|
||||
[InlineData(InstanceType.Whisparr)]
|
||||
public async Task ExecuteInternalAsync_WhenArrTypeEnabled_ProcessesCorrectInstances(InstanceType instanceType)
|
||||
{
|
||||
// Arrange
|
||||
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
|
||||
|
||||
var contentBlockerConfig = _fixture.DataContext.ContentBlockerConfigs.First();
|
||||
contentBlockerConfig.DeleteKnownMalware = true;
|
||||
// Need at least one blocklist enabled for processing to occur
|
||||
contentBlockerConfig.Sonarr = new BlocklistSettings { Enabled = true };
|
||||
_fixture.DataContext.SaveChanges();
|
||||
|
||||
TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
|
||||
TestDataContextFactory.AddRadarrInstance(_fixture.DataContext);
|
||||
EnableBlocklist(instanceType);
|
||||
AddArrInstance(instanceType);
|
||||
|
||||
var mockArrClient = new Mock<IArrClient>();
|
||||
_fixture.ArrClientFactory
|
||||
.Setup(x => x.GetClient(It.IsAny<InstanceType>(), It.IsAny<float>()))
|
||||
.Setup(x => x.GetClient(instanceType, It.IsAny<float>()))
|
||||
.Returns(mockArrClient.Object);
|
||||
|
||||
_fixture.ArrQueueIterator
|
||||
@@ -192,9 +189,8 @@ public class MalwareBlockerTests : IDisposable
|
||||
// Act
|
||||
await sut.ExecuteAsync();
|
||||
|
||||
// Assert - Sonarr and Radarr processed because DeleteKnownMalware is true
|
||||
_fixture.ArrClientFactory.Verify(x => x.GetClient(InstanceType.Sonarr, It.IsAny<float>()), Times.Once);
|
||||
_fixture.ArrClientFactory.Verify(x => x.GetClient(InstanceType.Radarr, It.IsAny<float>()), Times.Once);
|
||||
// Assert
|
||||
_fixture.ArrClientFactory.Verify(x => x.GetClient(instanceType, It.IsAny<float>()), Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -215,6 +211,7 @@ public class MalwareBlockerTests : IDisposable
|
||||
|
||||
var mockArrClient = new Mock<IArrClient>();
|
||||
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
|
||||
mockArrClient.Setup(x => x.HasContentId(It.IsAny<QueueRecord>())).Returns(true);
|
||||
|
||||
_fixture.ArrClientFactory
|
||||
.Setup(x => x.GetClient(InstanceType.Sonarr, It.IsAny<float>()))
|
||||
@@ -225,7 +222,9 @@ public class MalwareBlockerTests : IDisposable
|
||||
Id = 1,
|
||||
DownloadId = "ignored-download-id",
|
||||
Title = "Ignored Download",
|
||||
Protocol = "torrent"
|
||||
Protocol = "torrent",
|
||||
SeriesId = 1,
|
||||
EpisodeId = 1
|
||||
};
|
||||
|
||||
_fixture.ArrQueueIterator
|
||||
@@ -267,6 +266,7 @@ public class MalwareBlockerTests : IDisposable
|
||||
|
||||
var mockArrClient = new Mock<IArrClient>();
|
||||
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
|
||||
mockArrClient.Setup(x => x.HasContentId(It.IsAny<QueueRecord>())).Returns(true);
|
||||
|
||||
_fixture.ArrClientFactory
|
||||
.Setup(x => x.GetClient(InstanceType.Sonarr, It.IsAny<float>()))
|
||||
@@ -277,7 +277,9 @@ public class MalwareBlockerTests : IDisposable
|
||||
Id = 1,
|
||||
DownloadId = "torrent-download-id",
|
||||
Title = "Torrent Download",
|
||||
Protocol = "torrent"
|
||||
Protocol = "torrent",
|
||||
SeriesId = 1,
|
||||
EpisodeId = 1
|
||||
};
|
||||
|
||||
_fixture.ArrQueueIterator
|
||||
@@ -325,6 +327,7 @@ public class MalwareBlockerTests : IDisposable
|
||||
|
||||
var mockArrClient = new Mock<IArrClient>();
|
||||
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
|
||||
mockArrClient.Setup(x => x.HasContentId(It.IsAny<QueueRecord>())).Returns(true);
|
||||
|
||||
_fixture.ArrClientFactory
|
||||
.Setup(x => x.GetClient(InstanceType.Sonarr, It.IsAny<float>()))
|
||||
@@ -401,6 +404,7 @@ public class MalwareBlockerTests : IDisposable
|
||||
|
||||
var mockArrClient = new Mock<IArrClient>();
|
||||
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
|
||||
mockArrClient.Setup(x => x.HasContentId(It.IsAny<QueueRecord>())).Returns(true);
|
||||
|
||||
_fixture.ArrClientFactory
|
||||
.Setup(x => x.GetClient(InstanceType.Sonarr, It.IsAny<float>()))
|
||||
@@ -472,6 +476,7 @@ public class MalwareBlockerTests : IDisposable
|
||||
|
||||
var mockArrClient = new Mock<IArrClient>();
|
||||
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
|
||||
mockArrClient.Setup(x => x.HasContentId(It.IsAny<QueueRecord>())).Returns(true);
|
||||
|
||||
_fixture.ArrClientFactory
|
||||
.Setup(x => x.GetClient(InstanceType.Sonarr, It.IsAny<float>()))
|
||||
@@ -482,7 +487,9 @@ public class MalwareBlockerTests : IDisposable
|
||||
Id = 1,
|
||||
DownloadId = "missing-download-id",
|
||||
Title = "Missing Download",
|
||||
Protocol = "torrent"
|
||||
Protocol = "torrent",
|
||||
SeriesId = 1,
|
||||
EpisodeId = 1
|
||||
};
|
||||
|
||||
_fixture.ArrQueueIterator
|
||||
@@ -526,6 +533,142 @@ public class MalwareBlockerTests : IDisposable
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessInstanceAsync_SkipsItem_WhenMissingContentId_AndProcessNoContentIdIsFalse()
|
||||
{
|
||||
// Arrange
|
||||
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
|
||||
EnableSonarrBlocklist();
|
||||
TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
|
||||
|
||||
var mockArrClient = new Mock<IArrClient>();
|
||||
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
|
||||
mockArrClient.Setup(x => x.HasContentId(It.IsAny<QueueRecord>())).Returns(false);
|
||||
|
||||
_fixture.ArrClientFactory
|
||||
.Setup(x => x.GetClient(InstanceType.Sonarr, It.IsAny<float>()))
|
||||
.Returns(mockArrClient.Object);
|
||||
|
||||
var queueRecord = new QueueRecord
|
||||
{
|
||||
Id = 1,
|
||||
DownloadId = "no-content-id-download",
|
||||
Title = "No Content ID Download",
|
||||
Protocol = "torrent"
|
||||
};
|
||||
|
||||
_fixture.ArrQueueIterator
|
||||
.Setup(x => x.Iterate(
|
||||
It.IsAny<IArrClient>(),
|
||||
It.IsAny<ArrInstance>(),
|
||||
It.IsAny<Func<IReadOnlyList<QueueRecord>, Task>>()
|
||||
))
|
||||
.Returns(async (IArrClient client, ArrInstance instance, Func<IReadOnlyList<QueueRecord>, Task> callback) =>
|
||||
{
|
||||
await callback([queueRecord]);
|
||||
});
|
||||
|
||||
var sut = CreateSut();
|
||||
|
||||
// Act
|
||||
await sut.ExecuteAsync();
|
||||
|
||||
// Assert
|
||||
_logger.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Information,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("skip | item is missing the content id")),
|
||||
It.IsAny<Exception?>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()
|
||||
),
|
||||
Times.Once
|
||||
);
|
||||
|
||||
_fixture.MessageBus.Verify(
|
||||
x => x.Publish(
|
||||
It.IsAny<QueueItemRemoveRequest<SeriesSearchItem>>(),
|
||||
It.IsAny<CancellationToken>()
|
||||
),
|
||||
Times.Never
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessInstanceAsync_WhenMissingContentId_AndProcessNoContentIdIsTrue_PublishesRemoveRequestWithSkipSearch()
|
||||
{
|
||||
// Arrange
|
||||
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
|
||||
EnableSonarrBlocklist();
|
||||
TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
|
||||
|
||||
var contentBlockerConfig = _fixture.DataContext.ContentBlockerConfigs.First();
|
||||
contentBlockerConfig.ProcessNoContentId = true;
|
||||
_fixture.DataContext.SaveChanges();
|
||||
|
||||
var mockArrClient = new Mock<IArrClient>();
|
||||
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
|
||||
mockArrClient.Setup(x => x.HasContentId(It.IsAny<QueueRecord>())).Returns(false);
|
||||
|
||||
_fixture.ArrClientFactory
|
||||
.Setup(x => x.GetClient(InstanceType.Sonarr, It.IsAny<float>()))
|
||||
.Returns(mockArrClient.Object);
|
||||
|
||||
var queueRecord = new QueueRecord
|
||||
{
|
||||
Id = 1,
|
||||
DownloadId = "no-content-id-download",
|
||||
Title = "No Content ID Download",
|
||||
Protocol = "torrent"
|
||||
};
|
||||
|
||||
_fixture.ArrQueueIterator
|
||||
.Setup(x => x.Iterate(
|
||||
It.IsAny<IArrClient>(),
|
||||
It.IsAny<ArrInstance>(),
|
||||
It.IsAny<Func<IReadOnlyList<QueueRecord>, Task>>()
|
||||
))
|
||||
.Returns(async (IArrClient client, ArrInstance instance, Func<IReadOnlyList<QueueRecord>, Task> callback) =>
|
||||
{
|
||||
await callback([queueRecord]);
|
||||
});
|
||||
|
||||
var mockDownloadService = _fixture.CreateMockDownloadService();
|
||||
mockDownloadService
|
||||
.Setup(x => x.BlockUnwantedFilesAsync(
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<List<string>>()
|
||||
))
|
||||
.ReturnsAsync(new BlockFilesResult
|
||||
{
|
||||
Found = true,
|
||||
ShouldRemove = true,
|
||||
IsPrivate = false,
|
||||
DeleteReason = DeleteReason.AllFilesBlocked
|
||||
});
|
||||
|
||||
_fixture.DownloadServiceFactory
|
||||
.Setup(x => x.GetDownloadService(It.IsAny<DownloadClientConfig>()))
|
||||
.Returns(mockDownloadService.Object);
|
||||
|
||||
var sut = CreateSut();
|
||||
|
||||
// Act
|
||||
await sut.ExecuteAsync();
|
||||
|
||||
// Assert - SkipSearch must be true because the item has no content ID
|
||||
_fixture.MessageBus.Verify(
|
||||
x => x.Publish(
|
||||
It.Is<QueueItemRemoveRequest<SeriesSearchItem>>(r =>
|
||||
r.SkipSearch == true &&
|
||||
r.DeleteReason == DeleteReason.AllFilesBlocked
|
||||
),
|
||||
It.IsAny<CancellationToken>()
|
||||
),
|
||||
Times.Once
|
||||
);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Error Handling Tests
|
||||
@@ -540,6 +683,7 @@ public class MalwareBlockerTests : IDisposable
|
||||
|
||||
var mockArrClient = new Mock<IArrClient>();
|
||||
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
|
||||
mockArrClient.Setup(x => x.HasContentId(It.IsAny<QueueRecord>())).Returns(true);
|
||||
|
||||
_fixture.ArrClientFactory
|
||||
.Setup(x => x.GetClient(InstanceType.Sonarr, It.IsAny<float>()))
|
||||
@@ -550,7 +694,9 @@ public class MalwareBlockerTests : IDisposable
|
||||
Id = 1,
|
||||
DownloadId = "error-download-id",
|
||||
Title = "Error Download",
|
||||
Protocol = "torrent"
|
||||
Protocol = "torrent",
|
||||
SeriesId = 1,
|
||||
EpisodeId = 1
|
||||
};
|
||||
|
||||
_fixture.ArrQueueIterator
|
||||
@@ -605,5 +751,32 @@ public class MalwareBlockerTests : IDisposable
|
||||
_fixture.DataContext.SaveChanges();
|
||||
}
|
||||
|
||||
private void EnableBlocklist(InstanceType instanceType)
|
||||
{
|
||||
var config = _fixture.DataContext.ContentBlockerConfigs.First();
|
||||
var settings = new BlocklistSettings { Enabled = true };
|
||||
switch (instanceType)
|
||||
{
|
||||
case InstanceType.Radarr: config.Radarr = settings; break;
|
||||
case InstanceType.Lidarr: config.Lidarr = settings; break;
|
||||
case InstanceType.Readarr: config.Readarr = settings; break;
|
||||
case InstanceType.Whisparr: config.Whisparr = settings; break;
|
||||
default: throw new ArgumentOutOfRangeException(nameof(instanceType));
|
||||
}
|
||||
_fixture.DataContext.SaveChanges();
|
||||
}
|
||||
|
||||
private void AddArrInstance(InstanceType instanceType)
|
||||
{
|
||||
switch (instanceType)
|
||||
{
|
||||
case InstanceType.Radarr: TestDataContextFactory.AddRadarrInstance(_fixture.DataContext); break;
|
||||
case InstanceType.Lidarr: TestDataContextFactory.AddLidarrInstance(_fixture.DataContext); break;
|
||||
case InstanceType.Readarr: TestDataContextFactory.AddReadarrInstance(_fixture.DataContext); break;
|
||||
case InstanceType.Whisparr: TestDataContextFactory.AddWhisparrInstance(_fixture.DataContext); break;
|
||||
default: throw new ArgumentOutOfRangeException(nameof(instanceType));
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -220,6 +220,7 @@ public class QueueCleanerTests : IDisposable
|
||||
|
||||
var mockArrClient = new Mock<IArrClient>();
|
||||
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
|
||||
mockArrClient.Setup(x => x.HasContentId(It.IsAny<QueueRecord>())).Returns(true);
|
||||
|
||||
_fixture.ArrClientFactory
|
||||
.Setup(x => x.GetClient(InstanceType.Sonarr, It.IsAny<float>()))
|
||||
@@ -230,7 +231,9 @@ public class QueueCleanerTests : IDisposable
|
||||
Id = 1,
|
||||
DownloadId = "ignored-download-id",
|
||||
Title = "Ignored Download",
|
||||
Protocol = "torrent"
|
||||
Protocol = "torrent",
|
||||
SeriesId = 1,
|
||||
EpisodeId = 1
|
||||
};
|
||||
|
||||
_fixture.ArrQueueIterator
|
||||
@@ -275,6 +278,7 @@ public class QueueCleanerTests : IDisposable
|
||||
|
||||
var mockArrClient = new Mock<IArrClient>();
|
||||
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
|
||||
mockArrClient.Setup(x => x.HasContentId(It.IsAny<QueueRecord>())).Returns(true);
|
||||
|
||||
_fixture.ArrClientFactory
|
||||
.Setup(x => x.GetClient(InstanceType.Sonarr, It.IsAny<float>()))
|
||||
@@ -285,7 +289,9 @@ public class QueueCleanerTests : IDisposable
|
||||
Id = 1,
|
||||
DownloadId = "cached-download-id",
|
||||
Title = "Cached Download",
|
||||
Protocol = "torrent"
|
||||
Protocol = "torrent",
|
||||
SeriesId = 1,
|
||||
EpisodeId = 1
|
||||
};
|
||||
|
||||
_fixture.ArrQueueIterator
|
||||
@@ -326,6 +332,7 @@ public class QueueCleanerTests : IDisposable
|
||||
|
||||
var mockArrClient = new Mock<IArrClient>();
|
||||
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
|
||||
mockArrClient.Setup(x => x.HasContentId(It.IsAny<QueueRecord>())).Returns(true);
|
||||
mockArrClient.Setup(x => x.ShouldRemoveFromQueue(
|
||||
It.IsAny<InstanceType>(),
|
||||
It.IsAny<QueueRecord>(),
|
||||
@@ -342,7 +349,9 @@ public class QueueCleanerTests : IDisposable
|
||||
Id = 1,
|
||||
DownloadId = "torrent-download-id",
|
||||
Title = "Torrent Download",
|
||||
Protocol = "torrent"
|
||||
Protocol = "torrent",
|
||||
SeriesId = 1,
|
||||
EpisodeId = 1
|
||||
};
|
||||
|
||||
_fixture.ArrQueueIterator
|
||||
@@ -389,6 +398,7 @@ public class QueueCleanerTests : IDisposable
|
||||
|
||||
var mockArrClient = new Mock<IArrClient>();
|
||||
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
|
||||
mockArrClient.Setup(x => x.HasContentId(It.IsAny<QueueRecord>())).Returns(true);
|
||||
|
||||
_fixture.ArrClientFactory
|
||||
.Setup(x => x.GetClient(InstanceType.Sonarr, It.IsAny<float>()))
|
||||
@@ -458,6 +468,7 @@ public class QueueCleanerTests : IDisposable
|
||||
|
||||
var mockArrClient = new Mock<IArrClient>();
|
||||
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
|
||||
mockArrClient.Setup(x => x.HasContentId(It.IsAny<QueueRecord>())).Returns(true);
|
||||
mockArrClient.Setup(x => x.ShouldRemoveFromQueue(
|
||||
It.IsAny<InstanceType>(),
|
||||
It.IsAny<QueueRecord>(),
|
||||
@@ -474,7 +485,9 @@ public class QueueCleanerTests : IDisposable
|
||||
Id = 1,
|
||||
DownloadId = "missing-download-id",
|
||||
Title = "Missing Download",
|
||||
Protocol = "torrent"
|
||||
Protocol = "torrent",
|
||||
SeriesId = 1,
|
||||
EpisodeId = 1
|
||||
};
|
||||
|
||||
_fixture.ArrQueueIterator
|
||||
@@ -527,6 +540,7 @@ public class QueueCleanerTests : IDisposable
|
||||
|
||||
var mockArrClient = new Mock<IArrClient>();
|
||||
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
|
||||
mockArrClient.Setup(x => x.HasContentId(It.IsAny<QueueRecord>())).Returns(true);
|
||||
mockArrClient.Setup(x => x.ShouldRemoveFromQueue(
|
||||
It.IsAny<InstanceType>(),
|
||||
It.IsAny<QueueRecord>(),
|
||||
@@ -543,7 +557,9 @@ public class QueueCleanerTests : IDisposable
|
||||
Id = 1,
|
||||
DownloadId = "download-id",
|
||||
Title = "Test Download",
|
||||
Protocol = "torrent"
|
||||
Protocol = "torrent",
|
||||
SeriesId = 1,
|
||||
EpisodeId = 1
|
||||
};
|
||||
|
||||
_fixture.ArrQueueIterator
|
||||
@@ -595,6 +611,7 @@ public class QueueCleanerTests : IDisposable
|
||||
|
||||
var mockArrClient = new Mock<IArrClient>();
|
||||
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
|
||||
mockArrClient.Setup(x => x.HasContentId(It.IsAny<QueueRecord>())).Returns(true);
|
||||
mockArrClient.Setup(x => x.ShouldRemoveFromQueue(
|
||||
It.IsAny<InstanceType>(),
|
||||
It.IsAny<QueueRecord>(),
|
||||
@@ -656,6 +673,147 @@ public class QueueCleanerTests : IDisposable
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessInstanceAsync_SkipsItem_WhenMissingContentId_AndProcessNoContentIdIsFalse()
|
||||
{
|
||||
// Arrange
|
||||
TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
|
||||
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
|
||||
|
||||
var mockArrClient = new Mock<IArrClient>();
|
||||
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
|
||||
mockArrClient.Setup(x => x.HasContentId(It.IsAny<QueueRecord>())).Returns(false);
|
||||
|
||||
_fixture.ArrClientFactory
|
||||
.Setup(x => x.GetClient(InstanceType.Sonarr, It.IsAny<float>()))
|
||||
.Returns(mockArrClient.Object);
|
||||
|
||||
var queueRecord = new QueueRecord
|
||||
{
|
||||
Id = 1,
|
||||
DownloadId = "no-content-id-download",
|
||||
Title = "No Content ID Download",
|
||||
Protocol = "torrent"
|
||||
};
|
||||
|
||||
_fixture.ArrQueueIterator
|
||||
.Setup(x => x.Iterate(
|
||||
It.IsAny<IArrClient>(),
|
||||
It.IsAny<ArrInstance>(),
|
||||
It.IsAny<Func<IReadOnlyList<QueueRecord>, Task>>()
|
||||
))
|
||||
.Returns(async (IArrClient client, ArrInstance instance, Func<IReadOnlyList<QueueRecord>, Task> callback) =>
|
||||
{
|
||||
await callback([queueRecord]);
|
||||
});
|
||||
|
||||
var sut = CreateSut();
|
||||
|
||||
// Act
|
||||
await sut.ExecuteAsync();
|
||||
|
||||
// Assert
|
||||
_logger.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Information,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("skip | item is missing the content id")),
|
||||
It.IsAny<Exception?>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()
|
||||
),
|
||||
Times.Once
|
||||
);
|
||||
|
||||
_fixture.MessageBus.Verify(
|
||||
x => x.Publish(
|
||||
It.IsAny<QueueItemRemoveRequest<SeriesSearchItem>>(),
|
||||
It.IsAny<CancellationToken>()
|
||||
),
|
||||
Times.Never
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessInstanceAsync_WhenMissingContentId_AndProcessNoContentIdIsTrue_PublishesRemoveRequestWithSkipSearch()
|
||||
{
|
||||
// Arrange
|
||||
TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
|
||||
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
|
||||
|
||||
var queueCleanerConfig = _fixture.DataContext.QueueCleanerConfigs.First();
|
||||
queueCleanerConfig.ProcessNoContentId = true;
|
||||
_fixture.DataContext.SaveChanges();
|
||||
|
||||
var mockArrClient = new Mock<IArrClient>();
|
||||
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
|
||||
mockArrClient.Setup(x => x.HasContentId(It.IsAny<QueueRecord>())).Returns(false);
|
||||
mockArrClient.Setup(x => x.ShouldRemoveFromQueue(
|
||||
It.IsAny<InstanceType>(),
|
||||
It.IsAny<QueueRecord>(),
|
||||
It.IsAny<bool>(),
|
||||
It.IsAny<short>()
|
||||
)).ReturnsAsync(false);
|
||||
|
||||
_fixture.ArrClientFactory
|
||||
.Setup(x => x.GetClient(InstanceType.Sonarr, It.IsAny<float>()))
|
||||
.Returns(mockArrClient.Object);
|
||||
|
||||
var queueRecord = new QueueRecord
|
||||
{
|
||||
Id = 1,
|
||||
DownloadId = "no-content-id-download",
|
||||
Title = "No Content ID Download",
|
||||
Protocol = "torrent"
|
||||
};
|
||||
|
||||
_fixture.ArrQueueIterator
|
||||
.Setup(x => x.Iterate(
|
||||
It.IsAny<IArrClient>(),
|
||||
It.IsAny<ArrInstance>(),
|
||||
It.IsAny<Func<IReadOnlyList<QueueRecord>, Task>>()
|
||||
))
|
||||
.Returns(async (IArrClient client, ArrInstance instance, Func<IReadOnlyList<QueueRecord>, Task> callback) =>
|
||||
{
|
||||
await callback([queueRecord]);
|
||||
});
|
||||
|
||||
var mockDownloadService = _fixture.CreateMockDownloadService();
|
||||
mockDownloadService
|
||||
.Setup(x => x.ShouldRemoveFromArrQueueAsync(
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<List<string>>()
|
||||
))
|
||||
.ReturnsAsync(new DownloadCheckResult
|
||||
{
|
||||
Found = true,
|
||||
ShouldRemove = true,
|
||||
IsPrivate = false,
|
||||
DeleteFromClient = true,
|
||||
DeleteReason = DeleteReason.Stalled
|
||||
});
|
||||
|
||||
_fixture.DownloadServiceFactory
|
||||
.Setup(x => x.GetDownloadService(It.IsAny<DownloadClientConfig>()))
|
||||
.Returns(mockDownloadService.Object);
|
||||
|
||||
var sut = CreateSut();
|
||||
|
||||
// Act
|
||||
await sut.ExecuteAsync();
|
||||
|
||||
// Assert - SkipSearch must be true because the item has no content ID
|
||||
_fixture.MessageBus.Verify(
|
||||
x => x.Publish(
|
||||
It.Is<QueueItemRemoveRequest<SeriesSearchItem>>(r =>
|
||||
r.SkipSearch == true &&
|
||||
r.DeleteReason == DeleteReason.Stalled
|
||||
),
|
||||
It.IsAny<CancellationToken>()
|
||||
),
|
||||
Times.Once
|
||||
);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Error Handling Tests
|
||||
@@ -669,6 +827,7 @@ public class QueueCleanerTests : IDisposable
|
||||
|
||||
var mockArrClient = new Mock<IArrClient>();
|
||||
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
|
||||
mockArrClient.Setup(x => x.HasContentId(It.IsAny<QueueRecord>())).Returns(true);
|
||||
mockArrClient.Setup(x => x.ShouldRemoveFromQueue(
|
||||
It.IsAny<InstanceType>(),
|
||||
It.IsAny<QueueRecord>(),
|
||||
@@ -685,7 +844,9 @@ public class QueueCleanerTests : IDisposable
|
||||
Id = 1,
|
||||
DownloadId = "error-download-id",
|
||||
Title = "Error Download",
|
||||
Protocol = "torrent"
|
||||
Protocol = "torrent",
|
||||
SeriesId = 1,
|
||||
EpisodeId = 1
|
||||
};
|
||||
|
||||
_fixture.ArrQueueIterator
|
||||
@@ -744,6 +905,7 @@ public class QueueCleanerTests : IDisposable
|
||||
|
||||
var mockArrClient = new Mock<IArrClient>();
|
||||
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
|
||||
mockArrClient.Setup(x => x.HasContentId(It.IsAny<QueueRecord>())).Returns(true);
|
||||
|
||||
_fixture.ArrClientFactory
|
||||
.Setup(x => x.GetClient(InstanceType.Radarr, It.IsAny<float>()))
|
||||
@@ -833,6 +995,7 @@ public class QueueCleanerTests : IDisposable
|
||||
|
||||
var mockArrClient = new Mock<IArrClient>();
|
||||
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
|
||||
mockArrClient.Setup(x => x.HasContentId(It.IsAny<QueueRecord>())).Returns(true);
|
||||
|
||||
_fixture.ArrClientFactory
|
||||
.Setup(x => x.GetClient(InstanceType.Radarr, It.IsAny<float>()))
|
||||
@@ -905,6 +1068,7 @@ public class QueueCleanerTests : IDisposable
|
||||
|
||||
var mockArrClient = new Mock<IArrClient>();
|
||||
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
|
||||
mockArrClient.Setup(x => x.HasContentId(It.IsAny<QueueRecord>())).Returns(true);
|
||||
|
||||
_fixture.ArrClientFactory
|
||||
.Setup(x => x.GetClient(InstanceType.Lidarr, It.IsAny<float>()))
|
||||
@@ -977,6 +1141,7 @@ public class QueueCleanerTests : IDisposable
|
||||
|
||||
var mockArrClient = new Mock<IArrClient>();
|
||||
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
|
||||
mockArrClient.Setup(x => x.HasContentId(It.IsAny<QueueRecord>())).Returns(true);
|
||||
|
||||
_fixture.ArrClientFactory
|
||||
.Setup(x => x.GetClient(InstanceType.Readarr, It.IsAny<float>()))
|
||||
@@ -1049,6 +1214,7 @@ public class QueueCleanerTests : IDisposable
|
||||
|
||||
var mockArrClient = new Mock<IArrClient>();
|
||||
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
|
||||
mockArrClient.Setup(x => x.HasContentId(It.IsAny<QueueRecord>())).Returns(true);
|
||||
|
||||
_fixture.ArrClientFactory
|
||||
.Setup(x => x.GetClient(InstanceType.Whisparr, 2f))
|
||||
@@ -1124,6 +1290,7 @@ public class QueueCleanerTests : IDisposable
|
||||
|
||||
var mockArrClient = new Mock<IArrClient>();
|
||||
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
|
||||
mockArrClient.Setup(x => x.HasContentId(It.IsAny<QueueRecord>())).Returns(true);
|
||||
|
||||
_fixture.ArrClientFactory
|
||||
.Setup(x => x.GetClient(InstanceType.Whisparr, 3f))
|
||||
@@ -1196,6 +1363,7 @@ public class QueueCleanerTests : IDisposable
|
||||
|
||||
var mockArrClient = new Mock<IArrClient>();
|
||||
mockArrClient.Setup(x => x.IsRecordValid(It.IsAny<QueueRecord>())).Returns(true);
|
||||
mockArrClient.Setup(x => x.HasContentId(It.IsAny<QueueRecord>())).Returns(true);
|
||||
|
||||
_fixture.ArrClientFactory
|
||||
.Setup(x => x.GetClient(InstanceType.Whisparr, 2f))
|
||||
|
||||
-1
@@ -75,7 +75,6 @@ public static class TestDataContextFactory
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
IgnoredDownloads = [],
|
||||
DeleteKnownMalware = false,
|
||||
DeletePrivate = false,
|
||||
Sonarr = new BlocklistSettings { Enabled = false },
|
||||
Radarr = new BlocklistSettings { Enabled = false },
|
||||
|
||||
-28
@@ -118,34 +118,6 @@ public class BlocklistProviderTests : IDisposable
|
||||
result.Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMalwarePatterns_NotInCache_ReturnsEmptyBag()
|
||||
{
|
||||
// Act
|
||||
var result = _provider.GetMalwarePatterns();
|
||||
|
||||
// Assert
|
||||
result.ShouldNotBeNull();
|
||||
result.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMalwarePatterns_InCache_ReturnsCachedPatterns()
|
||||
{
|
||||
// Arrange
|
||||
var patterns = new ConcurrentBag<string> { "known_malware.exe", "trojan*", "virus.dll" };
|
||||
_cache.Set(CacheKeys.KnownMalwarePatterns(), patterns);
|
||||
|
||||
// Act
|
||||
var result = _provider.GetMalwarePatterns();
|
||||
|
||||
// Assert
|
||||
result.Count.ShouldBe(3);
|
||||
result.ShouldContain("known_malware.exe");
|
||||
result.ShouldContain("trojan*");
|
||||
result.ShouldContain("virus.dll");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(InstanceType.Sonarr)]
|
||||
[InlineData(InstanceType.Radarr)]
|
||||
|
||||
+2
-2
@@ -271,12 +271,12 @@ public class NotificationPublisherTests
|
||||
.Returns(providerMock.Object);
|
||||
|
||||
// Act
|
||||
await _publisher.NotifyQueueItemDeleted(false, DeleteReason.MalwareFileFound);
|
||||
await _publisher.NotifyQueueItemDeleted(false, DeleteReason.AllFilesBlocked);
|
||||
|
||||
// Assert
|
||||
providerMock.Verify(p => p.SendNotificationAsync(It.Is<NotificationContext>(
|
||||
c => c.Data["Removed from client?"] == "False" &&
|
||||
c.Data["Reason"] == "MalwareFileFound")), Times.Once);
|
||||
c.Data["Reason"] == "AllFilesBlocked")), Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
@@ -40,10 +40,8 @@ public class StrikerTests : IDisposable
|
||||
var notificationPublisher = Substitute.For<INotificationPublisher>();
|
||||
var dryRunInterceptor = Substitute.For<IDryRunInterceptor>();
|
||||
|
||||
// Configure dry run interceptor to just complete the task (we don't need actual DB saves in tests)
|
||||
dryRunInterceptor
|
||||
.InterceptAsync(Arg.Any<Delegate>(), Arg.Any<object[]>())
|
||||
.Returns(Task.CompletedTask);
|
||||
// Configure dry run interceptor to report dry run as disabled by default
|
||||
dryRunInterceptor.IsDryRunEnabled().Returns(false);
|
||||
|
||||
_eventPublisher = new EventPublisher(
|
||||
eventsContext,
|
||||
@@ -52,7 +50,7 @@ public class StrikerTests : IDisposable
|
||||
notificationPublisher,
|
||||
dryRunInterceptor);
|
||||
|
||||
_striker = new Striker(_logger, _strikerContext, _eventPublisher);
|
||||
_striker = new Striker(_logger, _strikerContext, _eventPublisher, dryRunInterceptor);
|
||||
|
||||
// Clear static state before each test
|
||||
Striker.RecurringHashes.Clear();
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
<PackageReference Include="Mapster" Version="7.4.0" />
|
||||
<PackageReference Include="MassTransit.Abstractions" Version="8.5.7" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.SignalR" Version="1.2.0" />
|
||||
<PackageReference Include="Microsoft.IdentityModel.Protocols.OpenIdConnect" Version="8.7.0" />
|
||||
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.7.0" />
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.7.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.1" />
|
||||
|
||||
@@ -43,7 +43,7 @@ public class EventPublisher : IEventPublisher
|
||||
/// <summary>
|
||||
/// Generic method for publishing events to database and SignalR clients
|
||||
/// </summary>
|
||||
public async Task PublishAsync(EventType eventType, string message, EventSeverity severity, object? data = null, Guid? trackingId = null, Guid? strikeId = null)
|
||||
public async Task PublishAsync(EventType eventType, string message, EventSeverity severity, object? data = null, Guid? trackingId = null, Guid? strikeId = null, bool? isDryRun = null)
|
||||
{
|
||||
AppEvent eventEntity = new()
|
||||
{
|
||||
@@ -63,16 +63,15 @@ public class EventPublisher : IEventPublisher
|
||||
DownloadClientName = ContextProvider.Get(ContextProvider.Keys.DownloadClientName) as string,
|
||||
};
|
||||
|
||||
// Save to database with dry run interception
|
||||
await _dryRunInterceptor.InterceptAsync(SaveEventToDatabase, eventEntity);
|
||||
eventEntity.IsDryRun = isDryRun ?? await _dryRunInterceptor.IsDryRunEnabled();
|
||||
await SaveEventToDatabase(eventEntity);
|
||||
|
||||
// Always send to SignalR clients (not affected by dry run)
|
||||
await NotifyClientsAsync(eventEntity);
|
||||
|
||||
_logger.LogTrace("Published event: {eventType}", eventType);
|
||||
}
|
||||
|
||||
public async Task PublishManualAsync(string message, EventSeverity severity, object? data = null)
|
||||
public async Task PublishManualAsync(string message, EventSeverity severity, object? data = null, bool? isDryRun = null)
|
||||
{
|
||||
ManualEvent eventEntity = new()
|
||||
{
|
||||
@@ -89,10 +88,9 @@ public class EventPublisher : IEventPublisher
|
||||
DownloadClientName = ContextProvider.Get(ContextProvider.Keys.DownloadClientName) as string,
|
||||
};
|
||||
|
||||
// Save to database with dry run interception
|
||||
await _dryRunInterceptor.InterceptAsync(SaveManualEventToDatabase, eventEntity);
|
||||
eventEntity.IsDryRun = isDryRun ?? await _dryRunInterceptor.IsDryRunEnabled();
|
||||
await SaveManualEventToDatabase(eventEntity);
|
||||
|
||||
// Always send to SignalR clients (not affected by dry run)
|
||||
await NotifyClientsAsync(eventEntity);
|
||||
|
||||
_logger.LogTrace("Published manual event: {message}", message);
|
||||
@@ -139,16 +137,19 @@ public class EventPublisher : IEventPublisher
|
||||
};
|
||||
}
|
||||
|
||||
bool isDryRun = await _dryRunInterceptor.IsDryRunEnabled();
|
||||
|
||||
// Publish the event
|
||||
await PublishAsync(
|
||||
eventType,
|
||||
$"Item '{itemName}' has been struck {strikeCount} times for reason '{strikeType}'",
|
||||
EventSeverity.Important,
|
||||
data: data,
|
||||
strikeId: strikeId);
|
||||
strikeId: strikeId,
|
||||
isDryRun: isDryRun);
|
||||
|
||||
// Broadcast strike to SignalR clients for real-time dashboard updates
|
||||
await BroadcastStrikeAsync(strikeId, strikeType, hash, itemName);
|
||||
await BroadcastStrikeAsync(strikeId, strikeType, hash, itemName, isDryRun);
|
||||
|
||||
// Send notification (uses ContextProvider internally)
|
||||
await _notificationPublisher.NotifyStrike(strikeType, strikeCount);
|
||||
@@ -232,7 +233,7 @@ public class EventPublisher : IEventPublisher
|
||||
public async Task PublishSearchNotTriggered(string hash, string itemName)
|
||||
{
|
||||
await PublishManualAsync(
|
||||
"Replacement search was not triggered after removal because the item keeps coming back\nPlease trigger a manual search if needed",
|
||||
"Replacement search was not triggered after removal\nPlease trigger a manual search if needed",
|
||||
EventSeverity.Warning,
|
||||
data: new { itemName, hash }
|
||||
);
|
||||
@@ -276,7 +277,7 @@ public class EventPublisher : IEventPublisher
|
||||
}
|
||||
}
|
||||
|
||||
private async Task BroadcastStrikeAsync(Guid? strikeId, StrikeType strikeType, string hash, string itemName)
|
||||
private async Task BroadcastStrikeAsync(Guid? strikeId, StrikeType strikeType, string hash, string itemName, bool isDryRun)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -287,6 +288,7 @@ public class EventPublisher : IEventPublisher
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
DownloadId = hash,
|
||||
Title = itemName,
|
||||
IsDryRun = isDryRun,
|
||||
};
|
||||
await _appHubContext.Clients.All.SendAsync("StrikeReceived", strike);
|
||||
}
|
||||
|
||||
@@ -4,9 +4,9 @@ namespace Cleanuparr.Infrastructure.Events.Interfaces;
|
||||
|
||||
public interface IEventPublisher
|
||||
{
|
||||
Task PublishAsync(EventType eventType, string message, EventSeverity severity, object? data = null, Guid? trackingId = null, Guid? strikeId = null);
|
||||
Task PublishAsync(EventType eventType, string message, EventSeverity severity, object? data = null, Guid? trackingId = null, Guid? strikeId = null, bool? isDryRun = null);
|
||||
|
||||
Task PublishManualAsync(string message, EventSeverity severity, object? data = null);
|
||||
Task PublishManualAsync(string message, EventSeverity severity, object? data = null, bool? isDryRun = null);
|
||||
|
||||
Task PublishStrike(StrikeType strikeType, int strikeCount, string hash, string itemName, Guid? strikeId = null);
|
||||
|
||||
|
||||
@@ -76,20 +76,28 @@ public abstract class ArrClient : IArrClient
|
||||
return false;
|
||||
}
|
||||
|
||||
bool hasWarn() => record.TrackedDownloadStatus
|
||||
bool HasWarn() => record.TrackedDownloadStatus
|
||||
.Equals("warning", StringComparison.InvariantCultureIgnoreCase);
|
||||
bool isImportBlocked() => record.TrackedDownloadState
|
||||
bool IsImportBlocked() => record.TrackedDownloadState
|
||||
.Equals("importBlocked", StringComparison.InvariantCultureIgnoreCase);
|
||||
bool isImportPending() => record.TrackedDownloadState
|
||||
bool IsImportPending() => record.TrackedDownloadState
|
||||
.Equals("importPending", StringComparison.InvariantCultureIgnoreCase);
|
||||
bool isImportFailed() => record.TrackedDownloadState
|
||||
bool IsImportFailed() => record.TrackedDownloadState
|
||||
.Equals("importFailed", StringComparison.InvariantCultureIgnoreCase);
|
||||
bool isFailedLidarr() => instanceType is InstanceType.Lidarr &&
|
||||
bool IsFailedLidarr() => instanceType is InstanceType.Lidarr &&
|
||||
(record.Status.Equals("failed", StringComparison.InvariantCultureIgnoreCase) ||
|
||||
record.Status.Equals("completed", StringComparison.InvariantCultureIgnoreCase)) &&
|
||||
hasWarn();
|
||||
HasWarn();
|
||||
bool IsDownloading() => record.TrackedDownloadState
|
||||
.Equals("downloading", StringComparison.InvariantCultureIgnoreCase);
|
||||
bool HasFailedImportMessage() => record.StatusMessages
|
||||
?.Any(status => status.Messages
|
||||
?.Any(message => message.StartsWith("Unable to import automatically", StringComparison.InvariantCultureIgnoreCase)) is true
|
||||
) is true;
|
||||
bool IsEdgeCase() => IsDownloading() && HasFailedImportMessage();
|
||||
|
||||
|
||||
if (hasWarn() && (isImportBlocked() || isImportPending() || isImportFailed()) || isFailedLidarr())
|
||||
if (HasWarn() && (IsImportBlocked() || IsImportPending() || IsImportFailed()) || IsFailedLidarr() || IsEdgeCase())
|
||||
{
|
||||
if (!ShouldStrikeFailedImport(queueCleanerConfig, record))
|
||||
{
|
||||
@@ -117,6 +125,8 @@ public abstract class ArrClient : IArrClient
|
||||
StrikeType.FailedImport
|
||||
);
|
||||
}
|
||||
|
||||
_logger.LogDebug("skip | not a failed import | {name}", record.Title);
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -158,7 +168,7 @@ public abstract class ArrClient : IArrClient
|
||||
|
||||
public abstract Task SearchItemsAsync(ArrInstance arrInstance, HashSet<SearchItem>? items);
|
||||
|
||||
public virtual bool IsRecordValid(QueueRecord record)
|
||||
public bool IsRecordValid(QueueRecord record)
|
||||
{
|
||||
if (string.IsNullOrEmpty(record.DownloadId))
|
||||
{
|
||||
@@ -169,6 +179,8 @@ public abstract class ArrClient : IArrClient
|
||||
return true;
|
||||
}
|
||||
|
||||
public abstract bool HasContentId(QueueRecord record);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task HealthCheckAsync(ArrInstance arrInstance)
|
||||
{
|
||||
|
||||
@@ -17,6 +17,13 @@ public interface IArrClient
|
||||
|
||||
bool IsRecordValid(QueueRecord record);
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether the record has an id (movie id, tv show id etc.)
|
||||
/// </summary>
|
||||
/// <param name="record">The record to check</param>
|
||||
/// <returns>True if the record has an id, false otherwise</returns>
|
||||
bool HasContentId(QueueRecord record);
|
||||
|
||||
/// <summary>
|
||||
/// Tests the connection to an Arr instance
|
||||
/// </summary>
|
||||
|
||||
@@ -87,16 +87,7 @@ public class LidarrClient : ArrClient, ILidarrClient
|
||||
}
|
||||
}
|
||||
|
||||
public override bool IsRecordValid(QueueRecord record)
|
||||
{
|
||||
if (record.ArtistId is 0 || record.AlbumId is 0)
|
||||
{
|
||||
_logger.LogDebug("skip | artist id and/or album id missing | {title}", record.Title);
|
||||
return false;
|
||||
}
|
||||
|
||||
return base.IsRecordValid(record);
|
||||
}
|
||||
public override bool HasContentId(QueueRecord record) => record.ArtistId is not 0 && record.AlbumId is not 0;
|
||||
|
||||
private static string GetSearchLog(
|
||||
Uri instanceUrl,
|
||||
|
||||
@@ -92,16 +92,7 @@ public class RadarrClient : ArrClient, IRadarrClient
|
||||
}
|
||||
}
|
||||
|
||||
public override bool IsRecordValid(QueueRecord record)
|
||||
{
|
||||
if (record.MovieId is 0)
|
||||
{
|
||||
_logger.LogDebug("skip | movie id missing | {title}", record.Title);
|
||||
return false;
|
||||
}
|
||||
|
||||
return base.IsRecordValid(record);
|
||||
}
|
||||
public override bool HasContentId(QueueRecord record) => record.MovieId is not 0;
|
||||
|
||||
private static string GetSearchLog(Uri instanceUrl, RadarrCommand command, bool success, string? logContext)
|
||||
{
|
||||
|
||||
@@ -92,16 +92,7 @@ public class ReadarrClient : ArrClient, IReadarrClient
|
||||
}
|
||||
}
|
||||
|
||||
public override bool IsRecordValid(QueueRecord record)
|
||||
{
|
||||
if (record.AuthorId is 0 || record.BookId is 0)
|
||||
{
|
||||
_logger.LogDebug("skip | author id and/or book id missing | {title}", record.Title);
|
||||
return false;
|
||||
}
|
||||
|
||||
return base.IsRecordValid(record);
|
||||
}
|
||||
public override bool HasContentId(QueueRecord record) => record.AuthorId is not 0 && record.BookId is not 0;
|
||||
|
||||
private static string GetSearchLog(Uri instanceUrl, ReadarrCommand command, bool success, string? logContext)
|
||||
{
|
||||
|
||||
@@ -90,16 +90,7 @@ public class SonarrClient : ArrClient, ISonarrClient
|
||||
}
|
||||
}
|
||||
|
||||
public override bool IsRecordValid(QueueRecord record)
|
||||
{
|
||||
if (record.EpisodeId is 0 || record.SeriesId is 0)
|
||||
{
|
||||
_logger.LogDebug("skip | episode id and/or series id missing | {title}", record.Title);
|
||||
return false;
|
||||
}
|
||||
|
||||
return base.IsRecordValid(record);
|
||||
}
|
||||
public override bool HasContentId(QueueRecord record) => record.EpisodeId is not 0 && record.SeriesId is not 0;
|
||||
|
||||
private static string GetSearchLog(
|
||||
SeriesSearchType searchType,
|
||||
|
||||
@@ -90,16 +90,7 @@ public class WhisparrV2Client : ArrClient, IWhisparrV2Client
|
||||
}
|
||||
}
|
||||
|
||||
public override bool IsRecordValid(QueueRecord record)
|
||||
{
|
||||
if (record.EpisodeId is 0 || record.SeriesId is 0)
|
||||
{
|
||||
_logger.LogDebug("skip | episode id and/or series id missing | {title}", record.Title);
|
||||
return false;
|
||||
}
|
||||
|
||||
return base.IsRecordValid(record);
|
||||
}
|
||||
public override bool HasContentId(QueueRecord record) => record.EpisodeId is not 0 && record.SeriesId is not 0;
|
||||
|
||||
private static string GetSearchLog(
|
||||
SeriesSearchType searchType,
|
||||
|
||||
@@ -93,16 +93,7 @@ public class WhisparrV3Client : ArrClient, IWhisparrV3Client
|
||||
}
|
||||
}
|
||||
|
||||
public override bool IsRecordValid(QueueRecord record)
|
||||
{
|
||||
if (record.MovieId is 0)
|
||||
{
|
||||
_logger.LogDebug("skip | movie id missing | {title}", record.Title);
|
||||
return false;
|
||||
}
|
||||
|
||||
return base.IsRecordValid(record);
|
||||
}
|
||||
public override bool HasContentId(QueueRecord record) => record.MovieId is not 0;
|
||||
|
||||
private static string GetSearchLog(Uri instanceUrl, WhisparrV3Command command, bool success, string? logContext)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
namespace Cleanuparr.Infrastructure.Features.Auth;
|
||||
|
||||
public sealed record OidcAuthorizationResult
|
||||
{
|
||||
public required string AuthorizationUrl { get; init; }
|
||||
public required string State { get; init; }
|
||||
}
|
||||
|
||||
public sealed record OidcCallbackResult
|
||||
{
|
||||
public required bool Success { get; init; }
|
||||
public string? Subject { get; init; }
|
||||
public string? PreferredUsername { get; init; }
|
||||
public string? Email { get; init; }
|
||||
public string? Error { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The user ID of the authenticated user who initiated this OIDC flow.
|
||||
/// Set when the flow is started from an authenticated context (e.g., account linking).
|
||||
/// Used to verify the callback is completing the correct user's flow.
|
||||
/// </summary>
|
||||
public string? InitiatorUserId { get; init; }
|
||||
}
|
||||
|
||||
public interface IOidcAuthService
|
||||
{
|
||||
/// <summary>
|
||||
/// Generates the OIDC authorization URL and stores state/verifier for the callback.
|
||||
/// </summary>
|
||||
/// <param name="redirectUri">The callback URI for the OIDC provider.</param>
|
||||
/// <param name="initiatorUserId">Optional user ID of the authenticated user initiating the flow (for account linking).</param>
|
||||
Task<OidcAuthorizationResult> StartAuthorization(string redirectUri, string? initiatorUserId = null);
|
||||
|
||||
/// <summary>
|
||||
/// Handles the OIDC callback: validates state, exchanges code for tokens, validates the ID token.
|
||||
/// </summary>
|
||||
Task<OidcCallbackResult> HandleCallback(string code, string state, string redirectUri);
|
||||
|
||||
/// <summary>
|
||||
/// Stores tokens associated with a one-time exchange code.
|
||||
/// Returns the one-time code.
|
||||
/// </summary>
|
||||
string StoreOneTimeCode(string accessToken, string refreshToken, int expiresIn);
|
||||
|
||||
/// <summary>
|
||||
/// Exchanges a one-time code for the stored tokens.
|
||||
/// The code is consumed (can only be used once).
|
||||
/// </summary>
|
||||
OidcTokenExchangeResult? ExchangeOneTimeCode(string code);
|
||||
}
|
||||
|
||||
public sealed record OidcTokenExchangeResult
|
||||
{
|
||||
public required string AccessToken { get; init; }
|
||||
public required string RefreshToken { get; init; }
|
||||
public required int ExpiresIn { get; init; }
|
||||
}
|
||||
@@ -2,6 +2,9 @@ namespace Cleanuparr.Infrastructure.Features.Auth;
|
||||
|
||||
public interface IPasswordService
|
||||
{
|
||||
string DummyHash { get; }
|
||||
|
||||
string HashPassword(string password);
|
||||
|
||||
bool VerifyPassword(string password, string hash);
|
||||
}
|
||||
@@ -0,0 +1,523 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Net.Http.Json;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Auth;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.IdentityModel.Protocols;
|
||||
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Features.Auth;
|
||||
|
||||
public sealed class OidcAuthService : IOidcAuthService
|
||||
{
|
||||
private const int MaxPendingFlows = 100;
|
||||
private const int MaxOneTimeCodes = 100;
|
||||
private static readonly TimeSpan FlowStateExpiry = TimeSpan.FromMinutes(10);
|
||||
private static readonly TimeSpan OneTimeCodeExpiry = TimeSpan.FromSeconds(30);
|
||||
|
||||
private static readonly ConcurrentDictionary<string, OidcFlowState> PendingFlows = new();
|
||||
private static readonly ConcurrentDictionary<string, OidcOneTimeCodeEntry> OneTimeCodes = new();
|
||||
private static readonly ConcurrentDictionary<string, ConfigurationManager<OpenIdConnectConfiguration>> ConfigManagers = new();
|
||||
|
||||
// Reference held to prevent GC collection; the timer fires CleanupExpiredEntries every minute
|
||||
#pragma warning disable IDE0052
|
||||
private static readonly Timer CleanupTimer = new(CleanupExpiredEntries, null, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(1));
|
||||
#pragma warning restore IDE0052
|
||||
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly UsersContext _usersContext;
|
||||
private readonly ILogger<OidcAuthService> _logger;
|
||||
|
||||
public OidcAuthService(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
UsersContext usersContext,
|
||||
ILogger<OidcAuthService> logger)
|
||||
{
|
||||
_httpClient = httpClientFactory.CreateClient("OidcAuth");
|
||||
_usersContext = usersContext;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<OidcAuthorizationResult> StartAuthorization(string redirectUri, string? initiatorUserId = null)
|
||||
{
|
||||
var oidcConfig = await GetOidcConfig();
|
||||
|
||||
if (!oidcConfig.Enabled)
|
||||
{
|
||||
throw new InvalidOperationException("OIDC is not enabled");
|
||||
}
|
||||
|
||||
if (PendingFlows.Count >= MaxPendingFlows)
|
||||
{
|
||||
throw new InvalidOperationException("Too many pending OIDC flows. Please try again later.");
|
||||
}
|
||||
|
||||
var discovery = await GetDiscoveryDocument(oidcConfig.IssuerUrl);
|
||||
|
||||
var state = GenerateRandomString();
|
||||
var nonce = GenerateRandomString();
|
||||
var codeVerifier = GenerateRandomString();
|
||||
var codeChallenge = ComputeCodeChallenge(codeVerifier);
|
||||
|
||||
var flowState = new OidcFlowState
|
||||
{
|
||||
State = state,
|
||||
Nonce = nonce,
|
||||
CodeVerifier = codeVerifier,
|
||||
RedirectUri = redirectUri,
|
||||
InitiatorUserId = initiatorUserId,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
if (!PendingFlows.TryAdd(state, flowState))
|
||||
{
|
||||
throw new InvalidOperationException("Failed to store OIDC flow state");
|
||||
}
|
||||
|
||||
var authUrl = BuildAuthorizationUrl(
|
||||
discovery.AuthorizationEndpoint,
|
||||
oidcConfig.ClientId,
|
||||
redirectUri,
|
||||
oidcConfig.Scopes,
|
||||
state,
|
||||
nonce,
|
||||
codeChallenge);
|
||||
|
||||
_logger.LogDebug("OIDC authorization started with state {State}", state);
|
||||
|
||||
return new OidcAuthorizationResult
|
||||
{
|
||||
AuthorizationUrl = authUrl,
|
||||
State = state
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<OidcCallbackResult> HandleCallback(string code, string state, string redirectUri)
|
||||
{
|
||||
if (!PendingFlows.TryGetValue(state, out var flowState))
|
||||
{
|
||||
_logger.LogWarning("OIDC callback with invalid or expired state: {State}", state);
|
||||
return new OidcCallbackResult
|
||||
{
|
||||
Success = false,
|
||||
Error = "Invalid or expired OIDC state"
|
||||
};
|
||||
}
|
||||
|
||||
if (DateTime.UtcNow - flowState.CreatedAt > FlowStateExpiry)
|
||||
{
|
||||
PendingFlows.TryRemove(state, out _);
|
||||
_logger.LogWarning("OIDC flow state expired for state: {State}", state);
|
||||
return new OidcCallbackResult
|
||||
{
|
||||
Success = false,
|
||||
Error = "OIDC flow has expired"
|
||||
};
|
||||
}
|
||||
|
||||
if (flowState.RedirectUri != redirectUri)
|
||||
{
|
||||
_logger.LogWarning("OIDC callback redirect URI mismatch. Expected: {Expected}, Got: {Got}",
|
||||
flowState.RedirectUri, redirectUri);
|
||||
return new OidcCallbackResult
|
||||
{
|
||||
Success = false,
|
||||
Error = "Redirect URI mismatch"
|
||||
};
|
||||
}
|
||||
|
||||
// Validation passed — consume the state
|
||||
PendingFlows.TryRemove(state, out _);
|
||||
|
||||
var oidcConfig = await GetOidcConfig();
|
||||
var discovery = await GetDiscoveryDocument(oidcConfig.IssuerUrl);
|
||||
|
||||
// Exchange authorization code for tokens
|
||||
var tokenResponse = await ExchangeCodeForTokens(
|
||||
discovery.TokenEndpoint,
|
||||
code,
|
||||
flowState.CodeVerifier,
|
||||
redirectUri,
|
||||
oidcConfig.ClientId,
|
||||
oidcConfig.ClientSecret);
|
||||
|
||||
if (tokenResponse is null)
|
||||
{
|
||||
return new OidcCallbackResult
|
||||
{
|
||||
Success = false,
|
||||
Error = "Failed to exchange authorization code"
|
||||
};
|
||||
}
|
||||
|
||||
// Validate the ID token
|
||||
var validatedToken = await ValidateIdToken(
|
||||
tokenResponse.IdToken,
|
||||
oidcConfig,
|
||||
discovery,
|
||||
flowState.Nonce);
|
||||
|
||||
if (validatedToken is null)
|
||||
{
|
||||
return new OidcCallbackResult
|
||||
{
|
||||
Success = false,
|
||||
Error = "ID token validation failed"
|
||||
};
|
||||
}
|
||||
|
||||
var subject = validatedToken.Claims.FirstOrDefault(c => c.Type == "sub")?.Value;
|
||||
var preferredUsername = validatedToken.Claims.FirstOrDefault(c => c.Type == "preferred_username")?.Value;
|
||||
var email = validatedToken.Claims.FirstOrDefault(c => c.Type == "email")?.Value;
|
||||
|
||||
if (string.IsNullOrEmpty(subject))
|
||||
{
|
||||
return new OidcCallbackResult
|
||||
{
|
||||
Success = false,
|
||||
Error = "ID token missing 'sub' claim"
|
||||
};
|
||||
}
|
||||
|
||||
_logger.LogInformation("OIDC authentication successful for subject: {Subject}", subject);
|
||||
|
||||
return new OidcCallbackResult
|
||||
{
|
||||
Success = true,
|
||||
Subject = subject,
|
||||
PreferredUsername = preferredUsername,
|
||||
Email = email,
|
||||
InitiatorUserId = flowState.InitiatorUserId
|
||||
};
|
||||
}
|
||||
|
||||
public string StoreOneTimeCode(string accessToken, string refreshToken, int expiresIn)
|
||||
{
|
||||
// Clean up if at capacity
|
||||
if (OneTimeCodes.Count >= MaxOneTimeCodes)
|
||||
{
|
||||
CleanupExpiredOneTimeCodes();
|
||||
|
||||
// If still at capacity after cleanup, evict oldest entries
|
||||
while (OneTimeCodes.Count >= MaxOneTimeCodes)
|
||||
{
|
||||
var oldest = OneTimeCodes.OrderBy(x => x.Value.CreatedAt).FirstOrDefault();
|
||||
if (oldest.Key is not null)
|
||||
{
|
||||
OneTimeCodes.TryRemove(oldest.Key, out _);
|
||||
}
|
||||
else
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var entry = new OidcOneTimeCodeEntry
|
||||
{
|
||||
AccessToken = accessToken,
|
||||
RefreshToken = refreshToken,
|
||||
ExpiresIn = expiresIn,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
// Retry with new codes on collision
|
||||
for (var i = 0; i < 3; i++)
|
||||
{
|
||||
var code = GenerateRandomString();
|
||||
if (OneTimeCodes.TryAdd(code, entry))
|
||||
{
|
||||
return code;
|
||||
}
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("Failed to generate a unique one-time code");
|
||||
}
|
||||
|
||||
public OidcTokenExchangeResult? ExchangeOneTimeCode(string code)
|
||||
{
|
||||
if (!OneTimeCodes.TryRemove(code, out var entry))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (DateTime.UtcNow - entry.CreatedAt > OneTimeCodeExpiry)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new OidcTokenExchangeResult
|
||||
{
|
||||
AccessToken = entry.AccessToken,
|
||||
RefreshToken = entry.RefreshToken,
|
||||
ExpiresIn = entry.ExpiresIn
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<OidcConfig> GetOidcConfig()
|
||||
{
|
||||
var user = await _usersContext.Users.AsNoTracking().FirstOrDefaultAsync();
|
||||
return user?.Oidc ?? new OidcConfig();
|
||||
}
|
||||
|
||||
private async Task<OpenIdConnectConfiguration> GetDiscoveryDocument(string issuerUrl)
|
||||
{
|
||||
var metadataAddress = issuerUrl.TrimEnd('/') + "/.well-known/openid-configuration";
|
||||
|
||||
var configManager = ConfigManagers.GetOrAdd(issuerUrl, _ =>
|
||||
{
|
||||
var isLocalhost = Uri.TryCreate(issuerUrl, UriKind.Absolute, out var uri) &&
|
||||
uri.Host is "localhost" or "127.0.0.1" or "::1" or "[::1]";
|
||||
return new ConfigurationManager<OpenIdConnectConfiguration>(
|
||||
metadataAddress,
|
||||
new OpenIdConnectConfigurationRetriever(),
|
||||
new HttpDocumentRetriever(_httpClient) { RequireHttps = !isLocalhost });
|
||||
});
|
||||
|
||||
return await configManager.GetConfigurationAsync();
|
||||
}
|
||||
|
||||
private async Task<OidcTokenResponse?> ExchangeCodeForTokens(
|
||||
string tokenEndpoint,
|
||||
string code,
|
||||
string codeVerifier,
|
||||
string redirectUri,
|
||||
string clientId,
|
||||
string clientSecret)
|
||||
{
|
||||
var parameters = new Dictionary<string, string>
|
||||
{
|
||||
["grant_type"] = "authorization_code",
|
||||
["code"] = code,
|
||||
["redirect_uri"] = redirectUri,
|
||||
["client_id"] = clientId,
|
||||
["code_verifier"] = codeVerifier
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(clientSecret))
|
||||
{
|
||||
parameters["client_secret"] = clientSecret;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, tokenEndpoint)
|
||||
{
|
||||
Content = new FormUrlEncodedContent(parameters)
|
||||
};
|
||||
|
||||
var response = await _httpClient.SendAsync(request);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorBody = await response.Content.ReadAsStringAsync();
|
||||
_logger.LogError("OIDC token exchange failed with status {Status}: {Body}",
|
||||
response.StatusCode, errorBody);
|
||||
return null;
|
||||
}
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<OidcTokenResponse>();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "OIDC token exchange failed");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<JwtSecurityToken?> ValidateIdToken(
|
||||
string idToken,
|
||||
OidcConfig oidcConfig,
|
||||
OpenIdConnectConfiguration discovery,
|
||||
string expectedNonce)
|
||||
{
|
||||
var handler = new JwtSecurityTokenHandler();
|
||||
|
||||
var validationParameters = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = true,
|
||||
ValidIssuers = new[]
|
||||
{
|
||||
oidcConfig.IssuerUrl.TrimEnd('/'),
|
||||
oidcConfig.IssuerUrl.TrimEnd('/') + "/"
|
||||
},
|
||||
ValidateAudience = true,
|
||||
ValidAudience = oidcConfig.ClientId,
|
||||
ValidateLifetime = true,
|
||||
ValidateIssuerSigningKey = true,
|
||||
// Bypass lifetime validation
|
||||
IssuerSigningKeyValidator = (_, _, _) => true,
|
||||
IssuerSigningKeys = discovery.SigningKeys,
|
||||
ClockSkew = TimeSpan.FromMinutes(2)
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
handler.ValidateToken(idToken, validationParameters, out var validatedSecurityToken);
|
||||
var jwtToken = (JwtSecurityToken)validatedSecurityToken;
|
||||
|
||||
return ValidateNonce(jwtToken, expectedNonce) ? jwtToken : null;
|
||||
}
|
||||
catch (SecurityTokenSignatureKeyNotFoundException)
|
||||
{
|
||||
// Try refreshing the configuration (JWKS key rotation)
|
||||
_logger.LogInformation("OIDC signing key not found, refreshing configuration");
|
||||
|
||||
if (ConfigManagers.TryGetValue(oidcConfig.IssuerUrl, out var configManager))
|
||||
{
|
||||
configManager.RequestRefresh();
|
||||
var refreshedConfig = await configManager.GetConfigurationAsync();
|
||||
validationParameters.IssuerSigningKeys = refreshedConfig.SigningKeys;
|
||||
|
||||
try
|
||||
{
|
||||
handler.ValidateToken(idToken, validationParameters, out var retryToken);
|
||||
var jwtRetryToken = (JwtSecurityToken)retryToken;
|
||||
|
||||
return ValidateNonce(jwtRetryToken, expectedNonce) ? jwtRetryToken : null;
|
||||
}
|
||||
catch (Exception retryEx)
|
||||
{
|
||||
_logger.LogError(retryEx, "OIDC ID token validation failed after key refresh");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "OIDC ID token validation failed");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildAuthorizationUrl(
|
||||
string authorizationEndpoint,
|
||||
string clientId,
|
||||
string redirectUri,
|
||||
string scopes,
|
||||
string state,
|
||||
string nonce,
|
||||
string codeChallenge)
|
||||
{
|
||||
var queryParams = new Dictionary<string, string>
|
||||
{
|
||||
["response_type"] = "code",
|
||||
["client_id"] = clientId,
|
||||
["redirect_uri"] = redirectUri,
|
||||
["scope"] = scopes,
|
||||
["state"] = state,
|
||||
["nonce"] = nonce,
|
||||
["code_challenge"] = codeChallenge,
|
||||
["code_challenge_method"] = "S256"
|
||||
};
|
||||
|
||||
var queryString = string.Join("&",
|
||||
queryParams.Select(kvp => $"{Uri.EscapeDataString(kvp.Key)}={Uri.EscapeDataString(kvp.Value)}"));
|
||||
|
||||
return $"{authorizationEndpoint}?{queryString}";
|
||||
}
|
||||
|
||||
private bool ValidateNonce(JwtSecurityToken jwtToken, string expectedNonce)
|
||||
{
|
||||
var tokenNonce = jwtToken.Claims.FirstOrDefault(c => c.Type == "nonce")?.Value;
|
||||
if (tokenNonce == expectedNonce) return true;
|
||||
|
||||
_logger.LogWarning("OIDC ID token nonce mismatch. Expected: {Expected}, Got: {Got}",
|
||||
expectedNonce, tokenNonce);
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string GenerateRandomString()
|
||||
{
|
||||
var bytes = new byte[32];
|
||||
using var rng = RandomNumberGenerator.Create();
|
||||
rng.GetBytes(bytes);
|
||||
return Base64UrlEncode(bytes);
|
||||
}
|
||||
|
||||
private static string ComputeCodeChallenge(string codeVerifier)
|
||||
{
|
||||
var bytes = SHA256.HashData(Encoding.ASCII.GetBytes(codeVerifier));
|
||||
return Base64UrlEncode(bytes);
|
||||
}
|
||||
|
||||
private static string Base64UrlEncode(byte[] bytes)
|
||||
{
|
||||
return Convert.ToBase64String(bytes)
|
||||
.TrimEnd('=')
|
||||
.Replace('+', '-')
|
||||
.Replace('/', '_');
|
||||
}
|
||||
|
||||
private static void CleanupExpiredEntries(object? state)
|
||||
{
|
||||
var flowCutoff = DateTime.UtcNow - FlowStateExpiry;
|
||||
foreach (var kvp in PendingFlows)
|
||||
{
|
||||
if (kvp.Value.CreatedAt < flowCutoff)
|
||||
{
|
||||
PendingFlows.TryRemove(kvp.Key, out _);
|
||||
}
|
||||
}
|
||||
|
||||
CleanupExpiredOneTimeCodes();
|
||||
}
|
||||
|
||||
private static void CleanupExpiredOneTimeCodes()
|
||||
{
|
||||
var codeCutoff = DateTime.UtcNow - OneTimeCodeExpiry;
|
||||
foreach (var kvp in OneTimeCodes)
|
||||
{
|
||||
if (kvp.Value.CreatedAt < codeCutoff)
|
||||
{
|
||||
OneTimeCodes.TryRemove(kvp.Key, out _);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears the cached OIDC discovery configuration. Used when issuer URL changes.
|
||||
/// </summary>
|
||||
public static void ClearDiscoveryCache()
|
||||
{
|
||||
ConfigManagers.Clear();
|
||||
}
|
||||
|
||||
private sealed class OidcFlowState
|
||||
{
|
||||
public required string State { get; init; }
|
||||
public required string Nonce { get; init; }
|
||||
public required string CodeVerifier { get; init; }
|
||||
public required string RedirectUri { get; init; }
|
||||
public string? InitiatorUserId { get; init; }
|
||||
public required DateTime CreatedAt { get; init; }
|
||||
}
|
||||
|
||||
private sealed class OidcOneTimeCodeEntry
|
||||
{
|
||||
public required string AccessToken { get; init; }
|
||||
public required string RefreshToken { get; init; }
|
||||
public required int ExpiresIn { get; init; }
|
||||
public required DateTime CreatedAt { get; init; }
|
||||
}
|
||||
|
||||
private sealed class OidcTokenResponse
|
||||
{
|
||||
[System.Text.Json.Serialization.JsonPropertyName("id_token")]
|
||||
public string IdToken { get; set; } = string.Empty;
|
||||
|
||||
[System.Text.Json.Serialization.JsonPropertyName("access_token")]
|
||||
public string AccessToken { get; set; } = string.Empty;
|
||||
|
||||
[System.Text.Json.Serialization.JsonPropertyName("token_type")]
|
||||
public string TokenType { get; set; } = string.Empty;
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,11 @@ public sealed class PasswordService : IPasswordService
|
||||
{
|
||||
private const int WorkFactor = 12;
|
||||
|
||||
/// <summary>
|
||||
/// Pre-computed BCrypt hash with a work factor of 12 used as a fallback when no user exists
|
||||
/// </summary>
|
||||
public string DummyHash => "$2a$12$tQw4MgGGq7WTFro3Me4mQOekctJ0mIOYmFMn.XEmEbyZhBq0i4qKy";
|
||||
|
||||
public string HashPassword(string password)
|
||||
{
|
||||
return BCrypt.Net.BCrypt.HashPassword(password, WorkFactor);
|
||||
|
||||
-8
@@ -68,7 +68,6 @@ public partial class DelugeService
|
||||
BlocklistType blocklistType = _blocklistProvider.GetBlocklistType(instanceType);
|
||||
ConcurrentBag<string> patterns = _blocklistProvider.GetPatterns(instanceType);
|
||||
ConcurrentBag<Regex> regexes = _blocklistProvider.GetRegexes(instanceType);
|
||||
ConcurrentBag<string> malwarePatterns = _blocklistProvider.GetMalwarePatterns();
|
||||
|
||||
ProcessFiles(contents.Contents, (name, file) =>
|
||||
{
|
||||
@@ -79,13 +78,6 @@ public partial class DelugeService
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (malwareBlockerConfig.DeleteKnownMalware && _filenameEvaluator.IsKnownMalware(name, malwarePatterns))
|
||||
{
|
||||
_logger.LogInformation("malware file found | {file} | {title}", file.Path, download.Name);
|
||||
result.ShouldRemove = true;
|
||||
result.DeleteReason = DeleteReason.MalwareFileFound;
|
||||
}
|
||||
|
||||
if (file.Priority is 0)
|
||||
{
|
||||
|
||||
+1
-10
@@ -73,8 +73,7 @@ public partial class QBitService
|
||||
BlocklistType blocklistType = _blocklistProvider.GetBlocklistType(instanceType);
|
||||
ConcurrentBag<string> patterns = _blocklistProvider.GetPatterns(instanceType);
|
||||
ConcurrentBag<Regex> regexes = _blocklistProvider.GetRegexes(instanceType);
|
||||
ConcurrentBag<string> malwarePatterns = _blocklistProvider.GetMalwarePatterns();
|
||||
|
||||
|
||||
foreach (TorrentContent file in files)
|
||||
{
|
||||
if (!file.Index.HasValue)
|
||||
@@ -84,14 +83,6 @@ public partial class QBitService
|
||||
}
|
||||
|
||||
totalFiles++;
|
||||
|
||||
if (malwareBlockerConfig.DeleteKnownMalware && _filenameEvaluator.IsKnownMalware(file.Name, malwarePatterns))
|
||||
{
|
||||
_logger.LogInformation("malware file found | {file} | {title}", file.Name, download.Name);
|
||||
result.ShouldRemove = true;
|
||||
result.DeleteReason = DeleteReason.MalwareFileFound;
|
||||
return result;
|
||||
}
|
||||
|
||||
if (file.Priority is TorrentContentPriority.Skip)
|
||||
{
|
||||
|
||||
-8
@@ -71,7 +71,6 @@ public partial class RTorrentService
|
||||
BlocklistType blocklistType = _blocklistProvider.GetBlocklistType(instanceType);
|
||||
ConcurrentBag<string> patterns = _blocklistProvider.GetPatterns(instanceType);
|
||||
ConcurrentBag<Regex> regexes = _blocklistProvider.GetRegexes(instanceType);
|
||||
ConcurrentBag<string> malwarePatterns = _blocklistProvider.GetMalwarePatterns();
|
||||
|
||||
List<(int Index, int Priority)> priorityUpdates = [];
|
||||
|
||||
@@ -85,13 +84,6 @@ public partial class RTorrentService
|
||||
continue;
|
||||
}
|
||||
|
||||
if (malwareBlockerConfig.DeleteKnownMalware && _filenameEvaluator.IsKnownMalware(fileName, malwarePatterns))
|
||||
{
|
||||
_logger.LogInformation("malware file found | {file} | {title}", file.Path, download.Name);
|
||||
result.ShouldRemove = true;
|
||||
result.DeleteReason = DeleteReason.MalwareFileFound;
|
||||
}
|
||||
|
||||
if (file.Priority == 0)
|
||||
{
|
||||
_logger.LogTrace("File is already skipped | {file}", file.Path);
|
||||
|
||||
+1
-3
@@ -33,9 +33,7 @@ public sealed class TransmissionItemWrapper : ITorrentItemWrapper
|
||||
|
||||
public long DownloadSpeed => Info.RateDownload ?? 0;
|
||||
|
||||
public double Ratio => (Info.UploadedEver ?? 0) > 0 && (Info.DownloadedEver ?? 0) > 0
|
||||
? (Info.UploadedEver ?? 0) / (double)(Info.DownloadedEver ?? 1)
|
||||
: 0.0;
|
||||
public double Ratio => Info.uploadRatio ?? 0.0;
|
||||
|
||||
public long Eta => Info.Eta ?? 0;
|
||||
|
||||
|
||||
+2
-11
@@ -56,8 +56,7 @@ public partial class TransmissionService
|
||||
BlocklistType blocklistType = _blocklistProvider.GetBlocklistType(instanceType);
|
||||
ConcurrentBag<string> patterns = _blocklistProvider.GetPatterns(instanceType);
|
||||
ConcurrentBag<Regex> regexes = _blocklistProvider.GetRegexes(instanceType);
|
||||
ConcurrentBag<string> malwarePatterns = _blocklistProvider.GetMalwarePatterns();
|
||||
|
||||
|
||||
for (int i = 0; i < download.Files.Length; i++)
|
||||
{
|
||||
if (download.FileStats?[i].Wanted == null)
|
||||
@@ -67,15 +66,7 @@ public partial class TransmissionService
|
||||
}
|
||||
|
||||
totalFiles++;
|
||||
|
||||
if (malwareBlockerConfig.DeleteKnownMalware && _filenameEvaluator.IsKnownMalware(download.Files[i].Name, malwarePatterns))
|
||||
{
|
||||
_logger.LogInformation("malware file found | {file} | {title}", download.Files[i].Name, download.Name);
|
||||
result.ShouldRemove = true;
|
||||
result.DeleteReason = DeleteReason.MalwareFileFound;
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
if (!download.FileStats[i].Wanted.Value)
|
||||
{
|
||||
_logger.LogTrace("File is already skipped | {file}", download.Files[i].Name);
|
||||
|
||||
-9
@@ -61,18 +61,9 @@ public partial class UTorrentService
|
||||
BlocklistType blocklistType = _blocklistProvider.GetBlocklistType(instanceType);
|
||||
ConcurrentBag<string> patterns = _blocklistProvider.GetPatterns(instanceType);
|
||||
ConcurrentBag<Regex> regexes = _blocklistProvider.GetRegexes(instanceType);
|
||||
ConcurrentBag<string> malwarePatterns = _blocklistProvider.GetMalwarePatterns();
|
||||
|
||||
for (int i = 0; i < files.Count; i++)
|
||||
{
|
||||
if (malwareBlockerConfig.DeleteKnownMalware && _filenameEvaluator.IsKnownMalware(files[i].Name, malwarePatterns))
|
||||
{
|
||||
_logger.LogInformation("malware file found | {file} | {title}", files[i].Name, download.Name);
|
||||
result.ShouldRemove = true;
|
||||
result.DeleteReason = DeleteReason.MalwareFileFound;
|
||||
return result;
|
||||
}
|
||||
|
||||
var file = files[i];
|
||||
|
||||
if (file.Priority == 0) // Already skipped
|
||||
|
||||
+2
@@ -21,4 +21,6 @@ public sealed record QueueItemRemoveRequest<T>
|
||||
public required DeleteReason DeleteReason { get; init; }
|
||||
|
||||
public required Guid JobRunId { get; init; }
|
||||
|
||||
public bool SkipSearch { get; init; }
|
||||
}
|
||||
@@ -73,15 +73,20 @@ public sealed class QueueItemRemover : IQueueItemRemover
|
||||
ContextProvider.Set(nameof(InstanceType), request.InstanceType);
|
||||
ContextProvider.Set(ContextProvider.Keys.Version, request.Instance.Version);
|
||||
|
||||
// Use the new centralized EventPublisher method
|
||||
await _eventPublisher.PublishQueueItemDeleted(request.RemoveFromClient, request.DeleteReason);
|
||||
|
||||
// If recurring, do not search for replacement
|
||||
string hash = request.Record.DownloadId.ToLowerInvariant();
|
||||
if (Striker.RecurringHashes.ContainsKey(hash))
|
||||
var isRecurring = Striker.RecurringHashes.ContainsKey(hash);
|
||||
|
||||
if (isRecurring || request.SkipSearch)
|
||||
{
|
||||
await _eventPublisher.PublishSearchNotTriggered(request.Record.DownloadId, request.Record.Title);
|
||||
Striker.RecurringHashes.Remove(hash, out _);
|
||||
|
||||
if (isRecurring)
|
||||
{
|
||||
Striker.RecurringHashes.Remove(hash, out _);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Collections.Concurrent;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Events.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.Context;
|
||||
using Cleanuparr.Infrastructure.Interceptors;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.State;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -14,14 +15,16 @@ public sealed class Striker : IStriker
|
||||
private readonly ILogger<Striker> _logger;
|
||||
private readonly EventsContext _context;
|
||||
private readonly IEventPublisher _eventPublisher;
|
||||
private readonly IDryRunInterceptor _dryRunInterceptor;
|
||||
|
||||
public static readonly ConcurrentDictionary<string, string?> RecurringHashes = [];
|
||||
|
||||
public Striker(ILogger<Striker> logger, EventsContext context, IEventPublisher eventPublisher)
|
||||
public Striker(ILogger<Striker> logger, EventsContext context, IEventPublisher eventPublisher, IDryRunInterceptor dryRunInterceptor)
|
||||
{
|
||||
_logger = logger;
|
||||
_context = context;
|
||||
_eventPublisher = eventPublisher;
|
||||
_dryRunInterceptor = dryRunInterceptor;
|
||||
}
|
||||
|
||||
public async Task<bool> StrikeAndCheckLimit(string hash, string itemName, ushort maxStrikes, StrikeType strikeType, long? lastDownloadedBytes = null)
|
||||
@@ -37,12 +40,15 @@ public sealed class Striker : IStriker
|
||||
int existingStrikeCount = await _context.Strikes
|
||||
.CountAsync(s => s.DownloadItemId == downloadItem.Id && s.Type == strikeType);
|
||||
|
||||
bool isDryRun = await _dryRunInterceptor.IsDryRunEnabled();
|
||||
|
||||
var strike = new Strike
|
||||
{
|
||||
DownloadItemId = downloadItem.Id,
|
||||
JobRunId = ContextProvider.GetJobRunId(),
|
||||
Type = strikeType,
|
||||
LastDownloadedBytes = lastDownloadedBytes
|
||||
LastDownloadedBytes = lastDownloadedBytes,
|
||||
IsDryRun = isDryRun
|
||||
};
|
||||
_context.Strikes.Add(strike);
|
||||
|
||||
|
||||
@@ -131,7 +131,8 @@ public abstract class GenericHandler : IHandler
|
||||
QueueRecord record,
|
||||
bool isPack,
|
||||
bool removeFromClient,
|
||||
DeleteReason deleteReason
|
||||
DeleteReason deleteReason,
|
||||
bool skipSearch = false
|
||||
)
|
||||
{
|
||||
if (_cache.TryGetValue(downloadRemovalKey, out bool _))
|
||||
@@ -139,7 +140,7 @@ public abstract class GenericHandler : IHandler
|
||||
_logger.LogDebug("skip removal request | already marked for removal | {title}", record.Title);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if (instanceType is InstanceType.Sonarr || (instanceType is InstanceType.Whisparr && instance.Version is 2))
|
||||
{
|
||||
QueueItemRemoveRequest<SeriesSearchItem> removeRequest = new()
|
||||
@@ -150,7 +151,8 @@ public abstract class GenericHandler : IHandler
|
||||
SearchItem = (SeriesSearchItem)GetRecordSearchItem(instanceType, instance.Version, record, isPack),
|
||||
RemoveFromClient = removeFromClient,
|
||||
DeleteReason = deleteReason,
|
||||
JobRunId = ContextProvider.GetJobRunId()
|
||||
JobRunId = ContextProvider.GetJobRunId(),
|
||||
SkipSearch = skipSearch
|
||||
};
|
||||
|
||||
await _messageBus.Publish(removeRequest);
|
||||
@@ -165,7 +167,8 @@ public abstract class GenericHandler : IHandler
|
||||
SearchItem = GetRecordSearchItem(instanceType, instance.Version, record, isPack),
|
||||
RemoveFromClient = removeFromClient,
|
||||
DeleteReason = deleteReason,
|
||||
JobRunId = ContextProvider.GetJobRunId()
|
||||
JobRunId = ContextProvider.GetJobRunId(),
|
||||
SkipSearch = skipSearch
|
||||
};
|
||||
|
||||
await _messageBus.Publish(removeRequest);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
using Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Events.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
|
||||
@@ -48,9 +48,13 @@ public sealed class MalwareBlocker : GenericHandler
|
||||
return;
|
||||
}
|
||||
|
||||
var config = ContextProvider.Get<ContentBlockerConfig>();
|
||||
ContentBlockerConfig malwareBlockerConfig = ContextProvider.Get<ContentBlockerConfig>();
|
||||
|
||||
if (!config.Sonarr.Enabled && !config.Radarr.Enabled && !config.Lidarr.Enabled && !config.Readarr.Enabled && !config.Whisparr.Enabled)
|
||||
if (!malwareBlockerConfig.Sonarr.Enabled &&
|
||||
!malwareBlockerConfig.Radarr.Enabled &&
|
||||
!malwareBlockerConfig.Lidarr.Enabled &&
|
||||
!malwareBlockerConfig.Readarr.Enabled &&
|
||||
!malwareBlockerConfig.Whisparr.Enabled)
|
||||
{
|
||||
_logger.LogWarning("No blocklists are enabled");
|
||||
return;
|
||||
@@ -64,27 +68,27 @@ public sealed class MalwareBlocker : GenericHandler
|
||||
var readarrConfig = ContextProvider.Get<ArrConfig>(nameof(InstanceType.Readarr));
|
||||
var whisparrConfig = ContextProvider.Get<ArrConfig>(nameof(InstanceType.Whisparr));
|
||||
|
||||
if (config.Sonarr.Enabled || config.DeleteKnownMalware)
|
||||
if (malwareBlockerConfig.Sonarr.Enabled)
|
||||
{
|
||||
await ProcessArrConfigAsync(sonarrConfig);
|
||||
}
|
||||
|
||||
if (config.Radarr.Enabled || config.DeleteKnownMalware)
|
||||
|
||||
if (malwareBlockerConfig.Radarr.Enabled)
|
||||
{
|
||||
await ProcessArrConfigAsync(radarrConfig);
|
||||
}
|
||||
|
||||
if (config.Lidarr.Enabled || config.DeleteKnownMalware)
|
||||
|
||||
if (malwareBlockerConfig.Lidarr.Enabled)
|
||||
{
|
||||
await ProcessArrConfigAsync(lidarrConfig);
|
||||
}
|
||||
|
||||
if (config.Readarr.Enabled || config.DeleteKnownMalware)
|
||||
|
||||
if (malwareBlockerConfig.Readarr.Enabled)
|
||||
{
|
||||
await ProcessArrConfigAsync(readarrConfig);
|
||||
}
|
||||
|
||||
if (config.Whisparr.Enabled || config.DeleteKnownMalware)
|
||||
|
||||
if (malwareBlockerConfig.Whisparr.Enabled)
|
||||
{
|
||||
await ProcessArrConfigAsync(whisparrConfig);
|
||||
}
|
||||
@@ -107,33 +111,43 @@ public sealed class MalwareBlocker : GenericHandler
|
||||
|
||||
IReadOnlyList<IDownloadService> downloadServices = await GetInitializedDownloadServicesAsync();
|
||||
|
||||
var config = ContextProvider.Get<ContentBlockerConfig>();
|
||||
|
||||
await _arrArrQueueIterator.Iterate(arrClient, instance, async items =>
|
||||
{
|
||||
var groups = items
|
||||
.GroupBy(x => x.DownloadId)
|
||||
.ToList();
|
||||
|
||||
|
||||
foreach (var group in groups)
|
||||
{
|
||||
if (group.Any(x => !arrClient.IsRecordValid(x)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
QueueRecord record = group.First();
|
||||
|
||||
_logger.LogTrace("processing | {title} | {id}", record.Title, record.DownloadId);
|
||||
|
||||
|
||||
if (!arrClient.IsRecordValid(record))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
if (ignoredDownloads.Contains(record.DownloadId, StringComparer.InvariantCultureIgnoreCase))
|
||||
{
|
||||
_logger.LogInformation("skip | {title} | ignored", record.Title);
|
||||
continue;
|
||||
}
|
||||
|
||||
_logger.LogTrace("processing | {title} | {id}", record.Title, record.DownloadId);
|
||||
|
||||
bool hasContentId = arrClient.HasContentId(record);
|
||||
|
||||
if (!hasContentId)
|
||||
{
|
||||
if (!config.ProcessNoContentId)
|
||||
{
|
||||
_logger.LogInformation("skip | item is missing the content id | {title}", record.Title);
|
||||
continue;
|
||||
}
|
||||
|
||||
_logger.LogDebug("item is missing the content id | {title}", record.Title);
|
||||
}
|
||||
|
||||
string downloadRemovalKey = CacheKeys.DownloadMarkedForRemoval(record.DownloadId, instance.Url);
|
||||
|
||||
@@ -196,8 +210,6 @@ public sealed class MalwareBlocker : GenericHandler
|
||||
continue;
|
||||
}
|
||||
|
||||
var config = ContextProvider.Get<ContentBlockerConfig>();
|
||||
|
||||
bool removeFromClient = true;
|
||||
|
||||
if (result.IsPrivate && !config.DeletePrivate)
|
||||
@@ -212,7 +224,8 @@ public sealed class MalwareBlocker : GenericHandler
|
||||
record,
|
||||
group.Count() > 1,
|
||||
removeFromClient,
|
||||
result.DeleteReason
|
||||
result.DeleteReason,
|
||||
skipSearch: !hasContentId
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -106,18 +106,11 @@ public sealed class QueueCleaner : GenericHandler
|
||||
var groups = items
|
||||
.GroupBy(x => x.DownloadId)
|
||||
.ToList();
|
||||
|
||||
|
||||
foreach (var group in groups)
|
||||
{
|
||||
if (group.Any(x => !arrClient.IsRecordValid(x)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
QueueRecord record = group.First();
|
||||
|
||||
_logger.LogTrace("processing | {title} | {id}", record.Title, record.DownloadId);
|
||||
|
||||
|
||||
if (!arrClient.IsRecordValid(record))
|
||||
{
|
||||
continue;
|
||||
@@ -129,6 +122,21 @@ public sealed class QueueCleaner : GenericHandler
|
||||
continue;
|
||||
}
|
||||
|
||||
_logger.LogDebug("processing | {title} | {id}", record.Title, record.DownloadId);
|
||||
|
||||
bool hasContentId = arrClient.HasContentId(record);
|
||||
|
||||
if (!hasContentId)
|
||||
{
|
||||
if (!queueCleanerConfig.ProcessNoContentId)
|
||||
{
|
||||
_logger.LogInformation("skip | item is missing the content id | {title}", record.Title);
|
||||
continue;
|
||||
}
|
||||
|
||||
_logger.LogDebug("item is missing the content id | {title}", record.Title);
|
||||
}
|
||||
|
||||
string downloadRemovalKey = CacheKeys.DownloadMarkedForRemoval(record.DownloadId, instance.Url);
|
||||
|
||||
if (_cache.TryGetValue(downloadRemovalKey, out bool _))
|
||||
@@ -190,7 +198,8 @@ public sealed class QueueCleaner : GenericHandler
|
||||
record,
|
||||
group.Count() > 1,
|
||||
removeFromClient,
|
||||
downloadCheckResult.DeleteReason
|
||||
downloadCheckResult.DeleteReason,
|
||||
skipSearch: !hasContentId
|
||||
);
|
||||
|
||||
continue;
|
||||
@@ -218,7 +227,8 @@ public sealed class QueueCleaner : GenericHandler
|
||||
record,
|
||||
group.Count() > 1,
|
||||
removeFromClient,
|
||||
DeleteReason.FailedImport
|
||||
DeleteReason.FailedImport,
|
||||
skipSearch: !hasContentId
|
||||
);
|
||||
|
||||
continue;
|
||||
|
||||
+4
-54
@@ -23,8 +23,6 @@ public sealed class BlocklistProvider : IBlocklistProvider
|
||||
private readonly Dictionary<string, DateTime> _lastLoadTimes = new();
|
||||
private const int DefaultLoadIntervalHours = 4;
|
||||
private const int FastLoadIntervalMinutes = 5;
|
||||
private const string MalwareListUrl = "https://cleanuparr.pages.dev/static/known_malware_file_name_patterns";
|
||||
private const string MalwareListKey = "MALWARE_PATTERNS";
|
||||
|
||||
public BlocklistProvider(
|
||||
ILogger<BlocklistProvider> logger,
|
||||
@@ -72,10 +70,7 @@ public sealed class BlocklistProvider : IBlocklistProvider
|
||||
changedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Always check and update malware patterns
|
||||
await LoadMalwarePatternsAsync(fileReader);
|
||||
|
||||
|
||||
if (changedCount > 0)
|
||||
{
|
||||
_logger.LogInformation("Successfully loaded {count} blocklists", changedCount);
|
||||
@@ -109,17 +104,10 @@ public sealed class BlocklistProvider : IBlocklistProvider
|
||||
public ConcurrentBag<Regex> GetRegexes(InstanceType instanceType)
|
||||
{
|
||||
_cache.TryGetValue(CacheKeys.BlocklistRegexes(instanceType), out ConcurrentBag<Regex>? regexes);
|
||||
|
||||
|
||||
return regexes ?? [];
|
||||
}
|
||||
|
||||
public ConcurrentBag<string> GetMalwarePatterns()
|
||||
{
|
||||
_cache.TryGetValue(CacheKeys.KnownMalwarePatterns(), out ConcurrentBag<string>? patterns);
|
||||
|
||||
return patterns ?? [];
|
||||
}
|
||||
|
||||
|
||||
private async Task<bool> EnsureInstanceLoadedAsync(BlocklistSettings settings, InstanceType instanceType, FileReader fileReader)
|
||||
{
|
||||
if (!settings.Enabled || string.IsNullOrEmpty(settings.BlocklistPath))
|
||||
@@ -165,47 +153,9 @@ public sealed class BlocklistProvider : IBlocklistProvider
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
return DateTime.UtcNow - lastLoad >= interval;
|
||||
}
|
||||
|
||||
private async Task LoadMalwarePatternsAsync(FileReader fileReader)
|
||||
{
|
||||
var malwareInterval = TimeSpan.FromMinutes(FastLoadIntervalMinutes);
|
||||
|
||||
if (!ShouldReloadBlocklist(MalwareListKey, malwareInterval))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_logger.LogDebug("Loading malware patterns");
|
||||
|
||||
string[] filePatterns = await fileReader.ReadContentAsync(MalwareListUrl);
|
||||
|
||||
long startTime = Stopwatch.GetTimestamp();
|
||||
ParallelOptions options = new() { MaxDegreeOfParallelism = 5 };
|
||||
ConcurrentBag<string> patterns = [];
|
||||
|
||||
Parallel.ForEach(filePatterns, options, pattern =>
|
||||
{
|
||||
patterns.Add(pattern);
|
||||
});
|
||||
|
||||
TimeSpan elapsed = Stopwatch.GetElapsedTime(startTime);
|
||||
|
||||
_cache.Set(CacheKeys.KnownMalwarePatterns(), patterns);
|
||||
_lastLoadTimes[MalwareListKey] = DateTime.UtcNow;
|
||||
|
||||
_logger.LogDebug("loaded {count} known malware patterns", patterns.Count);
|
||||
_logger.LogDebug("malware patterns loaded in {elapsed} ms", elapsed.TotalMilliseconds);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to load malware patterns from {url}", MalwareListUrl);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadPatternsAndRegexesAsync(BlocklistSettings blocklistSettings, InstanceType instanceType, FileReader fileReader)
|
||||
{
|
||||
|
||||
@@ -20,16 +20,6 @@ public class FilenameEvaluator : IFilenameEvaluator
|
||||
return IsValidAgainstPatterns(filename, type, patterns) && IsValidAgainstRegexes(filename, type, regexes);
|
||||
}
|
||||
|
||||
public bool IsKnownMalware(string filename, ConcurrentBag<string> malwarePatterns)
|
||||
{
|
||||
if (malwarePatterns.Count is 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return malwarePatterns.Any(pattern => filename.Contains(pattern, StringComparison.InvariantCultureIgnoreCase));
|
||||
}
|
||||
|
||||
private static bool IsValidAgainstPatterns(string filename, BlocklistType type, ConcurrentBag<string> patterns)
|
||||
{
|
||||
if (patterns.Count is 0)
|
||||
|
||||
@@ -13,6 +13,4 @@ public interface IBlocklistProvider
|
||||
ConcurrentBag<string> GetPatterns(InstanceType instanceType);
|
||||
|
||||
ConcurrentBag<Regex> GetRegexes(InstanceType instanceType);
|
||||
|
||||
ConcurrentBag<string> GetMalwarePatterns();
|
||||
}
|
||||
@@ -7,6 +7,4 @@ namespace Cleanuparr.Infrastructure.Features.MalwareBlocker;
|
||||
public interface IFilenameEvaluator
|
||||
{
|
||||
bool IsValid(string filename, BlocklistType type, ConcurrentBag<string> patterns, ConcurrentBag<Regex> regexes);
|
||||
|
||||
bool IsKnownMalware(string filename, ConcurrentBag<string> malwarePatterns);
|
||||
}
|
||||
@@ -8,8 +8,6 @@ public static class CacheKeys
|
||||
public static string BlocklistPatterns(InstanceType instanceType) => $"{instanceType.ToString()}_patterns";
|
||||
public static string BlocklistRegexes(InstanceType instanceType) => $"{instanceType.ToString()}_regexes";
|
||||
|
||||
public static string KnownMalwarePatterns() => "KNOWN_MALWARE_PATTERNS";
|
||||
|
||||
public static string IgnoredDownloads(string name) => $"{name}_ignored";
|
||||
|
||||
public static string DownloadMarkedForRemoval(string hash, Uri url) => $"remove_{hash.ToLowerInvariant()}_{url}";
|
||||
|
||||
@@ -78,4 +78,13 @@ public class DryRunInterceptor : IDryRunInterceptor
|
||||
|
||||
return default;
|
||||
}
|
||||
|
||||
public async Task<bool> IsDryRunEnabled()
|
||||
{
|
||||
var config = await _dataContext.GeneralConfigs
|
||||
.AsNoTracking()
|
||||
.FirstAsync();
|
||||
|
||||
return config.DryRun;
|
||||
}
|
||||
}
|
||||
@@ -7,4 +7,6 @@ public interface IDryRunInterceptor
|
||||
Task InterceptAsync(Delegate action, params object[] parameters);
|
||||
|
||||
Task<T?> InterceptAsync<T>(Delegate action, params object[] parameters);
|
||||
|
||||
Task<bool> IsDryRunEnabled();
|
||||
}
|
||||
+357
@@ -0,0 +1,357 @@
|
||||
using Cleanuparr.Persistence.Models.Auth;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
|
||||
|
||||
namespace Cleanuparr.Persistence.Tests.Models.Auth;
|
||||
|
||||
public sealed class OidcConfigTests
|
||||
{
|
||||
#region Validate - Disabled Config
|
||||
|
||||
[Fact]
|
||||
public void Validate_Disabled_WithEmptyFields_DoesNotThrow()
|
||||
{
|
||||
var config = new OidcConfig
|
||||
{
|
||||
Enabled = false,
|
||||
IssuerUrl = string.Empty,
|
||||
ClientId = string.Empty
|
||||
};
|
||||
|
||||
Should.NotThrow(() => config.Validate());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_Disabled_WithPopulatedFields_DoesNotThrow()
|
||||
{
|
||||
var config = new OidcConfig
|
||||
{
|
||||
Enabled = false,
|
||||
IssuerUrl = "https://auth.example.com",
|
||||
ClientId = "my-client"
|
||||
};
|
||||
|
||||
Should.NotThrow(() => config.Validate());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Validate - Issuer URL
|
||||
|
||||
[Fact]
|
||||
public void Validate_Enabled_ValidHttpsIssuerUrl_DoesNotThrow()
|
||||
{
|
||||
var config = new OidcConfig
|
||||
{
|
||||
Enabled = true,
|
||||
IssuerUrl = "https://auth.example.com/application/o/cleanuparr/",
|
||||
ClientId = "my-client",
|
||||
ProviderName = "Authentik"
|
||||
};
|
||||
|
||||
Should.NotThrow(() => config.Validate());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void Validate_Enabled_EmptyIssuerUrl_ThrowsValidationException(string issuerUrl)
|
||||
{
|
||||
var config = new OidcConfig
|
||||
{
|
||||
Enabled = true,
|
||||
IssuerUrl = issuerUrl,
|
||||
ClientId = "my-client"
|
||||
};
|
||||
|
||||
var exception = Should.Throw<ValidationException>(() => config.Validate());
|
||||
exception.Message.ShouldBe("OIDC Issuer URL is required when OIDC is enabled");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_Enabled_InvalidIssuerUrl_ThrowsValidationException()
|
||||
{
|
||||
var config = new OidcConfig
|
||||
{
|
||||
Enabled = true,
|
||||
IssuerUrl = "not-a-valid-url",
|
||||
ClientId = "my-client"
|
||||
};
|
||||
|
||||
var exception = Should.Throw<ValidationException>(() => config.Validate());
|
||||
exception.Message.ShouldBe("OIDC Issuer URL must be a valid absolute URL");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_Enabled_HttpIssuerUrl_ThrowsValidationException()
|
||||
{
|
||||
var config = new OidcConfig
|
||||
{
|
||||
Enabled = true,
|
||||
IssuerUrl = "http://auth.example.com",
|
||||
ClientId = "my-client"
|
||||
};
|
||||
|
||||
var exception = Should.Throw<ValidationException>(() => config.Validate());
|
||||
exception.Message.ShouldBe("OIDC Issuer URL must use HTTPS");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("http://localhost:8080/auth")]
|
||||
[InlineData("http://127.0.0.1:9000/auth")]
|
||||
[InlineData("http://[::1]:9000/auth")]
|
||||
public void Validate_Enabled_HttpLocalhostIssuerUrl_DoesNotThrow(string issuerUrl)
|
||||
{
|
||||
var config = new OidcConfig
|
||||
{
|
||||
Enabled = true,
|
||||
IssuerUrl = issuerUrl,
|
||||
ClientId = "my-client",
|
||||
ProviderName = "Dev"
|
||||
};
|
||||
|
||||
Should.NotThrow(() => config.Validate());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_Enabled_IssuerUrlWithTrailingSlash_DoesNotThrow()
|
||||
{
|
||||
var config = new OidcConfig
|
||||
{
|
||||
Enabled = true,
|
||||
IssuerUrl = "https://auth.example.com/",
|
||||
ClientId = "my-client",
|
||||
ProviderName = "Authentik"
|
||||
};
|
||||
|
||||
Should.NotThrow(() => config.Validate());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Validate - Client ID
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void Validate_Enabled_EmptyClientId_ThrowsValidationException(string clientId)
|
||||
{
|
||||
var config = new OidcConfig
|
||||
{
|
||||
Enabled = true,
|
||||
IssuerUrl = "https://auth.example.com",
|
||||
ClientId = clientId
|
||||
};
|
||||
|
||||
var exception = Should.Throw<ValidationException>(() => config.Validate());
|
||||
exception.Message.ShouldBe("OIDC Client ID is required when OIDC is enabled");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Validate - Provider Name
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void Validate_Enabled_EmptyProviderName_ThrowsValidationException(string providerName)
|
||||
{
|
||||
var config = new OidcConfig
|
||||
{
|
||||
Enabled = true,
|
||||
IssuerUrl = "https://auth.example.com",
|
||||
ClientId = "my-client",
|
||||
ProviderName = providerName
|
||||
};
|
||||
|
||||
var exception = Should.Throw<ValidationException>(() => config.Validate());
|
||||
exception.Message.ShouldBe("OIDC Provider Name is required when OIDC is enabled");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Validate - Full Valid Configs
|
||||
|
||||
[Fact]
|
||||
public void Validate_Enabled_ValidFullConfig_DoesNotThrow()
|
||||
{
|
||||
var config = new OidcConfig
|
||||
{
|
||||
Enabled = true,
|
||||
IssuerUrl = "https://authentik.example.com/application/o/cleanuparr/",
|
||||
ClientId = "cleanuparr-client-id",
|
||||
ClientSecret = "my-secret",
|
||||
Scopes = "openid profile email",
|
||||
AuthorizedSubject = "user-123-abc",
|
||||
ProviderName = "Authentik"
|
||||
};
|
||||
|
||||
Should.NotThrow(() => config.Validate());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_Enabled_WithoutClientSecret_DoesNotThrow()
|
||||
{
|
||||
var config = new OidcConfig
|
||||
{
|
||||
Enabled = true,
|
||||
IssuerUrl = "https://auth.example.com",
|
||||
ClientId = "my-client",
|
||||
ClientSecret = string.Empty,
|
||||
ProviderName = "Keycloak"
|
||||
};
|
||||
|
||||
Should.NotThrow(() => config.Validate());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Default Values
|
||||
|
||||
[Fact]
|
||||
public void DefaultValues_AreCorrect()
|
||||
{
|
||||
var config = new OidcConfig();
|
||||
|
||||
config.Enabled.ShouldBeFalse();
|
||||
config.IssuerUrl.ShouldBe(string.Empty);
|
||||
config.ClientId.ShouldBe(string.Empty);
|
||||
config.ClientSecret.ShouldBe(string.Empty);
|
||||
config.Scopes.ShouldBe("openid profile email");
|
||||
config.AuthorizedSubject.ShouldBe(string.Empty);
|
||||
config.ProviderName.ShouldBe("OIDC");
|
||||
config.ExclusiveMode.ShouldBeFalse();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Validate - Exclusive Mode
|
||||
|
||||
[Fact]
|
||||
public void Validate_ExclusiveMode_WhenOidcDisabled_Throws()
|
||||
{
|
||||
var config = new OidcConfig
|
||||
{
|
||||
Enabled = false,
|
||||
ExclusiveMode = true,
|
||||
AuthorizedSubject = "some-subject"
|
||||
};
|
||||
|
||||
var exception = Should.Throw<ValidationException>(() => config.Validate());
|
||||
exception.Message.ShouldBe("OIDC must be enabled to use exclusive mode");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_ExclusiveMode_WithoutAuthorizedSubject_DoesNotThrow()
|
||||
{
|
||||
var config = new OidcConfig
|
||||
{
|
||||
Enabled = true,
|
||||
ExclusiveMode = true,
|
||||
IssuerUrl = "https://auth.example.com",
|
||||
ClientId = "my-client",
|
||||
ProviderName = "Test",
|
||||
AuthorizedSubject = string.Empty
|
||||
};
|
||||
|
||||
Should.NotThrow(() => config.Validate());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_ExclusiveMode_FullyConfigured_DoesNotThrow()
|
||||
{
|
||||
var config = new OidcConfig
|
||||
{
|
||||
Enabled = true,
|
||||
ExclusiveMode = true,
|
||||
IssuerUrl = "https://auth.example.com",
|
||||
ClientId = "my-client",
|
||||
ProviderName = "Test",
|
||||
AuthorizedSubject = "user-123"
|
||||
};
|
||||
|
||||
Should.NotThrow(() => config.Validate());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_ExclusiveModeFalse_OidcDisabled_DoesNotThrow()
|
||||
{
|
||||
var config = new OidcConfig
|
||||
{
|
||||
Enabled = false,
|
||||
ExclusiveMode = false
|
||||
};
|
||||
|
||||
Should.NotThrow(() => config.Validate());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void Validate_ExclusiveMode_WhitespaceAuthorizedSubject_DoesNotThrow(string subject)
|
||||
{
|
||||
var config = new OidcConfig
|
||||
{
|
||||
Enabled = true,
|
||||
ExclusiveMode = true,
|
||||
IssuerUrl = "https://auth.example.com",
|
||||
ClientId = "my-client",
|
||||
ProviderName = "Test",
|
||||
AuthorizedSubject = subject
|
||||
};
|
||||
|
||||
Should.NotThrow(() => config.Validate());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Additional Edge Cases
|
||||
|
||||
[Fact]
|
||||
public void Validate_Enabled_HttpLocalhostWithoutPort_DoesNotThrow()
|
||||
{
|
||||
var config = new OidcConfig
|
||||
{
|
||||
Enabled = true,
|
||||
IssuerUrl = "http://localhost/auth",
|
||||
ClientId = "my-client",
|
||||
ProviderName = "Dev"
|
||||
};
|
||||
|
||||
Should.NotThrow(() => config.Validate());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_Enabled_ScopesWithoutOpenid_StillPasses()
|
||||
{
|
||||
// Documenting current behavior: the Validate method does not enforce "openid" in scopes.
|
||||
// This is intentional — the IdP will reject if openid is missing, giving a clear error.
|
||||
var config = new OidcConfig
|
||||
{
|
||||
Enabled = true,
|
||||
IssuerUrl = "https://auth.example.com",
|
||||
ClientId = "my-client",
|
||||
Scopes = "profile email",
|
||||
ProviderName = "Test"
|
||||
};
|
||||
|
||||
Should.NotThrow(() => config.Validate());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_Enabled_FtpScheme_ThrowsValidationException()
|
||||
{
|
||||
var config = new OidcConfig
|
||||
{
|
||||
Enabled = true,
|
||||
IssuerUrl = "ftp://auth.example.com",
|
||||
ClientId = "my-client"
|
||||
};
|
||||
|
||||
var exception = Should.Throw<ValidationException>(() => config.Validate());
|
||||
exception.Message.ShouldBe("OIDC Issuer URL must use HTTPS");
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
+1312
File diff suppressed because it is too large.
Load diff
+40
@@ -0,0 +1,40 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Cleanuparr.Persistence.Migrations.Data
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddProcessMissingContentId : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "process_no_content_id",
|
||||
table: "queue_cleaner_configs",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "process_no_content_id",
|
||||
table: "content_blocker_configs",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "process_no_content_id",
|
||||
table: "queue_cleaner_configs");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "process_no_content_id",
|
||||
table: "content_blocker_configs");
|
||||
}
|
||||
}
|
||||
}
|
||||
+1300
File diff suppressed because it is too large.
Load diff
+29
@@ -0,0 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Cleanuparr.Persistence.Migrations.Data
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class RemoveKnownMalwareOption : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "delete_known_malware",
|
||||
table: "content_blocker_configs");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "delete_known_malware",
|
||||
table: "content_blocker_configs",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
}
|
||||
}
|
||||
}
|
||||
+1300
File diff suppressed because it is too large.
Load diff
+40
@@ -0,0 +1,40 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Cleanuparr.Persistence.Migrations.Data
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class RemoveProcessMissingId : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "process_no_content_id",
|
||||
table: "queue_cleaner_configs");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "process_no_content_id",
|
||||
table: "content_blocker_configs");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "process_no_content_id",
|
||||
table: "queue_cleaner_configs",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "process_no_content_id",
|
||||
table: "content_blocker_configs",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+1308
File diff suppressed because it is too large.
Load diff
+40
@@ -0,0 +1,40 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Cleanuparr.Persistence.Migrations.Data
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddProcessMissingId : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "process_no_content_id",
|
||||
table: "queue_cleaner_configs",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "process_no_content_id",
|
||||
table: "content_blocker_configs",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "process_no_content_id",
|
||||
table: "queue_cleaner_configs");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "process_no_content_id",
|
||||
table: "content_blocker_configs");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -389,10 +389,6 @@ namespace Cleanuparr.Persistence.Migrations.Data
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("cron_expression");
|
||||
|
||||
b.Property<bool>("DeleteKnownMalware")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("delete_known_malware");
|
||||
|
||||
b.Property<bool>("DeletePrivate")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("delete_private");
|
||||
@@ -410,6 +406,10 @@ namespace Cleanuparr.Persistence.Migrations.Data
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("ignored_downloads");
|
||||
|
||||
b.Property<bool>("ProcessNoContentId")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("process_no_content_id");
|
||||
|
||||
b.Property<bool>("UseAdvancedScheduling")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("use_advanced_scheduling");
|
||||
@@ -920,6 +920,10 @@ namespace Cleanuparr.Persistence.Migrations.Data
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("ignored_downloads");
|
||||
|
||||
b.Property<bool>("ProcessNoContentId")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("process_no_content_id");
|
||||
|
||||
b.Property<bool>("UseAdvancedScheduling")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("use_advanced_scheduling");
|
||||
|
||||
Generated
+390
@@ -0,0 +1,390 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Cleanuparr.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Cleanuparr.Persistence.Migrations.Events
|
||||
{
|
||||
[DbContext(typeof(EventsContext))]
|
||||
[Migration("20260319195316_AddIsDryRun")]
|
||||
partial class AddIsDryRun
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.1");
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Events.AppEvent", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("Data")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("data");
|
||||
|
||||
b.Property<string>("DownloadClientName")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("download_client_name");
|
||||
|
||||
b.Property<string>("DownloadClientType")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("download_client_type");
|
||||
|
||||
b.Property<string>("EventType")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("event_type");
|
||||
|
||||
b.Property<string>("InstanceType")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("instance_type");
|
||||
|
||||
b.Property<string>("InstanceUrl")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("instance_url");
|
||||
|
||||
b.Property<bool>("IsDryRun")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("is_dry_run");
|
||||
|
||||
b.Property<Guid?>("JobRunId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("job_run_id");
|
||||
|
||||
b.Property<string>("Message")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("message");
|
||||
|
||||
b.Property<string>("Severity")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("severity");
|
||||
|
||||
b.Property<Guid?>("StrikeId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("strike_id");
|
||||
|
||||
b.Property<DateTime>("Timestamp")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("timestamp");
|
||||
|
||||
b.Property<Guid?>("TrackingId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("tracking_id");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_events");
|
||||
|
||||
b.HasIndex("DownloadClientType")
|
||||
.HasDatabaseName("ix_events_download_client_type");
|
||||
|
||||
b.HasIndex("EventType")
|
||||
.HasDatabaseName("ix_events_event_type");
|
||||
|
||||
b.HasIndex("InstanceType")
|
||||
.HasDatabaseName("ix_events_instance_type");
|
||||
|
||||
b.HasIndex("JobRunId")
|
||||
.HasDatabaseName("ix_events_job_run_id");
|
||||
|
||||
b.HasIndex("Message")
|
||||
.HasDatabaseName("ix_events_message");
|
||||
|
||||
b.HasIndex("Severity")
|
||||
.HasDatabaseName("ix_events_severity");
|
||||
|
||||
b.HasIndex("StrikeId")
|
||||
.HasDatabaseName("ix_events_strike_id");
|
||||
|
||||
b.HasIndex("Timestamp")
|
||||
.IsDescending()
|
||||
.HasDatabaseName("ix_events_timestamp");
|
||||
|
||||
b.ToTable("events", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Events.ManualEvent", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("Data")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("data");
|
||||
|
||||
b.Property<string>("DownloadClientName")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("download_client_name");
|
||||
|
||||
b.Property<string>("DownloadClientType")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("download_client_type");
|
||||
|
||||
b.Property<string>("InstanceType")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("instance_type");
|
||||
|
||||
b.Property<string>("InstanceUrl")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("instance_url");
|
||||
|
||||
b.Property<bool>("IsDryRun")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("is_dry_run");
|
||||
|
||||
b.Property<bool>("IsResolved")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("is_resolved");
|
||||
|
||||
b.Property<Guid?>("JobRunId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("job_run_id");
|
||||
|
||||
b.Property<string>("Message")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("message");
|
||||
|
||||
b.Property<string>("Severity")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("severity");
|
||||
|
||||
b.Property<DateTime>("Timestamp")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("timestamp");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_manual_events");
|
||||
|
||||
b.HasIndex("InstanceType")
|
||||
.HasDatabaseName("ix_manual_events_instance_type");
|
||||
|
||||
b.HasIndex("IsResolved")
|
||||
.HasDatabaseName("ix_manual_events_is_resolved");
|
||||
|
||||
b.HasIndex("JobRunId")
|
||||
.HasDatabaseName("ix_manual_events_job_run_id");
|
||||
|
||||
b.HasIndex("Message")
|
||||
.HasDatabaseName("ix_manual_events_message");
|
||||
|
||||
b.HasIndex("Severity")
|
||||
.HasDatabaseName("ix_manual_events_severity");
|
||||
|
||||
b.HasIndex("Timestamp")
|
||||
.IsDescending()
|
||||
.HasDatabaseName("ix_manual_events_timestamp");
|
||||
|
||||
b.ToTable("manual_events", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.State.DownloadItem", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("DownloadId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("download_id");
|
||||
|
||||
b.Property<bool>("IsMarkedForRemoval")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("is_marked_for_removal");
|
||||
|
||||
b.Property<bool>("IsRemoved")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("is_removed");
|
||||
|
||||
b.Property<bool>("IsReturning")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("is_returning");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("title");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_download_items");
|
||||
|
||||
b.HasIndex("DownloadId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_download_items_download_id");
|
||||
|
||||
b.ToTable("download_items", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.State.JobRun", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<DateTime?>("CompletedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("completed_at");
|
||||
|
||||
b.Property<DateTime>("StartedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("started_at");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("status");
|
||||
|
||||
b.Property<string>("Type")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("type");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_job_runs");
|
||||
|
||||
b.HasIndex("StartedAt")
|
||||
.IsDescending()
|
||||
.HasDatabaseName("ix_job_runs_started_at");
|
||||
|
||||
b.HasIndex("Type")
|
||||
.HasDatabaseName("ix_job_runs_type");
|
||||
|
||||
b.ToTable("job_runs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.State.Strike", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<Guid>("DownloadItemId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("download_item_id");
|
||||
|
||||
b.Property<bool>("IsDryRun")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("is_dry_run");
|
||||
|
||||
b.Property<Guid>("JobRunId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("job_run_id");
|
||||
|
||||
b.Property<long?>("LastDownloadedBytes")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("last_downloaded_bytes");
|
||||
|
||||
b.Property<string>("Type")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("type");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_strikes");
|
||||
|
||||
b.HasIndex("CreatedAt")
|
||||
.HasDatabaseName("ix_strikes_created_at");
|
||||
|
||||
b.HasIndex("JobRunId")
|
||||
.HasDatabaseName("ix_strikes_job_run_id");
|
||||
|
||||
b.HasIndex("DownloadItemId", "Type")
|
||||
.HasDatabaseName("ix_strikes_download_item_id_type");
|
||||
|
||||
b.ToTable("strikes", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Events.AppEvent", b =>
|
||||
{
|
||||
b.HasOne("Cleanuparr.Persistence.Models.State.JobRun", "JobRun")
|
||||
.WithMany("Events")
|
||||
.HasForeignKey("JobRunId")
|
||||
.HasConstraintName("fk_events_job_runs_job_run_id");
|
||||
|
||||
b.HasOne("Cleanuparr.Persistence.Models.State.Strike", "Strike")
|
||||
.WithMany()
|
||||
.HasForeignKey("StrikeId")
|
||||
.OnDelete(DeleteBehavior.SetNull)
|
||||
.HasConstraintName("fk_events_strikes_strike_id");
|
||||
|
||||
b.Navigation("JobRun");
|
||||
|
||||
b.Navigation("Strike");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Events.ManualEvent", b =>
|
||||
{
|
||||
b.HasOne("Cleanuparr.Persistence.Models.State.JobRun", "JobRun")
|
||||
.WithMany("ManualEvents")
|
||||
.HasForeignKey("JobRunId")
|
||||
.HasConstraintName("fk_manual_events_job_runs_job_run_id");
|
||||
|
||||
b.Navigation("JobRun");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.State.Strike", b =>
|
||||
{
|
||||
b.HasOne("Cleanuparr.Persistence.Models.State.DownloadItem", "DownloadItem")
|
||||
.WithMany("Strikes")
|
||||
.HasForeignKey("DownloadItemId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_strikes_download_items_download_item_id");
|
||||
|
||||
b.HasOne("Cleanuparr.Persistence.Models.State.JobRun", "JobRun")
|
||||
.WithMany("Strikes")
|
||||
.HasForeignKey("JobRunId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_strikes_job_runs_job_run_id");
|
||||
|
||||
b.Navigation("DownloadItem");
|
||||
|
||||
b.Navigation("JobRun");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.State.DownloadItem", b =>
|
||||
{
|
||||
b.Navigation("Strikes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.State.JobRun", b =>
|
||||
{
|
||||
b.Navigation("Events");
|
||||
|
||||
b.Navigation("ManualEvents");
|
||||
|
||||
b.Navigation("Strikes");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Cleanuparr.Persistence.Migrations.Events
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddIsDryRun : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "is_dry_run",
|
||||
table: "strikes",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "is_dry_run",
|
||||
table: "manual_events",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "is_dry_run",
|
||||
table: "events",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "is_dry_run",
|
||||
table: "strikes");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "is_dry_run",
|
||||
table: "manual_events");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "is_dry_run",
|
||||
table: "events");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -51,6 +51,10 @@ namespace Cleanuparr.Persistence.Migrations.Events
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("instance_url");
|
||||
|
||||
b.Property<bool>("IsDryRun")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("is_dry_run");
|
||||
|
||||
b.Property<Guid?>("JobRunId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("job_run_id");
|
||||
@@ -138,6 +142,10 @@ namespace Cleanuparr.Persistence.Migrations.Events
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("instance_url");
|
||||
|
||||
b.Property<bool>("IsDryRun")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("is_dry_run");
|
||||
|
||||
b.Property<bool>("IsResolved")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("is_resolved");
|
||||
@@ -279,6 +287,10 @@ namespace Cleanuparr.Persistence.Migrations.Events
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("download_item_id");
|
||||
|
||||
b.Property<bool>("IsDryRun")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("is_dry_run");
|
||||
|
||||
b.Property<Guid>("JobRunId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("job_run_id");
|
||||
|
||||
Generated
+271
@@ -0,0 +1,271 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Cleanuparr.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Cleanuparr.Persistence.Migrations.Users
|
||||
{
|
||||
[DbContext(typeof(UsersContext))]
|
||||
[Migration("20260312090408_AddOidcSupport")]
|
||||
partial class AddOidcSupport
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.1");
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Auth.RecoveryCode", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("CodeHash")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("code_hash");
|
||||
|
||||
b.Property<bool>("IsUsed")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("is_used");
|
||||
|
||||
b.Property<DateTime?>("UsedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("used_at");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("user_id");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_recovery_codes");
|
||||
|
||||
b.HasIndex("UserId")
|
||||
.HasDatabaseName("ix_recovery_codes_user_id");
|
||||
|
||||
b.ToTable("recovery_codes", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Auth.RefreshToken", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<DateTime>("ExpiresAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("expires_at");
|
||||
|
||||
b.Property<DateTime?>("RevokedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("revoked_at");
|
||||
|
||||
b.Property<string>("TokenHash")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("token_hash");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("user_id");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_refresh_tokens");
|
||||
|
||||
b.HasIndex("TokenHash")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_refresh_tokens_token_hash");
|
||||
|
||||
b.HasIndex("UserId")
|
||||
.HasDatabaseName("ix_refresh_tokens_user_id");
|
||||
|
||||
b.ToTable("refresh_tokens", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Auth.User", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("ApiKey")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("api_key");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<int>("FailedLoginAttempts")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("failed_login_attempts");
|
||||
|
||||
b.Property<DateTime?>("LockoutEnd")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("lockout_end");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("password_hash");
|
||||
|
||||
b.Property<string>("PlexAccountId")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("plex_account_id");
|
||||
|
||||
b.Property<string>("PlexAuthToken")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("plex_auth_token");
|
||||
|
||||
b.Property<string>("PlexEmail")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("plex_email");
|
||||
|
||||
b.Property<string>("PlexUsername")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("plex_username");
|
||||
|
||||
b.Property<bool>("SetupCompleted")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("setup_completed");
|
||||
|
||||
b.Property<bool>("TotpEnabled")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("totp_enabled");
|
||||
|
||||
b.Property<string>("TotpSecret")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("totp_secret");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.Property<string>("Username")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("username");
|
||||
|
||||
b.ComplexProperty(typeof(Dictionary<string, object>), "Oidc", "Cleanuparr.Persistence.Models.Auth.User.Oidc#OidcConfig", b1 =>
|
||||
{
|
||||
b1.IsRequired();
|
||||
|
||||
b1.Property<string>("AuthorizedSubject")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("oidc_authorized_subject");
|
||||
|
||||
b1.Property<string>("ClientId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("oidc_client_id");
|
||||
|
||||
b1.Property<string>("ClientSecret")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("oidc_client_secret");
|
||||
|
||||
b1.Property<bool>("Enabled")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("oidc_enabled");
|
||||
|
||||
b1.Property<bool>("ExclusiveMode")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("oidc_exclusive_mode");
|
||||
|
||||
b1.Property<string>("IssuerUrl")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("oidc_issuer_url");
|
||||
|
||||
b1.Property<string>("ProviderName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("oidc_provider_name");
|
||||
|
||||
b1.Property<string>("RedirectUrl")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("oidc_redirect_url");
|
||||
|
||||
b1.Property<string>("Scopes")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("oidc_scopes");
|
||||
});
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_users");
|
||||
|
||||
b.HasIndex("ApiKey")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_users_api_key");
|
||||
|
||||
b.HasIndex("Username")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_users_username");
|
||||
|
||||
b.ToTable("users", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Auth.RecoveryCode", b =>
|
||||
{
|
||||
b.HasOne("Cleanuparr.Persistence.Models.Auth.User", "User")
|
||||
.WithMany("RecoveryCodes")
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_recovery_codes_users_user_id");
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Auth.RefreshToken", b =>
|
||||
{
|
||||
b.HasOne("Cleanuparr.Persistence.Models.Auth.User", "User")
|
||||
.WithMany("RefreshTokens")
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_refresh_tokens_users_user_id");
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Auth.User", b =>
|
||||
{
|
||||
b.Navigation("RecoveryCodes");
|
||||
|
||||
b.Navigation("RefreshTokens");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Cleanuparr.Persistence.Migrations.Users
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddOidcSupport : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "oidc_authorized_subject",
|
||||
table: "users",
|
||||
type: "TEXT",
|
||||
maxLength: 500,
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "oidc_client_id",
|
||||
table: "users",
|
||||
type: "TEXT",
|
||||
maxLength: 200,
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "oidc_client_secret",
|
||||
table: "users",
|
||||
type: "TEXT",
|
||||
maxLength: 500,
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "oidc_enabled",
|
||||
table: "users",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "oidc_exclusive_mode",
|
||||
table: "users",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "oidc_issuer_url",
|
||||
table: "users",
|
||||
type: "TEXT",
|
||||
maxLength: 500,
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "oidc_provider_name",
|
||||
table: "users",
|
||||
type: "TEXT",
|
||||
maxLength: 100,
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "oidc_redirect_url",
|
||||
table: "users",
|
||||
type: "TEXT",
|
||||
maxLength: 500,
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "oidc_scopes",
|
||||
table: "users",
|
||||
type: "TEXT",
|
||||
maxLength: 500,
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "oidc_authorized_subject",
|
||||
table: "users");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "oidc_client_id",
|
||||
table: "users");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "oidc_client_secret",
|
||||
table: "users");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "oidc_enabled",
|
||||
table: "users");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "oidc_exclusive_mode",
|
||||
table: "users");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "oidc_issuer_url",
|
||||
table: "users");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "oidc_provider_name",
|
||||
table: "users");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "oidc_redirect_url",
|
||||
table: "users");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "oidc_scopes",
|
||||
table: "users");
|
||||
}
|
||||
}
|
||||
}
|
||||
Loaded 100 of 171 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user